Skip to content

Commit 5c1e05f

Browse files
test: add API route error-handling consistency tests (#1118) (#1119)
Co-authored-by: Ona <no-reply@ona.com>
1 parent 33e5ece commit 5c1e05f

2 files changed

Lines changed: 239 additions & 4 deletions

File tree

.agents/quality.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Tracks code quality per domain. Updated by automations as a side effect of featu
1414

1515
| Domain | Grade | Notes |
1616
|---|---|---|
17-
| Infrastructure | A | Sentry (client + server + edge), proxy with session refresh, health endpoint with tests (9 tests), PWA manifest, global error boundary, Supabase clients (browser + server + proxy). JetBrains Mono font correctly configured. Dark-only oklch theme tokens. Migration validation tests (33 tests). |
17+
| Infrastructure | A | Sentry (client + server + edge), proxy with session refresh, health endpoint with tests (9 tests), PWA manifest, global error boundary, Supabase clients (browser + server + proxy). JetBrains Mono font correctly configured. Dark-only oklch theme tokens. Migration validation tests (33 tests). API route error-handling consistency tests (6 tests). |
1818
| Auth | A | Sign-in, sign-up, invite accept pages. OAuth sign-in with GitHub and Google fully functional. Auth guard in app layout with redirect. Error boundaries on all auth routes (5 error.tsx files, 22 tests). Loading state for invite page. Typography regression test (2 tests). Sign-in unit tests (10 tests): form validation, error handling, redirect logic, loading state. Sign-up unit tests (11 tests): form validation, error handling, redirect logic, loading state. Auth callback route tests (7 tests). Root page tests (4 tests). OAuth buttons unit tests (10 tests). E2E spec covers form rendering, redirect, and sign-in flow (8 tests). |
1919
| Workspaces | A | Workspace home, settings (name/slug/delete), workspace switcher with create dialog. Slug generation utility with unit tests (12 tests). Settings form unit tests (17 tests): validation, save, slug sanitization, delete confirmation flow, error handling. Create workspace dialog unit tests (5 tests). E2E specs cover workspace creation (3 tests), workspace settings (3 tests), and workspace limit enforcement (2 tests). Max 3 workspace limit enforced via DB trigger. |
2020
| Pages | A | Page view with title + editor, page tree with CRUD + drag-and-drop + nest/unnest. Page menu with export/import markdown. Page icon picker with emoji support. Cover images, backlinks, version history, favorites, trash/soft-delete, page duplication, inline page link search. Keyboard shortcuts for duplicate (⌘D) and export (⌘⇧E). Tree logic extracted to `src/lib/page-tree.ts` with 37 unit tests covering build, reorder, nest, unnest, and drop computation. Page tree keyboard shortcut tests (5 tests). Page icon design spec tests (1 test). Page visit error handling tests (2 tests). Persisted tree expansion tests (9 tests). 10 E2E specs: page CRUD (5), sidebar drag (2), page icon (4), page cover (5), page duplicate (4), favorites (7), trash (7), version history (5), page link search (4), page shortcuts (4) — 47 tests total. |
@@ -33,9 +33,9 @@ Tracks code quality per domain. Updated by automations as a side effect of featu
3333

3434
| Category | Files | Tests |
3535
|---|---|---|
36-
| Unit/Integration (Vitest) | 142 | 1911 |
36+
| Unit/Integration (Vitest) | 143 | 1918 |
3737
| E2E (Playwright) | 82 | 406 |
38-
| **Total** | **224** | **2317** |
38+
| **Total** | **225** | **2324** |
3939

4040
### Test files by domain
4141

@@ -51,7 +51,7 @@ Tracks code quality per domain. Updated by automations as a side effect of featu
5151
- **Feedback**: `feedback/route.test.ts` (22 tests), `feedback-form-design-spec.test.ts` (5 tests), `use-screenshot.test.ts` (2 tests), `e2e/feedback.spec.ts` (6 tests)
5252
- **API**: `health/route.test.ts` (9 tests), `search/route.test.ts` (14 tests), `account/route.test.ts` (9 tests), `cron/purge-trash/route.test.ts` (10 tests), `feedback/route.test.ts` (22 tests), `pages/[pageId]/versions/route.test.ts` (17 tests), `pages/[pageId]/versions/route.rate-limit.test.ts` (3 tests), `pages/[pageId]/versions/[versionId]/route.test.ts` (14 tests), `pages/[pageId]/versions/[versionId]/route.rate-limit.test.ts` (3 tests), `e2e/account-deletion.spec.ts` (4 tests), `e2e/account-settings.spec.ts` (5 tests)
5353
- **UI**: `overlay-opacity.test.ts` (2 tests), `toast-error-duration.test.ts` (1 test), `dialog-design-spec.test.ts` (3 tests), `design-spec-compliance.test.ts` (9 tests), `relative-time.test.ts` (7 tests), `reduced-motion.test.ts` (6 tests), `e2e/visual-regression.spec.ts` (1 test)
54-
- **Lib**: `sentry.test.ts` (4 tests), `sentry.unit.test.ts` (171 tests), `retry.test.ts` (9 tests), `rate-limit.test.ts` (17 tests), `track-event-server.test.ts` (9 tests), `track-event.test.ts` (4 tests), `usage-tracking-guard.test.ts` (5 tests)
54+
- **Lib**: `sentry.test.ts` (4 tests), `sentry.unit.test.ts` (171 tests), `api-route-consistency.test.ts` (6 tests), `retry.test.ts` (9 tests), `rate-limit.test.ts` (17 tests), `track-event-server.test.ts` (9 tests), `track-event.test.ts` (4 tests), `usage-tracking-guard.test.ts` (5 tests)
5555
- **Infrastructure**: `migrations.test.ts` (33 tests), `build-timeseries.test.mjs` (6 tests), `supabase/client.test.ts` (7 tests), `supabase/proxy.test.ts` (2 tests)
5656

5757
## Known Gaps
@@ -157,5 +157,6 @@ Tracks code quality per domain. Updated by automations as a side effect of featu
157157
| 2026-05-14 | Filter HeadlessChrome submissions from feedback API (#1097). Updated `feedback/route.test.ts` (20→22): 2 tests for HeadlessChrome UA filter (silent discard without insert, normal UA inserts normally). Test totals: 142 Vitest files (1911 tests), 81 E2E specs (398 tests). |
158158
| 2026-05-15 | Add E2E tests for database person property type (#1101). Added 1 new E2E spec: `e2e/database-person.spec.ts` (5 tests): open picker and select member, search filters members by name, clear value by deselecting, person value on row detail page, editor closes on Escape. Test totals: 142 Vitest files (1911 tests), 82 E2E specs (403 tests). |
159159
| 2026-05-15 | Extend axe-core accessibility audit to account and password reset pages (#1110). Added 3 tests to `e2e/accessibility.spec.ts` (10→13): forgot-password, reset-password, account settings. Test totals: 142 Vitest files (1911 tests), 82 E2E specs (406 tests). |
160+
| 2026-05-16 | API route error-handling consistency tests (#1118). Added 1 new Vitest file: `api-route-consistency.test.ts` (6 tests) — structural convention tests verifying API routes with Supabase mutations import error classification utilities (captureSupabaseError, captureApiError, isForeignKeyViolationError) and use Sentry capture in catch blocks instead of bare console.error. Test totals: 143 Vitest files (1918 tests), 82 E2E specs (406 tests). |
160161

161162

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readdirSync, readFileSync, statSync } from "fs";
3+
import { join, relative } from "path";
4+
5+
/**
6+
* Structural convention tests for API route error handling (Issue #1118).
7+
*
8+
* Validates that API routes performing Supabase mutations use the standard
9+
* error classification utilities (captureSupabaseError, captureApiError,
10+
* isForeignKeyViolationError) instead of bare console.error or unclassified
11+
* catch blocks. This prevents the "forgot to add the check in the new
12+
* endpoint" pattern that caused repeated Sentry noise bugs.
13+
*/
14+
15+
const API_DIR = join(__dirname, "..", "..", "app", "api");
16+
const PROJECT_ROOT = join(__dirname, "..", "..", "..");
17+
18+
/**
19+
* Routes that intentionally skip certain checks.
20+
* Each entry must have a comment explaining why.
21+
*/
22+
const ALLOWLIST: Record<string, string> = {
23+
// Health endpoint uses raw fetch (no Supabase client), no mutations,
24+
// and is monitored externally — intentionally silent on errors.
25+
"src/app/api/health/route.ts": "raw fetch health check, no Supabase client",
26+
};
27+
28+
/** Mutation method patterns — calls that write data. */
29+
const MUTATION_PATTERN = /\.(?:insert|update|delete|upsert)\s*\(/;
30+
31+
/** Supabase .from() call pattern — indicates Supabase table access. */
32+
const FROM_CALL_PATTERN = /\.from\s*\(/;
33+
34+
/** Supabase .rpc() call pattern — indicates Supabase RPC access. */
35+
const RPC_CALL_PATTERN = /\.rpc\s*\(/;
36+
37+
/** Sentry capture function patterns. */
38+
const CAPTURE_SUPABASE_ERROR = "captureSupabaseError";
39+
const CAPTURE_API_ERROR = "captureApiError";
40+
const IS_FK_VIOLATION = "isForeignKeyViolationError";
41+
42+
/** Bare console.error pattern. */
43+
const CONSOLE_ERROR_PATTERN = /\bconsole\.error\b/;
44+
45+
/** Catch block pattern — matches `catch (identifier)` or `catch (identifier: type)`. */
46+
const CATCH_BLOCK_PATTERN = /\bcatch\s*\(\s*\w+/g;
47+
48+
function collectRouteFiles(dir: string): string[] {
49+
const files: string[] = [];
50+
51+
function walk(d: string) {
52+
for (const entry of readdirSync(d)) {
53+
const full = join(d, entry);
54+
const stat = statSync(full);
55+
if (stat.isDirectory()) {
56+
walk(full);
57+
} else if (entry === "route.ts") {
58+
files.push(full);
59+
}
60+
}
61+
}
62+
63+
walk(dir);
64+
return files;
65+
}
66+
67+
function toRelative(absPath: string): string {
68+
return relative(PROJECT_ROOT, absPath);
69+
}
70+
71+
describe("API route error-handling consistency", () => {
72+
const routeFiles = collectRouteFiles(API_DIR);
73+
74+
it("finds at least one API route file", () => {
75+
expect(routeFiles.length).toBeGreaterThan(0);
76+
});
77+
78+
it("routes with mutations import captureSupabaseError or captureApiError", () => {
79+
const violations: string[] = [];
80+
81+
for (const file of routeFiles) {
82+
const rel = toRelative(file);
83+
if (ALLOWLIST[rel]) continue;
84+
85+
const content = readFileSync(file, "utf-8");
86+
87+
// Skip routes that don't use Supabase at all
88+
const usesSupabase =
89+
FROM_CALL_PATTERN.test(content) || RPC_CALL_PATTERN.test(content);
90+
if (!usesSupabase) continue;
91+
92+
const hasMutations =
93+
MUTATION_PATTERN.test(content) || RPC_CALL_PATTERN.test(content);
94+
if (!hasMutations) continue;
95+
96+
const hasCaptureSupabase = content.includes(CAPTURE_SUPABASE_ERROR);
97+
const hasCaptureApi = content.includes(CAPTURE_API_ERROR);
98+
99+
if (!hasCaptureSupabase && !hasCaptureApi) {
100+
violations.push(
101+
`${rel}: performs mutations but does not import ${CAPTURE_SUPABASE_ERROR} or ${CAPTURE_API_ERROR}`,
102+
);
103+
}
104+
}
105+
106+
expect(
107+
violations,
108+
`API routes with Supabase mutations must import error capture utilities:\n${violations.join("\n")}`,
109+
).toEqual([]);
110+
});
111+
112+
it("routes with mutations that use .insert/.update/.delete import isForeignKeyViolationError or captureSupabaseError", () => {
113+
const violations: string[] = [];
114+
115+
for (const file of routeFiles) {
116+
const rel = toRelative(file);
117+
if (ALLOWLIST[rel]) continue;
118+
119+
const content = readFileSync(file, "utf-8");
120+
121+
// Only check routes that perform table mutations (not RPC-only routes)
122+
const hasTableMutations = MUTATION_PATTERN.test(content);
123+
if (!hasTableMutations) continue;
124+
125+
// captureSupabaseError already classifies FK violations internally
126+
// (downgrades to warning level), so either explicit isForeignKeyViolationError
127+
// checks or captureSupabaseError usage satisfies this requirement.
128+
const hasFkCheck = content.includes(IS_FK_VIOLATION);
129+
const hasCaptureSupabase = content.includes(CAPTURE_SUPABASE_ERROR);
130+
131+
if (!hasFkCheck && !hasCaptureSupabase) {
132+
violations.push(
133+
`${rel}: performs table mutations but does not import ${IS_FK_VIOLATION} or ${CAPTURE_SUPABASE_ERROR}`,
134+
);
135+
}
136+
}
137+
138+
expect(
139+
violations,
140+
`API routes with table mutations must handle FK violations (via ${IS_FK_VIOLATION} or ${CAPTURE_SUPABASE_ERROR}):\n${violations.join("\n")}`,
141+
).toEqual([]);
142+
});
143+
144+
it("catch blocks use captureApiError or captureSupabaseError, not bare console.error", () => {
145+
const violations: string[] = [];
146+
147+
for (const file of routeFiles) {
148+
const rel = toRelative(file);
149+
if (ALLOWLIST[rel]) continue;
150+
151+
const content = readFileSync(file, "utf-8");
152+
const lines = content.split("\n");
153+
154+
// Skip routes without catch blocks
155+
CATCH_BLOCK_PATTERN.lastIndex = 0;
156+
if (!CATCH_BLOCK_PATTERN.test(content)) continue;
157+
158+
const hasCaptureSupabase = content.includes(CAPTURE_SUPABASE_ERROR);
159+
const hasCaptureApi = content.includes(CAPTURE_API_ERROR);
160+
161+
// If the route has catch blocks but no Sentry capture at all, flag it
162+
if (!hasCaptureSupabase && !hasCaptureApi) {
163+
violations.push(
164+
`${rel}: has catch blocks but does not use ${CAPTURE_API_ERROR} or ${CAPTURE_SUPABASE_ERROR}`,
165+
);
166+
continue;
167+
}
168+
169+
// Check for bare console.error without accompanying Sentry capture
170+
for (let i = 0; i < lines.length; i++) {
171+
if (CONSOLE_ERROR_PATTERN.test(lines[i])) {
172+
violations.push(
173+
`${rel}:${i + 1}: uses console.error — use ${CAPTURE_API_ERROR} or ${CAPTURE_SUPABASE_ERROR} instead`,
174+
);
175+
}
176+
}
177+
}
178+
179+
expect(
180+
violations,
181+
`API route catch blocks must use Sentry capture utilities, not bare console.error:\n${violations.join("\n")}`,
182+
).toEqual([]);
183+
});
184+
185+
it("routes with Supabase access have at least one catch block with error capture", () => {
186+
const violations: string[] = [];
187+
188+
for (const file of routeFiles) {
189+
const rel = toRelative(file);
190+
if (ALLOWLIST[rel]) continue;
191+
192+
const content = readFileSync(file, "utf-8");
193+
194+
const usesSupabase =
195+
FROM_CALL_PATTERN.test(content) || RPC_CALL_PATTERN.test(content);
196+
if (!usesSupabase) continue;
197+
198+
// Route uses Supabase — it should have error handling
199+
CATCH_BLOCK_PATTERN.lastIndex = 0;
200+
const hasCatchBlock = CATCH_BLOCK_PATTERN.test(content);
201+
const hasCaptureSupabase = content.includes(CAPTURE_SUPABASE_ERROR);
202+
const hasCaptureApi = content.includes(CAPTURE_API_ERROR);
203+
204+
if (!hasCatchBlock || (!hasCaptureSupabase && !hasCaptureApi)) {
205+
violations.push(
206+
`${rel}: uses Supabase but lacks a catch block with ${CAPTURE_API_ERROR}/${CAPTURE_SUPABASE_ERROR}`,
207+
);
208+
}
209+
}
210+
211+
expect(
212+
violations,
213+
`API routes using Supabase must have catch blocks with Sentry error capture:\n${violations.join("\n")}`,
214+
).toEqual([]);
215+
});
216+
217+
it("allowlist entries reference existing route files", () => {
218+
const staleEntries: string[] = [];
219+
220+
for (const rel of Object.keys(ALLOWLIST)) {
221+
const absPath = join(PROJECT_ROOT, rel);
222+
try {
223+
statSync(absPath);
224+
} catch {
225+
staleEntries.push(`${rel}: ${ALLOWLIST[rel]}`);
226+
}
227+
}
228+
229+
expect(
230+
staleEntries,
231+
`Allowlist contains entries for files that no longer exist — remove them:\n${staleEntries.join("\n")}`,
232+
).toEqual([]);
233+
});
234+
});

0 commit comments

Comments
 (0)