fix(#1): Address code review findings
- Convert ApiResponse to discriminated union for type-safe error handling - Add HealthStatus type with HealthState literal union - Make BaseEntity fields readonly for immutability - Add GlobalExceptionFilter with structured logging - Add port validation with clear error messages in main.ts - Improve parseDate to log warnings for invalid dates - Add comprehensive Button tests (variants, onClick, disabled) - Add slugify edge case tests (empty, special chars, numbers) - Create ESLint configs for all packages - Remove compiled JS files from src directories - Convert .prettierrc.js to .prettierrc.json Refs #1 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -7,6 +7,16 @@ dist
|
||||
.next
|
||||
.turbo
|
||||
|
||||
# Compiled source (prevent accidental commits)
|
||||
apps/*/src/**/*.js
|
||||
apps/*/src/**/*.d.ts
|
||||
apps/*/src/**/*.js.map
|
||||
apps/*/src/**/*.d.ts.map
|
||||
packages/*/src/**/*.js
|
||||
packages/*/src/**/*.d.ts
|
||||
packages/*/src/**/*.js.map
|
||||
packages/*/src/**/*.d.ts.map
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import config from "@mosaic/config/prettier";
|
||||
|
||||
export default config;
|
||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
16
apps/api/eslint.config.js
Normal file
16
apps/api/eslint.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import nestjsConfig from "@mosaic/config/eslint/nestjs";
|
||||
|
||||
export default [
|
||||
...nestjsConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/**", "node_modules/**", "**/*.test.ts", "**/*.spec.ts"],
|
||||
},
|
||||
];
|
||||
14
apps/api/src/app.controller.d.ts
vendored
14
apps/api/src/app.controller.d.ts
vendored
@@ -1,14 +0,0 @@
|
||||
import { AppService } from "./app.service";
|
||||
import type { ApiResponse } from "@mosaic/shared/types";
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
}
|
||||
export declare class AppController {
|
||||
private readonly appService;
|
||||
constructor(appService: AppService);
|
||||
getHello(): string;
|
||||
getHealth(): ApiResponse<HealthStatus>;
|
||||
}
|
||||
export {};
|
||||
//# sourceMappingURL=app.controller.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"app.controller.d.ts","sourceRoot":"","sources":["app.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAC3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAExD,UAAU,YAAY;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,qBACa,aAAa;IACZ,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,UAAU;IAGnD,QAAQ,IAAI,MAAM;IAKlB,SAAS,IAAI,WAAW,CAAC,YAAY,CAAC;CASvC"}
|
||||
@@ -1,50 +0,0 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
var __metadata = (this && this.__metadata) || function (k, v) {
|
||||
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AppController = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const app_service_1 = require("./app.service");
|
||||
let AppController = class AppController {
|
||||
appService;
|
||||
constructor(appService) {
|
||||
this.appService = appService;
|
||||
}
|
||||
getHello() {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
getHealth() {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
exports.AppController = AppController;
|
||||
__decorate([
|
||||
(0, common_1.Get)(),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", String)
|
||||
], AppController.prototype, "getHello", null);
|
||||
__decorate([
|
||||
(0, common_1.Get)("health"),
|
||||
__metadata("design:type", Function),
|
||||
__metadata("design:paramtypes", []),
|
||||
__metadata("design:returntype", Object)
|
||||
], AppController.prototype, "getHealth", null);
|
||||
exports.AppController = AppController = __decorate([
|
||||
(0, common_1.Controller)(),
|
||||
__metadata("design:paramtypes", [app_service_1.AppService])
|
||||
], AppController);
|
||||
//# sourceMappingURL=app.controller.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"app.controller.js","sourceRoot":"","sources":["app.controller.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,2CAAiD;AACjD,+CAA2C;AASpC,IAAM,aAAa,GAAnB,MAAM,aAAa;IACK;IAA7B,YAA6B,UAAsB;QAAtB,eAAU,GAAV,UAAU,CAAY;IAAG,CAAC;IAGvD,QAAQ;QACN,OAAO,IAAI,CAAC,UAAU,CAAC,QAAQ,EAAE,CAAC;IACpC,CAAC;IAGD,SAAS;QACP,OAAO;YACL,OAAO,EAAE,IAAI;YACb,IAAI,EAAE;gBACJ,MAAM,EAAE,SAAS;gBACjB,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;aACpC;SACF,CAAC;IACJ,CAAC;CACF,CAAA;AAlBY,sCAAa;AAIxB;IADC,IAAA,YAAG,GAAE;;;;6CAGL;AAGD;IADC,IAAA,YAAG,EAAC,QAAQ,CAAC;;;;8CASb;wBAjBU,aAAa;IADzB,IAAA,mBAAU,GAAE;qCAE8B,wBAAU;GADxC,aAAa,CAkBzB"}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { AppService } from "./app.service";
|
||||
import type { ApiResponse } from "@mosaic/shared";
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
}
|
||||
import type { ApiResponse, HealthStatus } from "@mosaic/shared";
|
||||
import { successResponse } from "@mosaic/shared";
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
@@ -18,12 +14,9 @@ export class AppController {
|
||||
|
||||
@Get("health")
|
||||
getHealth(): ApiResponse<HealthStatus> {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
return successResponse({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/api/src/app.module.d.ts
vendored
3
apps/api/src/app.module.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
export declare class AppModule {
|
||||
}
|
||||
//# sourceMappingURL=app.module.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"app.module.d.ts","sourceRoot":"","sources":["app.module.ts"],"names":[],"mappings":"AAIA,qBAKa,SAAS;CAAG"}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AppModule = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
const app_controller_1 = require("./app.controller");
|
||||
const app_service_1 = require("./app.service");
|
||||
let AppModule = class AppModule {
|
||||
};
|
||||
exports.AppModule = AppModule;
|
||||
exports.AppModule = AppModule = __decorate([
|
||||
(0, common_1.Module)({
|
||||
imports: [],
|
||||
controllers: [app_controller_1.AppController],
|
||||
providers: [app_service_1.AppService],
|
||||
})
|
||||
], AppModule);
|
||||
//# sourceMappingURL=app.module.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"app.module.js","sourceRoot":"","sources":["app.module.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAAwC;AACxC,qDAAiD;AACjD,+CAA2C;AAOpC,IAAM,SAAS,GAAf,MAAM,SAAS;CAAG,CAAA;AAAZ,8BAAS;oBAAT,SAAS;IALrB,IAAA,eAAM,EAAC;QACN,OAAO,EAAE,EAAE;QACX,WAAW,EAAE,CAAC,8BAAa,CAAC;QAC5B,SAAS,EAAE,CAAC,wBAAU,CAAC;KACxB,CAAC;GACW,SAAS,CAAG"}
|
||||
4
apps/api/src/app.service.d.ts
vendored
4
apps/api/src/app.service.d.ts
vendored
@@ -1,4 +0,0 @@
|
||||
export declare class AppService {
|
||||
getHello(): string;
|
||||
}
|
||||
//# sourceMappingURL=app.service.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"app.service.d.ts","sourceRoot":"","sources":["app.service.ts"],"names":[],"mappings":"AAEA,qBACa,UAAU;IACrB,QAAQ,IAAI,MAAM;CAGnB"}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use strict";
|
||||
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
||||
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
||||
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
||||
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
||||
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.AppService = void 0;
|
||||
const common_1 = require("@nestjs/common");
|
||||
let AppService = class AppService {
|
||||
getHello() {
|
||||
return "Mosaic Stack API";
|
||||
}
|
||||
};
|
||||
exports.AppService = AppService;
|
||||
exports.AppService = AppService = __decorate([
|
||||
(0, common_1.Injectable)()
|
||||
], AppService);
|
||||
//# sourceMappingURL=app.service.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"app.service.js","sourceRoot":"","sources":["app.service.ts"],"names":[],"mappings":";;;;;;;;;AAAA,2CAA4C;AAGrC,IAAM,UAAU,GAAhB,MAAM,UAAU;IACrB,QAAQ;QACN,OAAO,kBAAkB,CAAC;IAC5B,CAAC;CACF,CAAA;AAJY,gCAAU;qBAAV,UAAU;IADtB,IAAA,mBAAU,GAAE;GACA,UAAU,CAItB"}
|
||||
69
apps/api/src/filters/global-exception.filter.ts
Normal file
69
apps/api/src/filters/global-exception.filter.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
message: string;
|
||||
errorId: string;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const errorId = randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = "An unexpected error occurred";
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
message =
|
||||
typeof exceptionResponse === "string"
|
||||
? exceptionResponse
|
||||
: ((exceptionResponse as { message?: string }).message ?? message);
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
// Structured error logging
|
||||
const logPayload = {
|
||||
level: "error",
|
||||
errorId,
|
||||
timestamp,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
statusCode: status,
|
||||
message: exception instanceof Error ? exception.message : String(exception),
|
||||
stack: !isProduction && exception instanceof Error ? exception.stack : undefined,
|
||||
};
|
||||
|
||||
console.error(isProduction ? JSON.stringify(logPayload) : logPayload);
|
||||
|
||||
// Sanitized client response
|
||||
const errorResponse: ErrorResponse = {
|
||||
success: false,
|
||||
message:
|
||||
isProduction && status === HttpStatus.INTERNAL_SERVER_ERROR
|
||||
? "An unexpected error occurred"
|
||||
: message,
|
||||
errorId,
|
||||
timestamp,
|
||||
path: request.url,
|
||||
statusCode: status,
|
||||
};
|
||||
|
||||
response.status(status).json(errorResponse);
|
||||
}
|
||||
}
|
||||
2
apps/api/src/main.d.ts
vendored
2
apps/api/src/main.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
export {};
|
||||
//# sourceMappingURL=main.d.ts.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":""}
|
||||
@@ -1,16 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const core_1 = require("@nestjs/core");
|
||||
const app_module_1 = require("./app.module");
|
||||
async function bootstrap() {
|
||||
const app = await core_1.NestFactory.create(app_module_1.AppModule);
|
||||
app.enableCors();
|
||||
const port = process.env["PORT"] ?? 3001;
|
||||
await app.listen(port);
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap().catch((err) => {
|
||||
console.error("Failed to start application:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
//# sourceMappingURL=main.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"main.js","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":";;AAAA,uCAA2C;AAC3C,6CAAyC;AAEzC,KAAK,UAAU,SAAS;IACtB,MAAM,GAAG,GAAG,MAAM,kBAAW,CAAC,MAAM,CAAC,sBAAS,CAAC,CAAC;IAChD,GAAG,CAAC,UAAU,EAAE,CAAC;IAEjB,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,IAAI,CAAC;IACzC,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAEvB,OAAO,CAAC,GAAG,CAAC,mCAAmC,IAAI,EAAE,CAAC,CAAC;AACzD,CAAC;AAED,SAAS,EAAE,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;IACjC,OAAO,CAAC,KAAK,CAAC,8BAA8B,EAAE,GAAG,CAAC,CAAC;IACnD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
||||
@@ -1,17 +1,61 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module";
|
||||
import { GlobalExceptionFilter } from "./filters/global-exception.filter";
|
||||
|
||||
function getPort(): number {
|
||||
const portEnv = process.env.PORT;
|
||||
|
||||
if (portEnv === undefined || portEnv === "") {
|
||||
return 3001;
|
||||
}
|
||||
|
||||
const port = parseInt(portEnv, 10);
|
||||
|
||||
if (isNaN(port)) {
|
||||
throw new Error(`Invalid PORT environment variable: "${portEnv}". PORT must be a number.`);
|
||||
}
|
||||
|
||||
if (port < 1 || port > 65535) {
|
||||
throw new Error(
|
||||
`Invalid PORT environment variable: ${String(port)}. PORT must be between 1 and 65535.`
|
||||
);
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
app.enableCors();
|
||||
|
||||
const port = process.env["PORT"] ?? 3001;
|
||||
const port = getPort();
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
console.log(`API running on http://localhost:${String(port)}`);
|
||||
}
|
||||
|
||||
bootstrap().catch((err: unknown) => {
|
||||
console.error("Failed to start application:", err);
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
const errorStack = err instanceof Error ? err.stack : undefined;
|
||||
|
||||
if (isProduction) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
level: "error",
|
||||
message: "Failed to start application",
|
||||
error: errorMessage,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.error("Failed to start application:", errorMessage);
|
||||
if (errorStack) {
|
||||
console.error(errorStack);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
16
apps/web/eslint.config.js
Normal file
16
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import nextjsConfig from "@mosaic/config/eslint/nextjs";
|
||||
|
||||
export default [
|
||||
...nextjsConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [".next/**", "node_modules/**"],
|
||||
},
|
||||
];
|
||||
@@ -6,8 +6,8 @@
|
||||
"build": "next build",
|
||||
"dev": "next dev --turbopack --port 3000",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "next lint --fix",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf .next",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -4,9 +4,7 @@ export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center p-24">
|
||||
<h1 className="text-4xl font-bold mb-8">Mosaic Stack</h1>
|
||||
<p className="text-lg text-gray-600 mb-8">
|
||||
Welcome to the Mosaic Stack monorepo
|
||||
</p>
|
||||
<p className="text-lg text-gray-600 mb-8">Welcome to the Mosaic Stack monorepo</p>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="primary">Get Started</Button>
|
||||
<Button variant="secondary">Learn More</Button>
|
||||
|
||||
@@ -4,9 +4,9 @@ export default [
|
||||
...baseConfig,
|
||||
{
|
||||
rules: {
|
||||
"@typescript-eslint/interface-name-prefix": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-extraneous-class": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
16
packages/shared/eslint.config.js
Normal file
16
packages/shared/eslint.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import baseConfig from "@mosaic/config/eslint/base";
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/**", "**/*.test.ts", "**/*.spec.ts"],
|
||||
},
|
||||
];
|
||||
@@ -1,38 +1,121 @@
|
||||
/**
|
||||
* Base entity type with common fields
|
||||
* @invariant updatedAt >= createdAt
|
||||
*/
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
readonly id: string;
|
||||
readonly createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* API response wrapper
|
||||
* API response wrapper - discriminated union for type-safe error handling
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* if (response.success) {
|
||||
* // TypeScript knows response.data exists
|
||||
* console.log(response.data);
|
||||
* } else {
|
||||
* // TypeScript knows response.message exists
|
||||
* console.error(response.message);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
export type ApiResponse<T> =
|
||||
| { success: true; data: T; message?: string }
|
||||
| { success: false; data?: never; message: string; errorCode?: string };
|
||||
|
||||
/**
|
||||
* Helper to create a successful API response
|
||||
*/
|
||||
export function successResponse<T>(data: T, message?: string): ApiResponse<T> {
|
||||
if (message !== undefined) {
|
||||
return { success: true, data, message };
|
||||
}
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create an error API response
|
||||
*/
|
||||
export function errorResponse<T>(message: string, errorCode?: string): ApiResponse<T> {
|
||||
if (errorCode !== undefined) {
|
||||
return { success: false, message, errorCode };
|
||||
}
|
||||
return { success: false, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination parameters
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
export interface PaginationParams<SortableFields extends string = string> {
|
||||
/** Page number (1-indexed) */
|
||||
page: number;
|
||||
/** Items per page */
|
||||
limit: number;
|
||||
sortBy?: string;
|
||||
/** Field to sort by */
|
||||
sortBy?: SortableFields;
|
||||
/** Sort direction */
|
||||
sortOrder?: "asc" | "desc";
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated response
|
||||
* @invariant totalPages === Math.ceil(total / limit)
|
||||
* @invariant data.length <= limit
|
||||
* @invariant page >= 1 && page <= totalPages (when total > 0)
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
readonly data: readonly T[];
|
||||
readonly total: number;
|
||||
readonly page: number;
|
||||
readonly limit: number;
|
||||
readonly totalPages: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create a paginated response with calculated totalPages
|
||||
*/
|
||||
export function createPaginatedResponse<T>(
|
||||
data: T[],
|
||||
total: number,
|
||||
page: number,
|
||||
limit: number
|
||||
): PaginatedResponse<T> {
|
||||
if (limit <= 0) {
|
||||
throw new Error("limit must be positive");
|
||||
}
|
||||
if (page < 1) {
|
||||
throw new Error("page must be >= 1");
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check status values
|
||||
*/
|
||||
export type HealthState = "healthy" | "degraded" | "unhealthy";
|
||||
|
||||
/**
|
||||
* Health check response
|
||||
*/
|
||||
export interface HealthStatus {
|
||||
status: HealthState;
|
||||
timestamp: string;
|
||||
version?: string;
|
||||
checks?: Record<
|
||||
string,
|
||||
{
|
||||
status: HealthState;
|
||||
message?: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,38 @@ describe("slugify", () => {
|
||||
it("should trim leading and trailing hyphens", () => {
|
||||
expect(slugify(" Hello World ")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(slugify("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle string with only whitespace", () => {
|
||||
expect(slugify(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle consecutive special characters", () => {
|
||||
expect(slugify("Hello---World")).toBe("hello-world");
|
||||
expect(slugify("Hello___World")).toBe("hello-world");
|
||||
expect(slugify("Hello World")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("should handle numbers", () => {
|
||||
expect(slugify("Product 123")).toBe("product-123");
|
||||
expect(slugify("2024 New Year")).toBe("2024-new-year");
|
||||
});
|
||||
|
||||
it("should handle mixed case", () => {
|
||||
expect(slugify("HeLLo WoRLd")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("should remove leading/trailing special chars", () => {
|
||||
expect(slugify("---Hello World---")).toBe("hello-world");
|
||||
expect(slugify("!@#Hello World!@#")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("should handle string with only special characters", () => {
|
||||
expect(slugify("!@#$%^&*()")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sleep", () => {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* Safely parse a date string or return undefined
|
||||
* Safely parse a date string or return undefined.
|
||||
* Logs a warning if an invalid date string is passed.
|
||||
*/
|
||||
export function parseDate(value: string | Date | undefined | null): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
if (value instanceof Date) return value;
|
||||
const parsed = new Date(value);
|
||||
return isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
if (isNaN(parsed.getTime())) {
|
||||
console.warn(`parseDate: Invalid date string received: "${value}"`);
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
16
packages/ui/eslint.config.js
Normal file
16
packages/ui/eslint.config.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import baseConfig from "@mosaic/config/eslint/base";
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["dist/**", "**/*.test.tsx", "**/*.test.ts", "**/*.spec.tsx", "**/*.spec.ts"],
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { describe, expect, it, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { Button } from "./Button.js";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -12,20 +12,75 @@ describe("Button", () => {
|
||||
expect(screen.getByRole("button")).toHaveTextContent("Click me");
|
||||
});
|
||||
|
||||
it("should apply variant styles", () => {
|
||||
render(<Button variant="danger">Delete</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("bg-red-600");
|
||||
describe("variants", () => {
|
||||
it("should apply primary variant styles by default", () => {
|
||||
render(<Button>Primary</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("bg-blue-600");
|
||||
});
|
||||
|
||||
it("should apply secondary variant styles", () => {
|
||||
render(<Button variant="secondary">Secondary</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("bg-gray-200");
|
||||
});
|
||||
|
||||
it("should apply danger variant styles", () => {
|
||||
render(<Button variant="danger">Delete</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("bg-red-600");
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply size styles", () => {
|
||||
render(<Button size="lg">Large Button</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("px-6");
|
||||
describe("sizes", () => {
|
||||
it("should apply medium size by default", () => {
|
||||
render(<Button>Medium</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("px-4");
|
||||
});
|
||||
|
||||
it("should apply small size styles", () => {
|
||||
render(<Button size="sm">Small</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("px-3");
|
||||
});
|
||||
|
||||
it("should apply large size styles", () => {
|
||||
render(<Button size="lg">Large Button</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("px-6");
|
||||
});
|
||||
});
|
||||
|
||||
describe("onClick", () => {
|
||||
it("should call onClick handler when clicked", () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick}>Click me</Button>);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not call onClick when disabled", () => {
|
||||
const handleClick = vi.fn();
|
||||
render(
|
||||
<Button onClick={handleClick} disabled>
|
||||
Disabled
|
||||
</Button>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass through additional props", () => {
|
||||
render(<Button disabled>Disabled</Button>);
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should merge custom className with default styles", () => {
|
||||
render(<Button className="custom-class">Custom</Button>);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).toContain("custom-class");
|
||||
expect(button.className).toContain("bg-blue-600");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user