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:
Jason Woltje
2026-01-28 15:07:04 -06:00
parent f277afde36
commit 355cf2124b
33 changed files with 411 additions and 191 deletions

10
.gitignore vendored
View File

@@ -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

View File

@@ -1,3 +0,0 @@
import config from "@mosaic/config/prettier";
export default config;

10
.prettierrc.json Normal file
View 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
View 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"],
},
];

View File

@@ -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

View File

@@ -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"}

View File

@@ -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

View File

@@ -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"}

View File

@@ -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(),
});
}
}

View File

@@ -1,3 +0,0 @@
export declare class AppModule {
}
//# sourceMappingURL=app.module.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"app.module.d.ts","sourceRoot":"","sources":["app.module.ts"],"names":[],"mappings":"AAIA,qBAKa,SAAS;CAAG"}

View File

@@ -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

View File

@@ -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"}

View File

@@ -1,4 +0,0 @@
export declare class AppService {
getHello(): string;
}
//# sourceMappingURL=app.service.d.ts.map

View File

@@ -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"}

View File

@@ -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

View File

@@ -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"}

View 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);
}
}

View File

@@ -1,2 +0,0 @@
export {};
//# sourceMappingURL=main.d.ts.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["main.ts"],"names":[],"mappings":""}

View File

@@ -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

View File

@@ -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"}

View File

@@ -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
View 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/**"],
},
];

View File

@@ -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",

View File

@@ -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>

View File

@@ -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",
},
},
];

View 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"],
},
];

View File

@@ -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;
}
>;
}

View File

@@ -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", () => {

View File

@@ -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;
}
/**

View 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"],
},
];

View File

@@ -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");
});
});