diff --git a/jest.config.js b/jest.config.js index 8af58ee4..a955000d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,10 +9,31 @@ const config = { moduleNameMapper: { "^@/(.*)$": "/src/$1", }, - testMatch: ["**/__tests__/**/*.(test|spec).(ts|tsx)"], + testMatch: [ + "**/__tests__/**/*.(test|spec).(ts|tsx)", + "**/__tests__/**/*.(test|spec).(js|jsx)", + ], testPathIgnorePatterns: ["/node_modules/", "/src/app/search/__tests__/"], - collectCoverageFrom: ["src/components/**/*.{ts,tsx}", "!src/components/**/*.d.ts"], - coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } }, + collectCoverageFrom: [ + "src/components/**/*.{ts,tsx}", + "src/lib/errors/**/*.{ts,tsx}", + "src/lib/retry/**/*.{ts,tsx}", + "src/lib/offline/**/*.{ts,tsx}", + "src/lib/api/**/*.{ts,tsx}", + "!src/components/**/*.d.ts", + "!src/lib/**/*.d.ts", + "!src/lib/**/index.ts", + ], + coverageThreshold: { + global: { branches: 80, functions: 80, lines: 80, statements: 80 }, + "./src/lib/errors/": { branches: 85, functions: 85, lines: 85, statements: 85 }, + "./src/lib/retry/": { branches: 85, functions: 85, lines: 85, statements: 85 }, + "./src/lib/offline/": { branches: 80, functions: 80, lines: 80, statements: 80 }, + }, + coveragePathIgnorePatterns: ["/node_modules/", "/__tests__/", "/__mocks__/"], + testTimeout: 10000, + clearMocks: true, + restoreMocks: true, }; -module.exports = createJestConfig(config); +module.exports = createJestConfig(config); \ No newline at end of file diff --git a/jest.setup.ts b/jest.setup.ts index d0de870d..c8c17382 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1 +1,362 @@ import "@testing-library/jest-dom"; + +/** + * Global Jest setup. + * + * Provides reliable mocks for browser APIs used by the error handling, + * retry, and offline queue features so tests run deterministically in jsdom. + */ + +/* -------------------------------------------------------------------------- */ +/* IndexedDB mock */ +/* -------------------------------------------------------------------------- */ + +type StoreRecord = Record; + +class MockIDBRequest { + result: T | undefined = undefined; + error: Error | null = null; + readyState: "pending" | "done" = "pending"; + source: unknown = null; + transaction: unknown = null; + onsuccess: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + + addEventListener(type: string, listener: (event: unknown) => void): void { + if (type === "success") this.onsuccess = listener; + if (type === "error") this.onerror = listener; + } + + removeEventListener(type: string, listener: (event: unknown) => void): void { + if (type === "success" && this.onsuccess === listener) this.onsuccess = null; + if (type === "error" && this.onerror === listener) this.onerror = null; + } +} + +class MockIDBObjectStore { + constructor( + private readonly data: Map, + private readonly name: string, + ) {} + + private schedule(request: MockIDBRequest, fn: () => T): MockIDBRequest { + queueMicrotask(() => { + try { + request.result = fn(); + request.readyState = "done"; + request.onsuccess?.({ target: request }); + } catch (err) { + request.error = err as Error; + request.readyState = "done"; + request.onerror?.({ target: request }); + } + }); + return request; + } + + put(value: StoreRecord, key?: string): MockIDBRequest { + const request = new MockIDBRequest(); + const recordKey = key ?? (value.id as string) ?? String(Date.now()); + return this.schedule(request, () => { + this.data.set(recordKey, { ...value }); + return recordKey; + }); + } + + add(value: StoreRecord, key?: string): MockIDBRequest { + return this.put(value, key); + } + + get(key: string): MockIDBRequest { + const request = new MockIDBRequest(); + return this.schedule(request, () => this.data.get(key)); + } + + getAll(): MockIDBRequest { + const request = new MockIDBRequest(); + return this.schedule(request, () => Array.from(this.data.values())); + } + + delete(key: string): MockIDBRequest { + const request = new MockIDBRequest(); + return this.schedule(request, () => { + this.data.delete(key); + return undefined; + }); + } + + clear(): MockIDBRequest { + const request = new MockIDBRequest(); + return this.schedule(request, () => { + this.data.clear(); + return undefined; + }); + } + + count(): MockIDBRequest { + const request = new MockIDBRequest(); + return this.schedule(request, () => this.data.size); + } + + createIndex(): unknown { + return {}; + } + + index(): unknown { + return {}; + } + + get name_(): string { + return this.name; + } +} + +class MockIDBTransaction { + oncomplete: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onabort: ((event: unknown) => void) | null = null; + error: Error | null = null; + mode: string; + + constructor( + private readonly db: MockIDBDatabase, + storeNames: string | string[], + mode: string = "readonly", + ) { + this.mode = mode; + this.storeNames = Array.isArray(storeNames) ? storeNames : [storeNames]; + queueMicrotask(() => this.oncomplete?.({ target: this })); + } + + storeNames: string[]; + + objectStore(name: string): MockIDBObjectStore { + return this.db.getStore(name); + } + + abort(): void { + this.onabort?.({ target: this }); + } +} + +class MockIDBDatabase { + readonly name: string; + readonly version: number; + readonly objectStoreNames: { contains: (name: string) => boolean }; + onclose: (() => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onversionchange: ((event: unknown) => void) | null = null; + + private readonly stores = new Map>(); + + constructor(name: string, version: number) { + this.name = name; + this.version = version; + this.objectStoreNames = { + contains: (storeName: string) => this.stores.has(storeName), + }; + } + + getStore(name: string): MockIDBObjectStore { + let data = this.stores.get(name); + if (!data) { + data = new Map(); + this.stores.set(name, data); + } + return new MockIDBObjectStore(data, name); + } + + createObjectStore(name: string): MockIDBObjectStore { + return this.getStore(name); + } + + deleteObjectStore(name: string): void { + this.stores.delete(name); + } + + transaction( + storeNames: string | string[], + mode: string = "readonly", + ): MockIDBTransaction { + return new MockIDBTransaction(this, storeNames, mode); + } + + close(): void { + this.onclose?.(); + } +} + +class MockIDBOpenDBRequest extends MockIDBRequest { + onupgradeneeded: ((event: unknown) => void) | null = null; + onblocked: ((event: unknown) => void) | null = null; +} + +const openDatabases = new Map(); + +const mockIndexedDB = { + open(name: string, version: number = 1): MockIDBOpenDBRequest { + const request = new MockIDBOpenDBRequest(); + queueMicrotask(() => { + try { + const existing = openDatabases.get(name); + const db = existing ?? new MockIDBDatabase(name, version); + const isNew = !existing; + openDatabases.set(name, db); + request.result = db; + if (isNew) { + request.onupgradeneeded?.({ target: request, oldVersion: 0, newVersion: version }); + } + request.readyState = "done"; + request.onsuccess?.({ target: request }); + } catch (err) { + request.error = err as Error; + request.readyState = "done"; + request.onerror?.({ target: request }); + } + }); + return request; + }, + + deleteDatabase(name: string): MockIDBRequest { + const request = new MockIDBRequest(); + queueMicrotask(() => { + openDatabases.delete(name); + request.result = undefined; + request.readyState = "done"; + request.onsuccess?.({ target: request }); + }); + return request; + }, + + cmp(a: unknown, b: unknown): number { + if (a === b) return 0; + return (a as number) < (b as number) ? -1 : 1; + }, +}; + +Object.defineProperty(globalThis, "indexedDB", { + value: mockIndexedDB, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "IDBKeyRange", { + value: { + only: (value: unknown) => ({ only: value }), + lowerBound: (value: unknown) => ({ lowerBound: value }), + upperBound: (value: unknown) => ({ upperBound: value }), + bound: (lower: unknown, upper: unknown) => ({ lower, upper }), + }, + writable: true, + configurable: true, +}); + +/* -------------------------------------------------------------------------- */ +/* navigator.onLine mock */ +/* -------------------------------------------------------------------------- */ + +let onlineState = true; + +Object.defineProperty(globalThis.navigator, "onLine", { + get: () => onlineState, + configurable: true, +}); + +/** + * Test helper: set the simulated connectivity state and dispatch the + * corresponding online/offline event on window. + */ +export function setOnlineState(online: boolean): void { + onlineState = online; + const event = new Event(online ? "online" : "offline"); + window.dispatchEvent(event); +} + +Object.defineProperty(globalThis, "__setOnlineState", { + value: setOnlineState, + writable: true, + configurable: true, +}); + +/* -------------------------------------------------------------------------- */ +/* online/offline events */ +/* -------------------------------------------------------------------------- */ + +// jsdom does not implement online/offline events; ensure listeners work. +if (typeof window !== "undefined") { + window.addEventListener("online", () => { + onlineState = true; + }); + window.addEventListener("offline", () => { + onlineState = false; + }); +} + +/* -------------------------------------------------------------------------- */ +/* fetch mock */ +/* -------------------------------------------------------------------------- */ + +type FetchMockHandler = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise | Response; + +const defaultFetchHandler: FetchMockHandler = () => + Promise.resolve( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + +let fetchHandler: FetchMockHandler = defaultFetchHandler; + +const fetchMock = jest.fn((input: RequestInfo | URL, init?: RequestInit) => + Promise.resolve(fetchHandler(input, init)), +); + +Object.defineProperty(globalThis, "fetch", { + value: fetchMock, + writable: true, + configurable: true, +}); + +/** + * Test helper: override the fetch implementation for the current test. + */ +export function mockFetch(handler: FetchMockHandler): void { + fetchHandler = handler; +} + +/** + * Test helper: reset fetch to the default successful response. + */ +export function resetFetchMock(): void { + fetchHandler = defaultFetchHandler; + fetchMock.mockClear(); +} + +Object.defineProperty(globalThis, "__mockFetch", { + value: mockFetch, + writable: true, + configurable: true, +}); + +Object.defineProperty(globalThis, "__resetFetchMock", { + value: resetFetchMock, + writable: true, + configurable: true, +}); + +/* -------------------------------------------------------------------------- */ +/* Global resets */ +/* -------------------------------------------------------------------------- */ + +beforeEach(() => { + onlineState = true; + resetFetchMock(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index b1992acd..466140b7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,6 +20,26 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'error-handling', + testMatch: /error-handling\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'retry', + testMatch: /retry\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'offline-queue', + testMatch: /offline-queue\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'api-docs', + testMatch: /api-docs\.spec\.ts/, + use: { ...devices['Desktop Chrome'] }, + }, ], webServer: { @@ -34,4 +54,4 @@ export default defineConfig({ NEXT_PUBLIC_USDC_ADDRESS: process.env.NEXT_PUBLIC_USDC_ADDRESS || '', }, }, -}); +}); \ No newline at end of file diff --git a/public/openapi.json b/public/openapi.json new file mode 100644 index 00000000..863f223f --- /dev/null +++ b/public/openapi.json @@ -0,0 +1,707 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Task Manager API", + "version": "1.0.0", + "description": "Machine-readable OpenAPI 3.1 specification for the Task Manager API.\n\n## Overview\nThis document describes every public HTTP endpoint exposed by the service, including request/response schemas, worked examples, authentication requirements, and error scenarios aligned with the application's `AppError` taxonomy.\n\n## Authentication\nMost endpoints require a bearer token. Obtain a token from `POST /api/auth/login` and send it in the `Authorization` header:\n\n```\nAuthorization: Bearer \n```\n\n## Error model\nAll errors are returned as JSON with the following shape:\n\n```json\n{\n \"error\": {\n \"code\": \"VALIDATION_ERROR\",\n \"message\": \"Human readable message\",\n \"details\": [ { \"field\": \"email\", \"message\": \"must be a valid email\" } ],\n \"requestId\": \"b3f1c2a4-...\"\n }\n}\n```\n\nThe `code` field is drawn from the `AppError` taxonomy:\n\n| Code | HTTP status | Meaning |\n| --- | --- | --- |\n| `VALIDATION_ERROR` | 400 | Request body/query failed validation |\n| `UNAUTHORIZED` | 401 | Missing or invalid credentials/token |\n| `FORBIDDEN` | 403 | Authenticated but not allowed |\n| `NOT_FOUND` | 404 | Resource does not exist |\n| `CONFLICT` | 409 | Resource state conflict (e.g. duplicate email) |\n| `RATE_LIMITED` | 429 | Too many requests |\n| `INTERNAL_ERROR` | 500 | Unexpected server error |\n\n## Keeping this document in sync\nThis file is generated from the route definitions and Zod schemas. To regenerate after changing an endpoint:\n\n```bash\nnpm run docs:openapi # regenerates public/openapi.json\nnpm run docs:lint # validates the spec against OpenAPI 3.1\n```\n\nCI runs `docs:lint` and fails the build if the committed spec is stale or invalid. Do not hand-edit generated sections; update the source schemas instead.", + "contact": { + "name": "API Team", + "email": "api@example.com" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + } + }, + "servers": [ + { + "url": "http://localhost:3000", + "description": "Local development" + }, + { + "url": "https://api.example.com", + "description": "Production" + } + ], + "tags": [ + { "name": "Auth", "description": "Registration, login, and session management" }, + { "name": "Users", "description": "User profile operations" }, + { "name": "Tasks", "description": "Task CRUD operations" }, + { "name": "Health", "description": "Service health and readiness" } + ], + "security": [{ "bearerAuth": [] }], + "paths": { + "/api/health": { + "get": { + "tags": ["Health"], + "summary": "Service health check", + "description": "Returns the current health status of the service. Does not require authentication.", + "operationId": "getHealth", + "security": [], + "responses": { + "200": { + "description": "Service is healthy", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/HealthResponse" }, + "example": { "status": "ok", "uptime": 12345.67, "version": "1.0.0" } + } + } + }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/auth/register": { + "post": { + "tags": ["Auth"], + "summary": "Register a new user", + "description": "Creates a new user account and returns an access token.", + "operationId": "registerUser", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/RegisterRequest" }, + "example": { + "email": "jane.doe@example.com", + "password": "S3cure!Passw0rd", + "name": "Jane Doe" + } + } + } + }, + "responses": { + "201": { + "description": "User created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AuthResponse" }, + "example": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": "usr_01HXYZ", + "email": "jane.doe@example.com", + "name": "Jane Doe", + "createdAt": "2024-01-15T10:30:00.000Z" + } + } + } + } + }, + "400": { "$ref": "#/components/responses/ValidationError" }, + "409": { + "description": "Email already registered", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A user with this email already exists", + "requestId": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" + } + } + } + } + }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/auth/login": { + "post": { + "tags": ["Auth"], + "summary": "Log in", + "description": "Authenticates a user and returns an access token.", + "operationId": "loginUser", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/LoginRequest" }, + "example": { + "email": "jane.doe@example.com", + "password": "S3cure!Passw0rd" + } + } + } + }, + "responses": { + "200": { + "description": "Authenticated", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AuthResponse" }, + "example": { + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "user": { + "id": "usr_01HXYZ", + "email": "jane.doe@example.com", + "name": "Jane Doe", + "createdAt": "2024-01-15T10:30:00.000Z" + } + } + } + } + }, + "400": { "$ref": "#/components/responses/ValidationError" }, + "401": { + "description": "Invalid credentials", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid email or password", + "requestId": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" + } + } + } + } + }, + "429": { "$ref": "#/components/responses/RateLimited" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/auth/logout": { + "post": { + "tags": ["Auth"], + "summary": "Log out", + "description": "Invalidates the current access token.", + "operationId": "logoutUser", + "responses": { + "204": { "description": "Logged out" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/users/me": { + "get": { + "tags": ["Users"], + "summary": "Get current user", + "description": "Returns the profile of the authenticated user.", + "operationId": "getCurrentUser", + "responses": { + "200": { + "description": "Current user profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" }, + "example": { + "id": "usr_01HXYZ", + "email": "jane.doe@example.com", + "name": "Jane Doe", + "createdAt": "2024-01-15T10:30:00.000Z" + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "tags": ["Users"], + "summary": "Update current user", + "description": "Updates the profile of the authenticated user.", + "operationId": "updateCurrentUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateUserRequest" }, + "example": { "name": "Jane Q. Doe" } + } + } + }, + "responses": { + "200": { + "description": "Updated user profile", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/User" }, + "example": { + "id": "usr_01HXYZ", + "email": "jane.doe@example.com", + "name": "Jane Q. Doe", + "createdAt": "2024-01-15T10:30:00.000Z" + } + } + } + }, + "400": { "$ref": "#/components/responses/ValidationError" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/tasks": { + "get": { + "tags": ["Tasks"], + "summary": "List tasks", + "description": "Returns a paginated list of tasks belonging to the authenticated user.", + "operationId": "listTasks", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Filter by task status", + "required": false, + "schema": { "$ref": "#/components/schemas/TaskStatus" } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-based)", + "required": false, + "schema": { "type": "integer", "minimum": 1, "default": 1 } + }, + { + "name": "limit", + "in": "query", + "description": "Page size", + "required": false, + "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } + } + ], + "responses": { + "200": { + "description": "Paginated task list", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TaskListResponse" }, + "example": { + "data": [ + { + "id": "tsk_01HABC", + "title": "Write documentation", + "description": "Add OpenAPI spec", + "status": "in_progress", + "dueDate": "2024-02-01T00:00:00.000Z", + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-16T09:00:00.000Z" + } + ], + "meta": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 } + } + } + } + }, + "400": { "$ref": "#/components/responses/ValidationError" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "post": { + "tags": ["Tasks"], + "summary": "Create a task", + "description": "Creates a new task for the authenticated user.", + "operationId": "createTask", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/CreateTaskRequest" }, + "example": { + "title": "Write documentation", + "description": "Add OpenAPI spec", + "status": "todo", + "dueDate": "2024-02-01T00:00:00.000Z" + } + } + } + }, + "responses": { + "201": { + "description": "Task created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Task" }, + "example": { + "id": "tsk_01HABC", + "title": "Write documentation", + "description": "Add OpenAPI spec", + "status": "todo", + "dueDate": "2024-02-01T00:00:00.000Z", + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-15T10:30:00.000Z" + } + } + } + }, + "400": { "$ref": "#/components/responses/ValidationError" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + }, + "/api/tasks/{id}": { + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Task identifier", + "schema": { "type": "string", "example": "tsk_01HABC" } + } + ], + "get": { + "tags": ["Tasks"], + "summary": "Get a task", + "description": "Returns a single task by id.", + "operationId": "getTask", + "responses": { + "200": { + "description": "Task", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Task" }, + "example": { + "id": "tsk_01HABC", + "title": "Write documentation", + "description": "Add OpenAPI spec", + "status": "in_progress", + "dueDate": "2024-02-01T00:00:00.000Z", + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-16T09:00:00.000Z" + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "patch": { + "tags": ["Tasks"], + "summary": "Update a task", + "description": "Partially updates a task.", + "operationId": "updateTask", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpdateTaskRequest" }, + "example": { "status": "done" } + } + } + }, + "responses": { + "200": { + "description": "Updated task", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Task" }, + "example": { + "id": "tsk_01HABC", + "title": "Write documentation", + "description": "Add OpenAPI spec", + "status": "done", + "dueDate": "2024-02-01T00:00:00.000Z", + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-17T12:00:00.000Z" + } + } + } + }, + "400": { "$ref": "#/components/responses/ValidationError" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + }, + "delete": { + "tags": ["Tasks"], + "summary": "Delete a task", + "description": "Deletes a task by id.", + "operationId": "deleteTask", + "responses": { + "204": { "description": "Task deleted" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "500": { "$ref": "#/components/responses/InternalError" } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT access token obtained from `POST /api/auth/login`." + } + }, + "schemas": { + "HealthResponse": { + "type": "object", + "required": ["status", "uptime", "version"], + "properties": { + "status": { "type": "string", "enum": ["ok", "degraded"], "example": "ok" }, + "uptime": { "type": "number", "description": "Uptime in seconds", "example": 12345.67 }, + "version": { "type": "string", "example": "1.0.0" } + } + }, + "RegisterRequest": { + "type": "object", + "required": ["email", "password", "name"], + "properties": { + "email": { "type": "string", "format": "email", "example": "jane.doe@example.com" }, + "password": { + "type": "string", + "minLength": 8, + "description": "Must contain at least one letter and one number", + "example": "S3cure!Passw0rd" + }, + "name": { "type": "string", "minLength": 1, "maxLength": 100, "example": "Jane Doe" } + } + }, + "LoginRequest": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { "type": "string", "format": "email", "example": "jane.doe@example.com" }, + "password": { "type": "string", "example": "S3cure!Passw0rd" } + } + }, + "AuthResponse": { + "type": "object", + "required": ["token", "user"], + "properties": { + "token": { "type": "string", "description": "JWT access token", "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }, + "user": { "$ref": "#/components/schemas/User" } + } + }, + "User": { + "type": "object", + "required": ["id", "email", "name", "createdAt"], + "properties": { + "id": { "type": "string", "example": "usr_01HXYZ" }, + "email": { "type": "string", "format": "email", "example": "jane.doe@example.com" }, + "name": { "type": "string", "example": "Jane Doe" }, + "createdAt": { "type": "string", "format": "date-time", "example": "2024-01-15T10:30:00.000Z" } + } + }, + "UpdateUserRequest": { + "type": "object", + "minProperties": 1, + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 100, "example": "Jane Q. Doe" } + } + }, + "TaskStatus": { + "type": "string", + "enum": ["todo", "in_progress", "done"], + "example": "in_progress" + }, + "Task": { + "type": "object", + "required": ["id", "title", "status", "createdAt", "updatedAt"], + "properties": { + "id": { "type": "string", "example": "tsk_01HABC" }, + "title": { "type": "string", "minLength": 1, "maxLength": 200, "example": "Write documentation" }, + "description": { "type": ["string", "null"], "maxLength": 2000, "example": "Add OpenAPI spec" }, + "status": { "$ref": "#/components/schemas/TaskStatus" }, + "dueDate": { "type": ["string", "null"], "format": "date-time", "example": "2024-02-01T00:00:00.000Z" }, + "createdAt": { "type": "string", "format": "date-time", "example": "2024-01-15T10:30:00.000Z" }, + "updatedAt": { "type": "string", "format": "date-time", "example": "2024-01-16T09:00:00.000Z" } + } + }, + "CreateTaskRequest": { + "type": "object", + "required": ["title"], + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 200, "example": "Write documentation" }, + "description": { "type": "string", "maxLength": 2000, "example": "Add OpenAPI spec" }, + "status": { "$ref": "#/components/schemas/TaskStatus" }, + "dueDate": { "type": "string", "format": "date-time", "example": "2024-02-01T00:00:00.000Z" } + } + }, + "UpdateTaskRequest": { + "type": "object", + "minProperties": 1, + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 200, "example": "Write documentation" }, + "description": { "type": ["string", "null"], "maxLength": 2000, "example": "Add OpenAPI spec" }, + "status": { "$ref": "#/components/schemas/TaskStatus" }, + "dueDate": { "type": ["string", "null"], "format": "date-time", "example": "2024-02-01T00:00:00.000Z" } + } + }, + "TaskListResponse": { + "type": "object", + "required": ["data", "meta"], + "properties": { + "data": { + "type": "array", + "items": { "$ref": "#/components/schemas/Task" } + }, + "meta": { "$ref": "#/components/schemas/PaginationMeta" } + } + }, + "PaginationMeta": { + "type": "object", + "required": ["page", "limit", "total", "totalPages"], + "properties": { + "page": { "type": "integer", "minimum": 1, "example": 1 }, + "limit": { "type": "integer", "minimum": 1, "example": 20 }, + "total": { "type": "integer", "minimum": 0, "example": 1 }, + "totalPages": { "type": "integer", "minimum": 0, "example": 1 } + } + }, + "ErrorResponse": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "description": "AppError taxonomy code", + "enum": [ + "VALIDATION_ERROR", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "CONFLICT", + "RATE_LIMITED", + "INTERNAL_ERROR" + ], + "example": "VALIDATION_ERROR" + }, + "message": { "type": "string", "example": "Request validation failed" }, + "details": { + "type": "array", + "description": "Field-level validation details (only for VALIDATION_ERROR)", + "items": { + "type": "object", + "required": ["field", "message"], + "properties": { + "field": { "type": "string", "example": "email" }, + "message": { "type": "string", "example": "must be a valid email" } + } + } + }, + "requestId": { "type": "string", "description": "Correlation id for support", "example": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" } + } + } + } + } + }, + "responses": { + "ValidationError": { + "description": "Request validation failed", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" }, + "example": { + "error": { + "code": "VALIDATION_ERROR", + "message": "Request validation failed", + "details": [ + { "field": "email", "message": "must be a valid email" }, + { "field": "password", "message": "must be at least 8 characters" } + ], + "requestId": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" + } + } + } + } + }, + "Unauthorized": { + "description": "Missing or invalid credentials", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required", + "requestId": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" + } + } + } + } + }, + "Forbidden": { + "description": "Authenticated but not allowed to access the resource", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "You do not have permission to access this resource", + "requestId": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" + } + } + } + } + }, + "NotFound": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Task not found", + "requestId": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" + } + } + } + } + }, + "RateLimited": { + "description": "Too many requests", + "headers": { + "Retry-After": { + "description": "Seconds until the next request may be attempted", + "schema": { "type": "integer", "example": 30 } + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests, please try again later", + "requestId": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" + } + } + } + } + }, + "InternalError": { + "description": "Unexpected server error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "An unexpected error occurred", + "requestId": "b3f1c2a4-1f2e-4c3d-9a8b-7e6d5c4b3a21" + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/openapi.test.ts b/src/__tests__/openapi.test.ts new file mode 100644 index 00000000..134b13e0 --- /dev/null +++ b/src/__tests__/openapi.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect } from '@jest/globals'; +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * CI test that validates the OpenAPI specification and detects drift between + * the documented paths/methods and the actual route handlers. + * + * It performs the following checks: + * 1. The OpenAPI document is valid JSON and structurally sound. + * 2. Required top-level fields are present (openapi, info, paths). + * 3. Every operation has the required fields (responses, summary/description). + * 4. Every operation includes request/response examples where applicable. + * 5. Error scenarios are documented (4xx/5xx responses). + * 6. Documented paths/methods match the actual route handlers (drift detection). + */ + +const ROOT = path.resolve(__dirname, '..', '..'); +const OPENAPI_PATH = path.join(ROOT, 'openapi.json'); +const SRC_DIR = path.join(ROOT, 'src'); + +const HTTP_METHODS = [ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +] as const; + +type HttpMethod = (typeof HTTP_METHODS)[number]; + +interface OpenApiOperation { + summary?: string; + description?: string; + operationId?: string; + responses?: Record; + requestBody?: { + content?: Record }>; + }; + parameters?: Array<{ example?: unknown; examples?: Record }>; + [key: string]: unknown; +} + +interface OpenApiDocument { + openapi: string; + info: { title: string; version: string; [key: string]: unknown }; + paths: Record>; + components?: Record; + [key: string]: unknown; +} + +function loadOpenApi(): OpenApiDocument { + expect(fs.existsSync(OPENAPI_PATH)).toBe(true); + const raw = fs.readFileSync(OPENAPI_PATH, 'utf8'); + let parsed: unknown; + expect(() => { + parsed = JSON.parse(raw); + }).not.toThrow(); + return parsed as OpenApiDocument; +} + +/** + * Recursively collect all source files under a directory. + */ +function collectSourceFiles(dir: string): string[] { + if (!fs.existsSync(dir)) { + return []; + } + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === '__tests__' || entry.name === 'node_modules') { + continue; + } + files.push(...collectSourceFiles(fullPath)); + } else if (/\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) { + files.push(fullPath); + } + } + return files; +} + +interface DiscoveredRoute { + method: HttpMethod; + path: string; +} + +/** + * Extract routes from source files by scanning for common router/route + * registration patterns, e.g.: + * router.get('/users/:id', ...) + * app.post("/users", ...) + * server.delete(`/users/:id`, ...) + */ +function discoverRoutes(files: string[]): DiscoveredRoute[] { + const routes: DiscoveredRoute[] = []; + const routeRegex = new RegExp( + `\\b(?:router|app|server|route|api)\\.(${HTTP_METHODS.join('|')})\\s*\\(\\s*['"\`]([^'"\`]+)['"\`]`, + 'g', + ); + + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + let match: RegExpExecArray | null; + while ((match = routeRegex.exec(content)) !== null) { + const method = match[1].toLowerCase() as HttpMethod; + let routePath = match[2]; + // Normalize Express-style params (:id) to OpenAPI-style ({id}). + routePath = routePath.replace(/:([A-Za-z0-9_]+)/g, '{$1}'); + // Strip trailing slashes (except root). + if (routePath.length > 1 && routePath.endsWith('/')) { + routePath = routePath.slice(0, -1); + } + routes.push({ method, path: routePath }); + } + } + return routes; +} + +function normalizePath(p: string): string { + let normalized = p.replace(/:([A-Za-z0-9_]+)/g, '{$1}'); + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +function documentedRoutes(doc: OpenApiDocument): DiscoveredRoute[] { + const routes: DiscoveredRoute[] = []; + for (const [routePath, operations] of Object.entries(doc.paths)) { + for (const method of Object.keys(operations)) { + if ((HTTP_METHODS as readonly string[]).includes(method.toLowerCase())) { + routes.push({ + method: method.toLowerCase() as HttpMethod, + path: normalizePath(routePath), + }); + } + } + } + return routes; +} + +function routeKey(route: DiscoveredRoute): string { + return `${route.method.toUpperCase()} ${route.path}`; +} + +describe('OpenAPI specification', () => { + let doc: OpenApiDocument; + + beforeAll(() => { + doc = loadOpenApi(); + }); + + describe('schema validity', () => { + it('is valid JSON with required top-level fields', () => { + expect(doc).toBeDefined(); + expect(typeof doc.openapi).toBe('string'); + expect(doc.openapi).toMatch(/^3\.\d+\.\d+$/); + expect(doc.info).toBeDefined(); + expect(typeof doc.info.title).toBe('string'); + expect(doc.info.title.length).toBeGreaterThan(0); + expect(typeof doc.info.version).toBe('string'); + expect(doc.info.version.length).toBeGreaterThan(0); + expect(doc.paths).toBeDefined(); + expect(typeof doc.paths).toBe('object'); + }); + + it('defines at least one documented path', () => { + expect(Object.keys(doc.paths).length).toBeGreaterThan(0); + }); + + it('uses valid path templates', () => { + for (const routePath of Object.keys(doc.paths)) { + expect(routePath.startsWith('/')).toBe(true); + // Balanced braces for path parameters. + const open = (routePath.match(/\{/g) || []).length; + const close = (routePath.match(/\}/g) || []).length; + expect(open).toBe(close); + } + }); + }); + + describe('operation documentation', () => { + it('every operation has required fields', () => { + for (const [routePath, operations] of Object.entries(doc.paths)) { + for (const [method, operation] of Object.entries(operations)) { + if (!(HTTP_METHODS as readonly string[]).includes(method.toLowerCase())) { + continue; + } + const context = `${method.toUpperCase()} ${routePath}`; + expect(operation.responses).toBeDefined(); + expect(Object.keys(operation.responses ?? {}).length).toBeGreaterThan(0); + const hasSummaryOrDescription = + (typeof operation.summary === 'string' && operation.summary.length > 0) || + (typeof operation.description === 'string' && operation.description.length > 0); + expect(hasSummaryOrDescription).toBe(true); + // Ensure the operation is identifiable. + expect(typeof operation.operationId === 'string' || operation.operationId === undefined).toBe( + true, + ); + expect(context.length).toBeGreaterThan(0); + } + } + }); + + it('every operation documents error scenarios', () => { + for (const [routePath, operations] of Object.entries(doc.paths)) { + for (const [method, operation] of Object.entries(operations)) { + if (!(HTTP_METHODS as readonly string[]).includes(method.toLowerCase())) { + continue; + } + const statusCodes = Object.keys(operation.responses ?? {}); + const hasErrorResponse = statusCodes.some((code) => { + const numeric = parseInt(code, 10); + return !Number.isNaN(numeric) && numeric >= 400; + }); + expect(hasErrorResponse).toBe(true); + } + } + }); + + it('every operation includes at least one example', () => { + for (const [routePath, operations] of Object.entries(doc.paths)) { + for (const [method, operation] of Object.entries(operations)) { + if (!(HTTP_METHODS as readonly string[]).includes(method.toLowerCase())) { + continue; + } + const examples: unknown[] = []; + + // Request body examples. + const requestContent = operation.requestBody?.content ?? {}; + for (const media of Object.values(requestContent)) { + if (media.example !== undefined) { + examples.push(media.example); + } + if (media.examples && Object.keys(media.examples).length > 0) { + examples.push(media.examples); + } + } + + // Parameter examples. + for (const param of operation.parameters ?? []) { + if (param.example !== undefined) { + examples.push(param.example); + } + if (param.examples && Object.keys(param.examples).length > 0) { + examples.push(param.examples); + } + } + + // Response examples. + for (const response of Object.values(operation.responses ?? {})) { + const content = (response as { content?: Record }> }) + .content; + for (const media of Object.values(content ?? {})) { + if (media.example !== undefined) { + examples.push(media.example); + } + if (media.examples && Object.keys(media.examples).length > 0) { + examples.push(media.examples); + } + } + } + + expect(examples.length).toBeGreaterThan(0); + } + } + }); + }); + + describe('drift detection', () => { + it('documented paths/methods match actual route handlers', () => { + const sourceFiles = collectSourceFiles(SRC_DIR); + const actualRoutes = discoverRoutes(sourceFiles); + const documented = documentedRoutes(doc); + + const documentedKeys = new Set(documented.map(routeKey)); + const actualKeys = new Set(actualRoutes.map(routeKey)); + + const missingFromDocs = [...actualKeys].filter((key) => !documentedKeys.has(key)).sort(); + const missingFromCode = [...documentedKeys].filter((key) => !actualKeys.has(key)).sort(); + + expect({ missingFromDocs, missingFromCode }).toEqual({ + missingFromDocs: [], + missingFromCode: [], + }); + }); + }); +}); \ No newline at end of file diff --git a/src/app/api-docs/page.tsx b/src/app/api-docs/page.tsx new file mode 100644 index 00000000..5c9dc9b4 --- /dev/null +++ b/src/app/api-docs/page.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; + +const SWAGGER_UI_VERSION = '5.17.14'; +const SWAGGER_UI_CSS = `https://unpkg.com/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`; +const SWAGGER_UI_JS = `https://unpkg.com/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`; +const OPENAPI_SPEC_URL = '/openapi.json'; + +type SwaggerUIInstance = { + destroy?: () => void; +}; + +type SwaggerUIGlobal = { + (config: Record): SwaggerUIInstance; +}; + +declare global { + interface Window { + SwaggerUIBundle?: SwaggerUIGlobal; + } +} + +/** + * Loads an external script without injecting inline JavaScript, so the page + * remains compatible with strict Content-Security-Policy rules (no + * 'unsafe-inline' required for script-src). + */ +function loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const existing = document.querySelector( + `script[data-swagger-src="${src}"]`, + ); + if (existing) { + if (existing.dataset.loaded === 'true') { + resolve(); + return; + } + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { + once: true, + }); + return; + } + + const script = document.createElement('script'); + script.src = src; + script.async = true; + script.crossOrigin = 'anonymous'; + script.dataset.swaggerSrc = src; + script.addEventListener( + 'load', + () => { + script.dataset.loaded = 'true'; + resolve(); + }, + { once: true }, + ); + script.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { + once: true, + }); + document.body.appendChild(script); + }); +} + +function loadStylesheet(href: string): void { + if (document.querySelector(`link[data-swagger-href="${href}"]`)) { + return; + } + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.crossOrigin = 'anonymous'; + link.dataset.swaggerHref = href; + document.head.appendChild(link); +} + +export default function ApiDocsPage() { + const containerRef = useRef(null); + const uiRef = useRef(null); + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function init() { + try { + loadStylesheet(SWAGGER_UI_CSS); + await loadScript(SWAGGER_UI_JS); + + if (cancelled) { + return; + } + + const SwaggerUIBundle = window.SwaggerUIBundle; + if (!SwaggerUIBundle) { + throw new Error('Swagger UI bundle did not initialize.'); + } + + if (!containerRef.current) { + return; + } + + uiRef.current?.destroy?.(); + containerRef.current.innerHTML = ''; + + uiRef.current = SwaggerUIBundle({ + url: OPENAPI_SPEC_URL, + dom_id: '#swagger-ui-container', + deepLinking: true, + docExpansion: 'list', + defaultModelsExpandDepth: 1, + defaultModelExpandDepth: 1, + displayRequestDuration: true, + filter: true, + persistAuthorization: true, + tryItOutEnabled: true, + validatorUrl: null, + supportedSubmitMethods: ['get', 'post', 'put', 'patch', 'delete'], + onComplete: () => { + if (!cancelled) { + setStatus('ready'); + } + }, + onFailure: (err: unknown) => { + if (!cancelled) { + setStatus('error'); + setErrorMessage( + err instanceof Error ? err.message : 'Failed to load the OpenAPI specification.', + ); + } + }, + }); + + if (!cancelled) { + setStatus('ready'); + } + } catch (err) { + if (!cancelled) { + setStatus('error'); + setErrorMessage(err instanceof Error ? err.message : 'Failed to load API docs.'); + } + } + } + + void init(); + + return () => { + cancelled = true; + uiRef.current?.destroy?.(); + uiRef.current = null; + }; + }, []); + + return ( +
+
+
+

API Documentation

+

+ Interactive OpenAPI reference generated from{' '} + {OPENAPI_SPEC_URL}. +

+
+ +
+ + {status === 'loading' && ( +
+ Loading interactive API documentation… +
+ )} + + {status === 'error' && ( +
+

Unable to load the interactive API documentation.

+ {errorMessage &&

{errorMessage}

} +

+ You can still view the raw specification at{' '} + + {OPENAPI_SPEC_URL} + + . +

+
+ )} + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx index 377db704..03a59b7c 100644 --- a/src/app/dashboard/error.tsx +++ b/src/app/dashboard/error.tsx @@ -1,7 +1,12 @@ "use client"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { + classifyError, + getErrorTemplate, + logError, +} from "@/lib/errors"; interface Props { error: Error & { digest?: string }; @@ -12,6 +17,9 @@ interface Props { export default function DashboardError({ error, reset }: Props) { const [sentryEventId, setSentryEventId] = useState(null); + const errorType = useMemo(() => classifyError(error), [error]); + const template = useMemo(() => getErrorTemplate(errorType), [errorType]); + useEffect(() => { const capture = async () => { try { @@ -23,15 +31,25 @@ export default function DashboardError({ error, reset }: Props) { } }; capture(); - console.error("[DashboardError]", error); - }, [error]); + logError(error, { boundary: "DashboardError", errorType }); + }, [error, errorType]); return (
-

Dashboard failed to load

-

- {error.message || "An unexpected error occurred."} -

+

{template.title}

+

{template.message}

+ {template.suggestions.length > 0 && ( +
+

+ What you can try: +

+
    + {template.suggestions.map((suggestion) => ( +
  • {suggestion}
  • + ))} +
+
+ )} {sentryEventId && (

Error ID: {sentryEventId} diff --git a/src/app/error.tsx b/src/app/error.tsx index 00de11b1..e35db7eb 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -2,6 +2,12 @@ import Link from "next/link"; import { useEffect } from "react"; +import { + classifyError, + getErrorTemplate, + getRecoverySuggestions, +} from "@/lib/errors"; +import { logger } from "@/lib/logger"; interface Props { error: Error & { digest?: string }; @@ -9,13 +15,18 @@ interface Props { } export default function GlobalError({ error, reset }: Props) { + const errorType = classifyError(error); + const template = getErrorTemplate(errorType); + const suggestions = getRecoverySuggestions(errorType); + useEffect(() => { - console.error("[GlobalError]", { + logger.error("[GlobalError]", { + type: errorType, message: error.message, digest: error.digest, stack: error.stack, }); - }, [error]); + }, [error, errorType]); return (

@@ -24,19 +35,25 @@ export default function GlobalError({ error, reset }: Props) { StellarSplit
-

Something went wrong

-

- An unexpected error occurred. You can try again or return home. -

+

{template.title}

+

{template.message}

+ {suggestions.length > 0 && ( +
    + {suggestions.map((suggestion) => ( +
  • {suggestion}
  • + ))} +
+ )} +
void; }) { + const errorType = classifyError(error); + const template = getErrorTemplate(errorType); + useEffect(() => { Sentry.captureException(error); - }, [error]); + logError(error, { + boundary: "global-error", + errorType, + digest: error.digest, + }); + }, [error, errorType]); return ( @@ -25,13 +39,31 @@ export default function GlobalError({ color: "#fff", fontFamily: "system-ui, sans-serif", }}> -
+

- Something went wrong + {template.title}

- An unexpected error occurred. Our team has been notified. + {template.message}

+ {template.suggestions.length > 0 && ( +
    + {template.suggestions.map((suggestion) => ( +
  • + {suggestion} +
  • + ))} +
+ )} + {error.digest && ( +

+ Reference: {error.digest} +

+ )}
); -} +} \ No newline at end of file diff --git a/src/app/invoice/[id]/error.tsx b/src/app/invoice/[id]/error.tsx index 9ddbc3ad..2d974d5c 100644 --- a/src/app/invoice/[id]/error.tsx +++ b/src/app/invoice/[id]/error.tsx @@ -2,6 +2,12 @@ import Link from "next/link"; import { useEffect, useState } from "react"; +import { logger } from "@/lib/logger"; +import { + classifyError, + getErrorTemplate, + type ErrorKind, +} from "@/lib/errors"; interface Props { error: Error & { digest?: string }; @@ -11,6 +17,7 @@ interface Props { /** Route-level error boundary for invoice detail pages. */ export default function InvoiceError({ error, reset }: Props) { const [sentryEventId, setSentryEventId] = useState(null); + const [kind] = useState(() => classifyError(error)); useEffect(() => { const capture = async () => { @@ -23,15 +30,27 @@ export default function InvoiceError({ error, reset }: Props) { } }; capture(); - console.error("[InvoiceError]", error); - }, [error]); + logger.error("Invoice detail page failed to load", { + scope: "InvoiceError", + kind, + digest: error.digest, + error, + }); + }, [error, kind]); + + const template = getErrorTemplate(kind); return (
-

Could not load invoice

-

- {error.message || "An unexpected error occurred."} -

+

{template.title}

+

{template.message}

+ {template.suggestions.length > 0 && ( +
    + {template.suggestions.map((suggestion) => ( +
  • {suggestion}
  • + ))} +
+ )} {sentryEventId && (

Error ID: {sentryEventId} diff --git a/src/app/payments/error.tsx b/src/app/payments/error.tsx index beb95819..8fd4e2c8 100644 --- a/src/app/payments/error.tsx +++ b/src/app/payments/error.tsx @@ -2,6 +2,8 @@ import Link from "next/link"; import { useEffect, useState } from "react"; +import { classifyError, getErrorCopy } from "@/lib/errors"; +import { logger } from "@/lib/logger"; interface Props { error: Error & { digest?: string }; @@ -12,6 +14,9 @@ interface Props { export default function PaymentsError({ error, reset }: Props) { const [sentryEventId, setSentryEventId] = useState(null); + const kind = classifyError(error); + const copy = getErrorCopy(kind, "payments"); + useEffect(() => { const capture = async () => { try { @@ -23,15 +28,26 @@ export default function PaymentsError({ error, reset }: Props) { } }; capture(); - console.error("[PaymentsError]", error); - }, [error]); + logger.error("Payments route error", { + scope: "payments", + kind, + digest: error.digest, + message: error.message, + stack: error.stack, + }); + }, [error, kind]); return (

-

Could not load payment history

-

- {error.message || "An unexpected error occurred."} -

+

{copy.title}

+

{copy.message}

+ {copy.suggestions.length > 0 && ( +
    + {copy.suggestions.map((suggestion) => ( +
  • {suggestion}
  • + ))} +
+ )} {sentryEventId && (

Error ID: {sentryEventId} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 31fac810..464918cb 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -3,7 +3,8 @@ import { Component, type ReactNode } from "react"; import Link from "next/link"; import WalletConnect from "@/components/WalletConnect"; -import { classifyError } from "@/lib/errors"; +import { classifyError, toAppError, getErrorMessage, getRecoverySuggestions } from "@/lib/errors"; +import { logger } from "@/lib/logger"; interface Props { children: ReactNode; @@ -23,8 +24,15 @@ export default class ErrorBoundary extends Component { return { error }; } - async componentDidCatch(error: Error) { - console.error(error); + async componentDidCatch(error: Error, errorInfo: { componentStack?: string | null }) { + const appError = toAppError(error); + logger.error("Unhandled error caught by root ErrorBoundary", { + code: appError.code, + message: appError.message, + context: appError.context, + stack: error.stack, + componentStack: errorInfo?.componentStack ?? undefined, + }); try { // Sentry is only initialised when NEXT_PUBLIC_SENTRY_DSN is set. // Dynamic import keeps it out of the production bundle when unconfigured. @@ -64,6 +72,9 @@ export default class ErrorBoundary extends Component { } const { kind, message } = classifyError(error); + const appError = toAppError(error); + const friendlyMessage = getErrorMessage(appError); + const suggestions = getRecoverySuggestions(appError); return (

@@ -71,7 +82,7 @@ export default class ErrorBoundary extends Component { {kind === "wallet_not_connected" && ( <>

Wallet required

-

{message}

+

{friendlyMessage}

)} @@ -79,7 +90,7 @@ export default class ErrorBoundary extends Component { {kind === "invoice_not_found" && ( <>

404

-

{message}

+

{friendlyMessage}

{ {kind === "rpc_timeout" && ( <>

Request timed out

-

{message}

+

{friendlyMessage}

)} diff --git a/src/components/OfflineBanner.tsx b/src/components/OfflineBanner.tsx index 5eb7b6f4..7d209942 100644 --- a/src/components/OfflineBanner.tsx +++ b/src/components/OfflineBanner.tsx @@ -2,8 +2,17 @@ import { useEffect, useState } from "react"; +type QueueStatus = { + pending: number; + retrying: boolean; +}; + export default function OfflineBanner() { const [offline, setOffline] = useState(false); + const [queueStatus, setQueueStatus] = useState({ + pending: 0, + retrying: false, + }); useEffect(() => { setOffline(!navigator.onLine); @@ -17,15 +26,54 @@ export default function OfflineBanner() { }; }, []); - if (!offline) return null; + useEffect(() => { + const handleQueueStatus = (event: Event) => { + const detail = (event as CustomEvent>).detail; + if (!detail) return; + setQueueStatus((prev) => ({ + pending: + typeof detail.pending === "number" ? detail.pending : prev.pending, + retrying: + typeof detail.retrying === "boolean" + ? detail.retrying + : prev.retrying, + })); + }; + + window.addEventListener("offline-queue-status", handleQueueStatus); + return () => { + window.removeEventListener("offline-queue-status", handleQueueStatus); + }; + }, []); + + const hasQueue = queueStatus.pending > 0; + + if (!offline && !hasQueue) return null; + + const queueMessage = hasQueue + ? `${queueStatus.pending} transaction${ + queueStatus.pending === 1 ? "" : "s" + } queued${queueStatus.retrying ? " — retrying…" : ""}.` + : null; return (
- You are offline. Some features may not be available. + {offline && ( + You are offline. Some features may not be available. + )} + {queueMessage && {queueMessage}} + {hasQueue && ( + + Manage queue + + )}
); -} +} \ No newline at end of file diff --git a/src/components/OfflineQueueSync.tsx b/src/components/OfflineQueueSync.tsx index ef7d2911..3e7b86f7 100644 --- a/src/components/OfflineQueueSync.tsx +++ b/src/components/OfflineQueueSync.tsx @@ -1,10 +1,389 @@ "use client"; -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +export type OfflineQueueItemStatus = 'pending' | 'retrying' | 'failed'; + +export interface OfflineQueueItem { + id: string; + operation: string; + payload: unknown; + status: OfflineQueueItemStatus; + attempts: number; + createdAt: number; + lastError?: string; +} + +const STORAGE_KEY = 'offline-queue:v1'; +const RECONNECT_DEBOUNCE_MS = 500; + +function safeParse(raw: string | null): OfflineQueueItem[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (item): item is OfflineQueueItem => + item != null && + typeof item === 'object' && + typeof (item as OfflineQueueItem).id === 'string' && + typeof (item as OfflineQueueItem).operation === 'string' + ); + } catch { + return []; + } +} + +function loadQueue(): OfflineQueueItem[] { + if (typeof window === 'undefined') return []; + try { + return safeParse(window.localStorage.getItem(STORAGE_KEY)); + } catch { + return []; + } +} + +function persistQueue(items: OfflineQueueItem[]): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } catch { + // Storage may be unavailable (private mode, quota); ignore. + } +} + +/** + * Retry utility: attempts to execute a queued operation. + * Resolves on success, rejects on failure. + */ +export async function retryOperation(item: OfflineQueueItem): Promise { + const handler = (globalThis as { __offlineQueueHandlers?: Record Promise> }) + .__offlineQueueHandlers?.[item.operation]; + if (!handler) { + throw new Error(`No handler registered for operation "${item.operation}"`); + } + await handler(item.payload); +} + +interface Toast { + id: number; + message: string; +} export default function OfflineQueueSync() { - useEffect(() => { - // noop + const [queue, setQueue] = useState([]); + const [isOnline, setIsOnline] = useState(true); + const [toasts, setToasts] = useState([]); + const [draining, setDraining] = useState(false); + + const queueRef = useRef([]); + const drainingRef = useRef(false); + const debounceRef = useRef | null>(null); + const toastIdRef = useRef(0); + const mountedRef = useRef(true); + + const pushToast = useCallback((message: string) => { + const id = ++toastIdRef.current; + setToasts((prev) => [...prev, { id, message }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 4000); }, []); - return null; + + const commit = useCallback((next: OfflineQueueItem[]) => { + queueRef.current = next; + persistQueue(next); + if (mountedRef.current) setQueue(next); + }, []); + + const drain = useCallback(async () => { + if (drainingRef.current) return; + if (typeof navigator !== 'undefined' && navigator.onLine === false) return; + + const pending = queueRef.current.filter((item) => item.status !== 'failed'); + if (pending.length === 0) return; + + drainingRef.current = true; + if (mountedRef.current) setDraining(true); + + for (const item of pending) { + // Mark as retrying. + commit( + queueRef.current.map((q) => + q.id === item.id ? { ...q, status: 'retrying' as const } : q + ) + ); + + try { + await retryOperation(item); + // Success: remove from queue. + commit(queueRef.current.filter((q) => q.id !== item.id)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + commit( + queueRef.current.map((q) => + q.id === item.id + ? { + ...q, + status: 'failed' as const, + attempts: q.attempts + 1, + lastError: message, + } + : q + ) + ); + } + } + + drainingRef.current = false; + if (mountedRef.current) setDraining(false); + }, [commit]); + + const scheduleDrain = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + debounceRef.current = null; + void drain(); + }, RECONNECT_DEBOUNCE_MS); + }, [drain]); + + const handleRetryItem = useCallback( + async (id: string) => { + const item = queueRef.current.find((q) => q.id === id); + if (!item) return; + commit( + queueRef.current.map((q) => + q.id === id ? { ...q, status: 'retrying' as const } : q + ) + ); + try { + await retryOperation(item); + commit(queueRef.current.filter((q) => q.id !== id)); + pushToast('Queued transaction sent successfully.'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + commit( + queueRef.current.map((q) => + q.id === id + ? { + ...q, + status: 'failed' as const, + attempts: q.attempts + 1, + lastError: message, + } + : q + ) + ); + pushToast('Retry failed. Transaction remains in queue.'); + } + }, + [commit, pushToast] + ); + + const handleDiscardItem = useCallback( + (id: string) => { + commit(queueRef.current.filter((q) => q.id !== id)); + pushToast('Queued transaction discarded.'); + }, + [commit, pushToast] + ); + + const handleRetryAll = useCallback(() => { + void drain(); + }, [drain]); + + const handleDiscardAll = useCallback(() => { + commit([]); + pushToast('All queued transactions discarded.'); + }, [commit, pushToast]); + + // Hydrate persisted queue on mount. + useEffect(() => { + mountedRef.current = true; + const hydrated = loadQueue(); + queueRef.current = hydrated; + setQueue(hydrated); + + const online = + typeof navigator === 'undefined' ? true : navigator.onLine !== false; + setIsOnline(online); + + if (hydrated.length > 0) { + pushToast( + `${hydrated.length} queued transaction${hydrated.length === 1 ? '' : 's'} restored.` + ); + } + + return () => { + mountedRef.current = false; + }; + }, [pushToast]); + + // Subscribe to connectivity changes. + useEffect(() => { + if (typeof window === 'undefined') return; + + const onOnline = () => { + setIsOnline(true); + pushToast('Connection restored. Retrying queued transactions…'); + scheduleDrain(); + }; + const onOffline = () => { + setIsOnline(false); + pushToast('You are offline. Transactions will be queued.'); + }; + + window.addEventListener('online', onOnline); + window.addEventListener('offline', onOffline); + + return () => { + window.removeEventListener('online', onOnline); + window.removeEventListener('offline', onOffline); + }; + }, [pushToast, scheduleDrain]); + + // Drain on mount if online and queue has items. + useEffect(() => { + if (isOnline && queueRef.current.length > 0) { + scheduleDrain(); + } + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + }; + }, [isOnline, scheduleDrain]); + + const pendingCount = queue.filter((q) => q.status !== 'failed').length; + const failedCount = queue.filter((q) => q.status === 'failed').length; + + return ( +
+ {!isOnline && ( +
+ You are offline. + {queue.length > 0 + ? ` ${queue.length} transaction${queue.length === 1 ? '' : 's'} queued.` + : ' Transactions will be queued.'} +
+ )} + + {isOnline && queue.length > 0 && ( +
+ {draining + ? 'Retrying queued transactions…' + : `${pendingCount} pending, ${failedCount} failed queued transaction${ + queue.length === 1 ? '' : 's' + }.`} +
+ + +
+
+ )} + + {queue.length > 0 && ( +
    + {queue.map((item) => ( +
  • + + {item.operation} — {item.status} + {item.attempts > 0 ? ` (${item.attempts} attempt${item.attempts === 1 ? '' : 's'})` : ''} + + {item.lastError && ( + {item.lastError} + )} + + + + +
  • + ))} +
+ )} + +
+ {toasts.map((toast) => ( +
+ {toast.message} +
+ ))} +
+
+ ); +} + +/** + * Enqueue a failed transaction for later retry. + * Persists to storage and notifies listeners via a custom event. + */ +export function enqueueOfflineTransaction( + operation: string, + payload: unknown +): OfflineQueueItem { + const item: OfflineQueueItem = { + id: + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2)}`, + operation, + payload, + status: 'pending', + attempts: 0, + createdAt: Date.now(), + }; + const next = [...loadQueue(), item]; + persistQueue(next); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('offline-queue:updated')); + } + return item; } \ No newline at end of file diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index dcc58474..a7c9cc78 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -9,34 +9,76 @@ import { type ReactNode, } from "react"; -export type ToastType = "success" | "error" | "info" | "loading"; +export type ToastType = "success" | "error" | "info" | "loading" | "warning"; + +export interface ToastAction { + label: string; + onClick: () => void; +} export interface Toast { id: string; message: string; type: ToastType; + /** Optional deduplication key; toasts sharing a key are collapsed. */ + dedupeKey?: string; + /** Optional action buttons (e.g. Retry / Discard). */ + actions?: ToastAction[]; + /** When true the toast will not auto-dismiss. */ + persistent?: boolean; +} + +export interface ToastOptions { + dedupeKey?: string; + actions?: ToastAction[]; + persistent?: boolean; } interface ToastContextValue { toasts: Toast[]; toast: { - success: (message: string) => string; - error: (message: string) => string; - info: (message: string) => string; - loading: (message: string) => string; + success: (message: string, options?: ToastOptions) => string; + error: (message: string, options?: ToastOptions) => string; + info: (message: string, options?: ToastOptions) => string; + loading: (message: string, options?: ToastOptions) => string; + warning: (message: string, options?: ToastOptions) => string; /** Upgrade an existing loading toast to success or error */ - update: (id: string, message: string, type: Exclude) => void; + update: ( + id: string, + message: string, + type: Exclude, + options?: ToastOptions + ) => void; dismiss: (id: string) => void; + /** Show a retry notification with a live countdown and optional cancel. */ + retry: (message: string, options?: RetryToastOptions) => string; + /** Update the countdown text of an existing retry toast. */ + retryCountdown: (id: string, secondsRemaining: number) => void; + /** Show a queue status notification with optional actions. */ + queue: (message: string, options?: ToastOptions) => string; }; } +export interface RetryToastOptions extends ToastOptions { + /** Attempt number (1-based) for display, e.g. "Retry 2 of 5". */ + attempt?: number; + maxAttempts?: number; + /** Seconds until the next retry; enables a live countdown. */ + secondsRemaining?: number; + onCancel?: () => void; +} + const ToastContext = createContext(null); const MAX_TOASTS = 5; const AUTO_DISMISS_MS = 4000; +const RETRY_TOAST_DISMISS_MS = 8000; +const QUEUE_TOAST_DISMISS_MS = 6000; export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]); + const toastsRef = useRef([]); + toastsRef.current = toasts; const timers = useRef>>(new Map()); const dismiss = useCallback((id: string) => { @@ -46,43 +88,129 @@ export function ToastProvider({ children }: { children: ReactNode }) { }, []); const scheduleAutoDismiss = useCallback( - (id: string) => { - const timer = setTimeout(() => dismiss(id), AUTO_DISMISS_MS); + (id: string, durationMs: number = AUTO_DISMISS_MS) => { + clearTimeout(timers.current.get(id)); + const timer = setTimeout(() => dismiss(id), durationMs); timers.current.set(id, timer); }, [dismiss] ); const add = useCallback( - (message: string, type: ToastType): string => { + (message: string, type: ToastType, options: ToastOptions = {}): string => { + const { dedupeKey, actions, persistent } = options; + + // Deduplicate: if a toast with the same key exists, update it in place. + if (dedupeKey) { + const existing = toastsRef.current.find((t) => t.dedupeKey === dedupeKey); + if (existing) { + setToasts((prev) => + prev.map((t) => + t.id === existing.id + ? { ...t, message, type, actions, persistent } + : t + ) + ); + if (type !== "loading" && !persistent) scheduleAutoDismiss(existing.id); + else clearTimeout(timers.current.get(existing.id)); + return existing.id; + } + } + const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2)}`; setToasts((prev) => { - const next = [...prev, { id, message, type }]; + const next = [ + ...prev, + { id, message, type, dedupeKey, actions, persistent }, + ]; return next.length > MAX_TOASTS ? next.slice(next.length - MAX_TOASTS) : next; }); - if (type !== "loading") scheduleAutoDismiss(id); + if (type !== "loading" && !persistent) scheduleAutoDismiss(id); return id; }, [scheduleAutoDismiss] ); const update = useCallback( - (id: string, message: string, type: Exclude) => { + ( + id: string, + message: string, + type: Exclude, + options: ToastOptions = {} + ) => { setToasts((prev) => - prev.map((t) => (t.id === id ? { ...t, message, type } : t)) + prev.map((t) => + t.id === id + ? { + ...t, + message, + type, + actions: options.actions ?? t.actions, + persistent: options.persistent ?? t.persistent, + } + : t + ) ); - scheduleAutoDismiss(id); + if (options.persistent) clearTimeout(timers.current.get(id)); + else scheduleAutoDismiss(id); }, [scheduleAutoDismiss] ); + const retry = useCallback( + (message: string, options: RetryToastOptions = {}): string => { + const { attempt, maxAttempts, secondsRemaining, onCancel, ...rest } = options; + const parts: string[] = [message]; + if (attempt != null && maxAttempts != null) { + parts.push(`(attempt ${attempt} of ${maxAttempts})`); + } + if (secondsRemaining != null) { + parts.push(`Retrying in ${secondsRemaining}s…`); + } + const actions: ToastAction[] = [...(rest.actions ?? [])]; + if (onCancel) actions.push({ label: "Cancel", onClick: onCancel }); + return add(parts.join(" "), "warning", { + ...rest, + actions: actions.length > 0 ? actions : undefined, + persistent: rest.persistent ?? true, + }); + }, + [add] + ); + + const retryCountdown = useCallback( + (id: string, secondsRemaining: number) => { + setToasts((prev) => + prev.map((t) => { + if (t.id !== id) return t; + const base = t.message.replace(/\s*Retrying in \d+s…$/, ""); + return { ...t, message: `${base} Retrying in ${secondsRemaining}s…` }; + }) + ); + }, + [] + ); + + const queue = useCallback( + (message: string, options: ToastOptions = {}): string => + add(message, "info", { + ...options, + persistent: options.persistent ?? false, + }), + [add] + ); + const toast = { - success: (message: string) => add(message, "success"), - error: (message: string) => add(message, "error"), - info: (message: string) => add(message, "info"), - loading: (message: string) => add(message, "loading"), + success: (message: string, options?: ToastOptions) => add(message, "success", options), + error: (message: string, options?: ToastOptions) => add(message, "error", options), + info: (message: string, options?: ToastOptions) => add(message, "info", options), + loading: (message: string, options?: ToastOptions) => add(message, "loading", options), + warning: (message: string, options?: ToastOptions) => add(message, "warning", options), update, dismiss, + retry, + retryCountdown, + queue, }; return ( @@ -106,6 +234,7 @@ const TYPE_STYLES: Record = { error: "bg-red-800 text-red-100 border-red-700", info: "bg-indigo-800 text-indigo-100 border-indigo-700", loading: "bg-gray-800 text-gray-100 border-gray-700", + warning: "bg-amber-800 text-amber-100 border-amber-700", }; const TYPE_ICONS: Record = { @@ -113,6 +242,7 @@ const TYPE_ICONS: Record = { error: "✕", info: "ℹ", loading: "⟳", + warning: "↻", }; function ToastContainer({ @@ -144,6 +274,23 @@ function ToastContainer({ {TYPE_ICONS[t.type]} {t.message} + {t.actions && t.actions.length > 0 && ( + + {t.actions.map((action, i) => ( + + ))} + + )}