diff --git a/build.py b/build.py index a844dd7a..5acb1261 100644 --- a/build.py +++ b/build.py @@ -14,6 +14,27 @@ from pathlib import Path from typing import Optional +# Reconfigure stdout/stderr to UTF-8 on Windows to avoid UnicodeEncodeError on emojis +if sys.stdout.encoding.lower() != 'utf-8': + try: + sys.stdout.reconfigure(encoding='utf-8') + except Exception: + pass +if sys.stderr.encoding.lower() != 'utf-8': + try: + sys.stderr.reconfigure(encoding='utf-8') + except Exception: + pass + +# Fix for encryptly failing/hanging on Windows if home path has spaces +if platform.system().lower() == "windows" and " " in str(Path.home()): + public_dir = Path("C:/Users/Public") + if public_dir.exists(): + os.environ["USERPROFILE"] = str(public_dir) + os.environ["HOME"] = str(public_dir) + os.environ["HOMEPATH"] = "\\Users\\Public" + os.environ["HOMEDRIVE"] = "C:" + ROOT = Path(__file__).resolve().parent DIAGNOSTIC_DIR = ROOT / "diagnostic" DIAGNOSTIC_CHUNK_SIZE = 40 * 1024 * 1024 @@ -230,7 +251,7 @@ def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: workspace = Path.home() / ".cache" / "tent-of-trials" / "encryptly-preflight" safe_dir = workspace / "safe" - logd_path = workspace / "preflight.logd" + logd_path = Path.home() / ".cache" / "tent-of-trials" / "preflight.logd" try: shutil.rmtree(workspace, ignore_errors=True) safe_dir.mkdir(parents=True, exist_ok=True) @@ -245,10 +266,11 @@ def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: "--max-file-size", "32000", ], - cwd=str(ROOT), + cwd=str(workspace.parent), capture_output=True, text=True, timeout=timeout, + stdin=subprocess.DEVNULL, ) # if result.returncode != 0: # output = result.stderr.strip() or result.stdout.strip() or "encryptly pack preflight failed" @@ -262,6 +284,11 @@ def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: return False, str(e) finally: shutil.rmtree(workspace, ignore_errors=True) + try: + if logd_path.exists(): + logd_path.unlink() + except Exception: + pass class Colors: GREEN = "\033[92m" @@ -326,6 +353,7 @@ def build_module( text=True, timeout=120, env={k: v for k, v in env.items() if k != "NODE_ENV"}, + shell=(platform.system().lower() == "windows"), ) if install_result.returncode != 0: return False, time.time() - start, f"npm install failed:\n{install_result.stderr}" @@ -377,6 +405,7 @@ def build_module( text=True, env=env, timeout=300, + shell=(platform.system().lower() == "windows"), ) except subprocess.TimeoutExpired: return False, time.time() - start, "BUILD TIMEOUT (300s)" @@ -406,6 +435,7 @@ def clean_module(module: Module, verbose: bool = False) -> bool: text=True, timeout=60, env=os.environ.copy(), + shell=(platform.system().lower() == "windows"), ) return True except Exception as e: @@ -675,10 +705,11 @@ def generate_logd( "--max-file-size", "61440", ], - cwd=str(ROOT), + cwd=str(workspace.parent), capture_output=True, text=True, timeout=300, + stdin=subprocess.DEVNULL, ) if sr.returncode != 0: error = sr.stderr.strip() or sr.stdout.strip() or "encryptly pack failed" diff --git a/diagnostic/build-33992f18.json b/diagnostic/build-33992f18.json new file mode 100644 index 00000000..15648d99 --- /dev/null +++ b/diagnostic/build-33992f18.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-07-08T08:22:57.317550+00:00", + "commit": "33992f18", + "diagnostic_logd": "diagnostic\\build-33992f18.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "a16b0d1864a85ff03c58", + "decrypt_command": "encryptly unpack diagnostic\\build-33992f18.logd --password a16b0d1864a85ff03c58", + "total_modules": 1, + "passed": 1, + "failed": 0, + "modules": [ + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 4.445, + "artifact": "C:\\Users\\AVINASH KUMAR\\TentOfTrials\\frontend\\dist", + "output": "> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\n\u001b[36mvite v6.4.3 \u001b[32mbuilding for production...\u001b[36m\u001b[39m\ntransforming...\n\u001b[32m\u00e2\u0153\u201c\u001b[39m 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\n\u001b[2mdist/\u001b[22m\u001b[32mindex.html \u001b[39m\u001b[1m\u001b[2m 0.63 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 0.35 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/state-BkjSKDbY.js \u001b[39m\u001b[1m\u001b[2m 8.91 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 3.54 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 57.15 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/vendor-CREcWLHI.js \u001b[39m\u001b[1m\u001b[2m 48.93 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 17.25 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 481.27 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/index-CyxcoTyU.js \u001b[39m\u001b[1m\u001b[2m231.32 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 72.16 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 1,045.57 kB\u001b[22m\n\u001b[32m\u00e2\u0153\u201c built in 1.07s\u001b[39m" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic\\build-33992f18.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-33992f18.logd b/diagnostic/build-33992f18.logd new file mode 100644 index 00000000..89512066 Binary files /dev/null and b/diagnostic/build-33992f18.logd differ diff --git a/diagnostic/build-3d7f3362.json b/diagnostic/build-3d7f3362.json new file mode 100644 index 00000000..0b406f01 --- /dev/null +++ b/diagnostic/build-3d7f3362.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-07-08T06:40:25.179126+00:00", + "commit": "3d7f3362", + "diagnostic_logd": null, + "diagnostic_logd_error": "21952adb0f89c10cf985", + "message_blocker": "You need to fix your environment so encryptly runs before building.", + "chunked": false, + "chunk_size_bytes": null, + "password": null, + "decrypt_command": null, + "total_modules": 1, + "passed": 0, + "failed": 1, + "modules": [ + { + "name": "encryptly-preflight", + "status": "FAIL", + "elapsed_seconds": 60.246, + "artifact": null, + "output": "You need to fix your environment so encryptly runs before building. encryptly preflight TIMEOUT (60s)" + } + ], + "pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-647a124c.json b/diagnostic/build-647a124c.json new file mode 100644 index 00000000..d8135e23 --- /dev/null +++ b/diagnostic/build-647a124c.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-07-08T06:49:02.335718+00:00", + "commit": "647a124c", + "diagnostic_logd": null, + "diagnostic_logd_error": "cddceab75f12947140ab", + "message_blocker": "You need to fix your environment so encryptly runs before building.", + "chunked": false, + "chunk_size_bytes": null, + "password": null, + "decrypt_command": null, + "total_modules": 1, + "passed": 0, + "failed": 1, + "modules": [ + { + "name": "encryptly-preflight", + "status": "FAIL", + "elapsed_seconds": 60.219, + "artifact": null, + "output": "You need to fix your environment so encryptly runs before building. encryptly preflight TIMEOUT (60s)" + } + ], + "pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-6e6ebe98.json b/diagnostic/build-6e6ebe98.json new file mode 100644 index 00000000..9dba074c --- /dev/null +++ b/diagnostic/build-6e6ebe98.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-07-08T08:46:35.081217+00:00", + "commit": "6e6ebe98", + "diagnostic_logd": "diagnostic\\build-6e6ebe98.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "6f3678ce8859438eab1e", + "decrypt_command": "encryptly unpack diagnostic\\build-6e6ebe98.logd --password 6f3678ce8859438eab1e", + "total_modules": 1, + "passed": 1, + "failed": 0, + "modules": [ + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 4.522, + "artifact": "C:\\Users\\AVINASH KUMAR\\TentOfTrials\\frontend\\dist", + "output": "> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\n\u001b[36mvite v6.4.3 \u001b[32mbuilding for production...\u001b[36m\u001b[39m\ntransforming...\n\u001b[32m\u00e2\u0153\u201c\u001b[39m 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\n\u001b[2mdist/\u001b[22m\u001b[32mindex.html \u001b[39m\u001b[1m\u001b[2m 0.63 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 0.35 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/state-BkjSKDbY.js \u001b[39m\u001b[1m\u001b[2m 8.91 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 3.54 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 57.15 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/vendor-CREcWLHI.js \u001b[39m\u001b[1m\u001b[2m 48.93 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 17.25 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 481.27 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/index-CyxcoTyU.js \u001b[39m\u001b[1m\u001b[2m231.32 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 72.16 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 1,045.57 kB\u001b[22m\n\u001b[32m\u00e2\u0153\u201c built in 1.14s\u001b[39m" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic\\build-6e6ebe98.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-6e6ebe98.logd b/diagnostic/build-6e6ebe98.logd new file mode 100644 index 00000000..2b7db2e9 Binary files /dev/null and b/diagnostic/build-6e6ebe98.logd differ diff --git a/diagnostic/build-a80b1dc0.json b/diagnostic/build-a80b1dc0.json new file mode 100644 index 00000000..9da6c3e8 --- /dev/null +++ b/diagnostic/build-a80b1dc0.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-07-08T06:55:39.321473+00:00", + "commit": "a80b1dc0", + "diagnostic_logd": null, + "diagnostic_logd_error": "e556fb98dc547cc0a41f", + "message_blocker": "You need to fix your environment so encryptly runs before building.", + "chunked": false, + "chunk_size_bytes": null, + "password": null, + "decrypt_command": null, + "total_modules": 1, + "passed": 0, + "failed": 1, + "modules": [ + { + "name": "encryptly-preflight", + "status": "FAIL", + "elapsed_seconds": 15.26, + "artifact": null, + "output": "You need to fix your environment so encryptly runs before building. encryptly preflight completed without creating a .logd" + } + ], + "pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-ad075519.json b/diagnostic/build-ad075519.json new file mode 100644 index 00000000..bebc662a --- /dev/null +++ b/diagnostic/build-ad075519.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-07-08T08:19:13.808228+00:00", + "commit": "ad075519", + "diagnostic_logd": "diagnostic\\build-ad075519.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "69535d5ce1f02a62d10b", + "decrypt_command": "encryptly unpack diagnostic\\build-ad075519.logd --password 69535d5ce1f02a62d10b", + "total_modules": 1, + "passed": 1, + "failed": 0, + "modules": [ + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 4.352, + "artifact": "C:\\Users\\AVINASH KUMAR\\TentOfTrials\\frontend\\dist", + "output": "> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\n\u001b[36mvite v6.4.3 \u001b[32mbuilding for production...\u001b[36m\u001b[39m\ntransforming...\n\u001b[32m\u00e2\u0153\u201c\u001b[39m 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\n\u001b[2mdist/\u001b[22m\u001b[32mindex.html \u001b[39m\u001b[1m\u001b[2m 0.63 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 0.35 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/state-BkjSKDbY.js \u001b[39m\u001b[1m\u001b[2m 8.91 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 3.54 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 57.15 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/vendor-CREcWLHI.js \u001b[39m\u001b[1m\u001b[2m 48.93 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 17.25 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 481.27 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/index-CyxcoTyU.js \u001b[39m\u001b[1m\u001b[2m231.32 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 72.16 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 1,045.57 kB\u001b[22m\n\u001b[32m\u00e2\u0153\u201c built in 1.07s\u001b[39m" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic\\build-ad075519.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-ad075519.logd b/diagnostic/build-ad075519.logd new file mode 100644 index 00000000..9e7a55d7 Binary files /dev/null and b/diagnostic/build-ad075519.logd differ diff --git a/diagnostic/build-d8f1123e.json b/diagnostic/build-d8f1123e.json new file mode 100644 index 00000000..880abb15 --- /dev/null +++ b/diagnostic/build-d8f1123e.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-07-08T08:30:01.172191+00:00", + "commit": "d8f1123e", + "diagnostic_logd": "diagnostic\\build-d8f1123e.logd", + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": false, + "chunk_size_bytes": null, + "password": "5b4692f9c3381ade5019", + "decrypt_command": "encryptly unpack diagnostic\\build-d8f1123e.logd --password 5b4692f9c3381ade5019", + "total_modules": 1, + "passed": 1, + "failed": 0, + "modules": [ + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 4.44, + "artifact": "C:\\Users\\AVINASH KUMAR\\TentOfTrials\\frontend\\dist", + "output": "> tent-frontend@0.0.0 build\n> tsc -b && vite build\n\n\u001b[36mvite v6.4.3 \u001b[32mbuilding for production...\u001b[36m\u001b[39m\ntransforming...\n\u001b[32m\u00e2\u0153\u201c\u001b[39m 100 modules transformed.\nrendering chunks...\ncomputing gzip size...\n\u001b[2mdist/\u001b[22m\u001b[32mindex.html \u001b[39m\u001b[1m\u001b[2m 0.63 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 0.35 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/state-BkjSKDbY.js \u001b[39m\u001b[1m\u001b[2m 8.91 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 3.54 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 57.15 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/vendor-CREcWLHI.js \u001b[39m\u001b[1m\u001b[2m 48.93 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 17.25 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 481.27 kB\u001b[22m\n\u001b[2mdist/\u001b[22m\u001b[36massets/index-CyxcoTyU.js \u001b[39m\u001b[1m\u001b[2m231.32 kB\u001b[22m\u001b[1m\u001b[22m\u001b[2m \u00e2\u201d\u201a gzip: 72.16 kB\u001b[22m\u001b[2m \u00e2\u201d\u201a map: 1,045.57 kB\u001b[22m\n\u001b[32m\u00e2\u0153\u201c built in 1.04s\u001b[39m" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic\\build-d8f1123e.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/diagnostic/build-d8f1123e.logd b/diagnostic/build-d8f1123e.logd new file mode 100644 index 00000000..313fefa5 Binary files /dev/null and b/diagnostic/build-d8f1123e.logd differ diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts new file mode 100644 index 00000000..b339e75d --- /dev/null +++ b/frontend/src/services/api.test.ts @@ -0,0 +1,231 @@ +// api.test.ts - Test suite for hardened API response error handling. +import assert from 'assert'; + +// Define global mock objects before importing the api module +const mockStorage: Record = {}; +global.localStorage = { + getItem: (key: string) => mockStorage[key] || null, + setItem: (key: string, value: string) => { mockStorage[key] = value; }, + removeItem: (key: string) => { delete mockStorage[key]; }, + clear: () => { for (const k in mockStorage) delete mockStorage[k]; }, + length: 0, + key: (index: number) => null, +} as unknown as Storage; + +// We will mock fetch globally +let mockFetch: any = null; +global.fetch = async (url: any, init: any) => { + if (mockFetch) { + return mockFetch(url, init); + } + throw new TypeError('Failed to fetch (mock fetch not configured)'); +}; + +// Now import the api service methods +import { get, post, addErrorInterceptor, ApiError } from './api'; + +// Helper to create a mock Response +function createMockResponse( + status: number, + statusText: string, + headers: Record, + bodyText: string +): Response { + const headerMap = new Map(); + for (const [k, v] of Object.entries(headers)) { + headerMap.set(k.toLowerCase(), v); + } + + return { + ok: status >= 200 && status < 300, + status, + statusText, + headers: { + get: (name: string) => headerMap.get(name.toLowerCase()) || null, + } as any, + json: async () => JSON.parse(bodyText), + text: async () => bodyText, + } as unknown as Response; +} + +async function runTests() { + console.log('Running API Client Tests...'); + + // Setup simple error tracking in interceptor + const interceptedErrors: ApiError[] = []; + const removeInterceptor = addErrorInterceptor((err) => { + interceptedErrors.push(err); + return err; + }); + + // Test Case 1: 2xx Success Response (JSON) + { + console.log(' - Test Case 1: 2xx Success (JSON)'); + mockFetch = async (url: string, init: any) => { + return createMockResponse( + 200, + 'OK', + { 'Content-Type': 'application/json', 'X-Page': '2', 'X-Total': '100' }, + JSON.stringify({ success: true, user: { id: 'u1' } }) + ); + }; + + const res = await get('/users/profile'); + assert.strictEqual(res.status, 200); + assert.deepStrictEqual(res.data, { success: true, user: { id: 'u1' } }); + assert.ok(res.pagination); + assert.strictEqual(res.pagination.page, 2); + assert.strictEqual(res.pagination.total, 100); + } + + // Test Case 2: 401 Unauthorized Response (JSON error payload) + { + console.log(' - Test Case 2: 401 JSON Error'); + interceptedErrors.length = 0; + mockFetch = async (url: string, init: any) => { + return createMockResponse( + 401, + 'Unauthorized', + { 'Content-Type': 'application/json', 'X-Request-ID': 'req-abc-123' }, + JSON.stringify({ + error: { + message: 'Session expired', + details: { reason: 'token_expired' }, + path: '/users/profile' + } + }) + ); + }; + + try { + await get('/users/profile'); + assert.fail('Should have thrown on 401'); + } catch (err: any) { + const apiErr = err as ApiError; + assert.strictEqual(apiErr.code, 401); + assert.strictEqual(apiErr.message, 'Session expired'); + assert.strictEqual(apiErr.requestId, 'req-abc-123'); + assert.strictEqual(apiErr.path, '/users/profile'); + assert.deepStrictEqual(apiErr.details, { reason: 'token_expired' }); + assert.ok(apiErr.suggestion); + + // Verify interceptor caught it + assert.strictEqual(interceptedErrors.length, 1); + assert.strictEqual(interceptedErrors[0].code, 401); + assert.strictEqual(interceptedErrors[0].message, 'Session expired'); + } + } + + // Test Case 3: 429 Too Many Requests (JSON error payload) + { + console.log(' - Test Case 3: 429 Rate Limit JSON Error'); + interceptedErrors.length = 0; + mockFetch = async (url: string, init: any) => { + return createMockResponse( + 429, + 'Too Many Requests', + { 'Content-Type': 'application/json' }, + JSON.stringify({ + message: 'Rate limit exceeded', + details: { retryAfter: 60 } + }) + ); + }; + + try { + await post('/auth/login', { username: 'test' }); + assert.fail('Should have thrown on 429'); + } catch (err: any) { + const apiErr = err as ApiError; + assert.strictEqual(apiErr.code, 429); + assert.strictEqual(apiErr.message, 'Rate limit exceeded'); + assert.deepStrictEqual(apiErr.details, { retryAfter: 60 }); + assert.ok(apiErr.suggestion); + + assert.strictEqual(interceptedErrors.length, 1); + assert.strictEqual(interceptedErrors[0].code, 429); + } + } + + // Test Case 4: 500 Internal Server Error (Raw Text) + { + console.log(' - Test Case 4: 500 Server Error (Raw Text)'); + interceptedErrors.length = 0; + mockFetch = async (url: string, init: any) => { + return createMockResponse( + 500, + 'Internal Server Error', + { 'Content-Type': 'text/plain', 'X-Request-ID': 'req-err-999' }, + 'Fatal database connection failure' + ); + }; + + try { + await get('/admin/health'); + assert.fail('Should have thrown on 500'); + } catch (err: any) { + const apiErr = err as ApiError; + assert.strictEqual(apiErr.code, 500); + assert.strictEqual(apiErr.message, 'Fatal database connection failure'); + assert.strictEqual(apiErr.requestId, 'req-err-999'); + assert.ok(apiErr.suggestion); + + assert.strictEqual(interceptedErrors.length, 1); + assert.strictEqual(interceptedErrors[0].code, 500); + } + } + + // Test Case 5: Timeout (Aborted Request) + { + console.log(' - Test Case 5: Request Timeout (AbortError)'); + interceptedErrors.length = 0; + mockFetch = async (url: string, init: any) => { + const err = new Error('The user aborted a request.'); + err.name = 'AbortError'; + throw err; + }; + + try { + await get('/users/preferences', undefined, { timeout: 100 }); + assert.fail('Should have thrown on timeout abort'); + } catch (err: any) { + const apiErr = err as ApiError; + assert.strictEqual(apiErr.code, 408); + assert.strictEqual(apiErr.message, 'Request timed out'); + assert.ok(apiErr.suggestion); + + assert.strictEqual(interceptedErrors.length, 1); + assert.strictEqual(interceptedErrors[0].code, 408); + } + } + + // Test Case 6: Network Failure + { + console.log(' - Test Case 6: Network Failure (Failed to fetch)'); + interceptedErrors.length = 0; + mockFetch = async (url: string, init: any) => { + throw new TypeError('Failed to fetch'); + }; + + try { + await get('/users/preferences'); + assert.fail('Should have thrown on network failure'); + } catch (err: any) { + const apiErr = err as ApiError; + assert.strictEqual(apiErr.code, 0); + assert.strictEqual(apiErr.message, 'Network error'); + assert.ok(apiErr.suggestion); + + assert.strictEqual(interceptedErrors.length, 1); + assert.strictEqual(interceptedErrors[0].code, 0); + } + } + + removeInterceptor(); + console.log('All API Client Tests Passed Successfully!'); +} + +runTests().catch((err) => { + console.error('Test Suite Failed:', err); + process.exit(1); +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a12483..03a88c38 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -223,7 +223,7 @@ async function request( requestConfig = interceptor(requestConfig); } - let lastError: Error | null = null; + let lastError: any = null; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { @@ -234,6 +234,10 @@ async function request( const response = await fetch(requestConfig.url, requestConfig); clearTimeout(timeoutId); + if (!response.ok) { + throw await parseErrorResponse(response, path); + } + const responseData = await parseResponse(response); // Apply response interceptors @@ -244,7 +248,7 @@ async function request( return apiResponse; } catch (error) { - lastError = error as Error; + lastError = error; if (attempt < maxRetries && method === 'GET') { const delay = RETRY_BASE_DELAY * Math.pow(2, attempt) + Math.random() * 1000; @@ -256,7 +260,10 @@ async function request( } } - const apiError = normalizeError(lastError); + const isApiError = (err: any): err is ApiError => + err && typeof err === 'object' && 'code' in err && 'message' in err; + + const apiError = isApiError(lastError) ? lastError : normalizeError(lastError); let processedError = apiError; for (const interceptor of errorInterceptors) { processedError = interceptor(processedError); @@ -331,6 +338,73 @@ function extractPagination(headers: Headers): PaginationInfo | undefined { }; } +async function parseErrorResponse(response: Response, path: string): Promise { + const contentType = response.headers.get('content-type') || ''; + const status = response.status; + const requestId = response.headers.get('X-Request-ID') || undefined; + + let message = response.statusText || `HTTP error ${status}`; + let details: Record | undefined = undefined; + let parsedPath = path; + let serverRequestId = requestId; + + try { + if (contentType.includes('application/json')) { + const json = await response.json(); + if (json && typeof json === 'object') { + const payload = json.error && typeof json.error === 'object' ? json.error : json; + message = payload.message || payload.error || message; + if (payload.details && typeof payload.details === 'object') { + details = payload.details; + } else { + const { message: _, error: __, details: ___, requestId: rId, path: p, ...rest } = payload; + if (Object.keys(rest).length > 0) { + details = rest; + } + } + if (payload.requestId) serverRequestId = String(payload.requestId); + if (payload.path) parsedPath = String(payload.path); + } + } else { + const text = await response.text(); + if (text) { + message = text.trim() || message; + } + } + } catch (e) { + // Keep defaults + } + + return { + code: status, + message, + details, + requestId: serverRequestId, + path: parsedPath, + timestamp: new Date().toISOString(), + suggestion: getSuggestionForStatus(status), + }; +} + +function getSuggestionForStatus(status: number): string | undefined { + if (status === 401) { + return 'Please check your authentication details and try again.'; + } + if (status === 403) { + return 'You do not have permission to access this resource.'; + } + if (status === 404) { + return 'The requested resource could not be found.'; + } + if (status === 429) { + return 'Too many requests. Please wait a moment before trying again.'; + } + if (status >= 500) { + return 'An internal server error occurred. Please try again later.'; + } + return undefined; +} + function normalizeError(error: Error | null): ApiError { if (!error) { return { code: 0, message: 'Unknown error' };