Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hono-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ app.use('*', logger());
app.use(
'*',
cors({
origin: ['http://localhost:5173', 'http://localhost:3000'],
origin: ['http://localhost:3030', 'http://localhost:5173', 'http://localhost:3000'],
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
})
Expand Down
6 changes: 3 additions & 3 deletions specs/core/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ components:
nullable: true
specialRequirements:
type: string
maxLength: 1000
maxLength: 5000
nullable: true
salaryMin:
Comment thread
WhatIfWeDigDeeper marked this conversation as resolved.
type: integer
Expand Down Expand Up @@ -845,7 +845,7 @@ components:
type: boolean
specialRequirements:
type: string
maxLength: 1000
maxLength: 5000
salaryMin:
type: integer
minimum: 0
Expand Down Expand Up @@ -899,7 +899,7 @@ components:
nullable: true
specialRequirements:
type: string
maxLength: 1000
maxLength: 5000
nullable: true
salaryMin:
type: integer
Expand Down
2 changes: 1 addition & 1 deletion specs/core/domain/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A record representing a single job application submitted by the user.
| skillsMatch | integer | no | - | 1-5 scale |
| jobSource | JobSource | no | - | See [enums.md](enums.md) |
| coverLetterRequired | boolean | no | - | true/false |
| specialRequirements | string | no | - | Max 1000 characters |
| specialRequirements | string | no | - | Max 5000 characters |
| salaryMin | integer | no | - | Positive number |
| salaryMax | integer | no | - | Positive number, >= salaryMin |
| notes | string | no | - | Max 5000 characters |
Expand Down
2 changes: 1 addition & 1 deletion specs/core/domain/validation-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ This document defines validation rules for domain entities, independent of any v
|-------|-----|-----|---------------|
| companyName | 1 | 200 | "Company name must be between 1 and 200 characters" |
| positionTitle | 1 | 200 | "Position title must be between 1 and 200 characters" |
| specialRequirements | - | 1000 | "Special requirements must not exceed 1000 characters" |
| specialRequirements | - | 5000 | "Special requirements must not exceed 5000 characters" |
| notes | - | 5000 | "Notes must not exceed 5000 characters" |

### URL Validation
Expand Down
6 changes: 5 additions & 1 deletion tests/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ Shared API and E2E tests run against multiple stacks.
- Shared API Jest tests mutate one schema per stack. Keep `--runInBand` in commands that run `tests/api`, including `scripts/run-api-tests.sh` and direct `test:api:<stack>` scripts.
- `run-api-tests.sh all` continues on failure and reports every failed stack.
- Go API and Spring API require all non-optional fields in PATCH requests. Include `companyName` + `positionTitle` for application PATCH, and `name` + `order` for interview stage PATCH.
- Update stack flags in `tests/api/helpers.ts` when adding a stack: `validatesDates`, `hasInterviewStageDates`, and similar contract capability flags.
- Update stack flags in `tests/api/helpers.ts` when adding a stack: `validatesDates`, `hasInterviewStageDates`, `expectedAllowedOrigin`, and similar contract capability flags.

### Cross-stack contract gaps (deliberately not enforced)

- **Stage UUID stability across history restore is not part of the cross-stack contract.** Only `rails-api` preserves stage IDs across snapshot restore (its `ApplicationRestoreService` does `destroy_all` + recreate-with-original-UUID). Other stacks may legitimately mint new stage IDs on restore, treating restored stages as new entities. Clients that need stable stage IDs across restore should query the history endpoint, not depend on stage IDs surviving. Do not add a shared `tests/api/` assertion for stage-ID preservation.

## E2E Tests

Expand Down
14 changes: 14 additions & 0 deletions tests/api/application-crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,18 @@ describe.each(getTargetStacks(ALL_STACKS))('Application CRUD ($name)', ({ baseUr
const data: ListResponse = await res.json();
expect(data.items.length).toBeLessThanOrEqual(2);
});

// Locks the cross-stack contract for `specialRequirements` at maxLength: 5000.
// Implementations had drifted to 5000 while openapi.yaml still said 1000;
// the spec was raised to 5000 (it's freeform notes, not user-supplied query
// input). 4000 chars must be accepted by every stack.
it('POST /applications with 4000-char specialRequirements → 201', async () => {
const app = await createApp({
companyName: 'API: Long Special Requirements Corp',
positionTitle: 'Engineer',
specialRequirements: 'x'.repeat(4000),
});
createdIds.push(app.id);
expect(app.id).toBeTruthy();
});
});
34 changes: 34 additions & 0 deletions tests/api/cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// CORS preflight regression test
//
// Every stack ships its own CORS middleware so the dev UI on a different
// localhost port can call the API. Without a shared test, accidental removal
// or misconfiguration only surfaces when a developer notices their browser
// failing in the browser console.
//
// Asserts: a preflight (OPTIONS with `Origin` and `Access-Control-Request-Method`)
// returns 2xx and an `Access-Control-Allow-Origin` header that admits the
// configured dev-UI origin — either by echoing it or by allowing all (`*`).
//
// Runs against all stacks when API_URL is unset, or a single stack when API_URL is set.

import { ALL_STACKS, getTargetStacks } from './helpers';

describe.each(getTargetStacks(ALL_STACKS))('CORS preflight ($name)', ({ baseUrl, expectedAllowedOrigin }) => {
it('responds to OPTIONS preflight with an Access-Control-Allow-Origin header that admits the dev UI', async () => {
const res = await fetch(`${baseUrl}/applications`, {
method: 'OPTIONS',
headers: {
Origin: expectedAllowedOrigin,
'Access-Control-Request-Method': 'GET',
'Access-Control-Request-Headers': 'content-type',
},
});

expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);

const allowOrigin = res.headers.get('access-control-allow-origin');
expect(allowOrigin).not.toBeNull();
expect([expectedAllowedOrigin, '*']).toContain(allowOrigin);
});
});
32 changes: 20 additions & 12 deletions tests/api/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
// `expectedAllowedOrigin` is the dev-UI origin paired with each API stack —
// used by `cors.test.ts` to verify the preflight regression-tests its dev-UI
// allowlist. For stacks with permissive CORS (e.g., Express's bare `cors()`),
// any origin is accepted and the response is `Access-Control-Allow-Origin: *`;
// for explicit allowlists, the origin must be in the allowlist. Use the actual
// paired UI's dev port — using a different known-allowed origin (e.g. :5173)
// would let the test pass even when the real dev UI is blocked, defeating the
// regression goal.
export const ALL_STACKS = [
{ name: 'express-api', baseUrl: 'http://localhost:3001', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'koa-api', baseUrl: 'http://localhost:5010', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'nuxt-api', baseUrl: 'http://localhost:5040/api', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: false },
{ name: 'hono-api', baseUrl: 'http://localhost:5030', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'fastapi', baseUrl: 'http://localhost:5160', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'nest-api', baseUrl: 'http://localhost:5050', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'go-api', baseUrl: 'http://localhost:5070', validatesDates: false, hasInterviewStageDates: false, hasStageHistory: false },
{ name: 'spring-api', baseUrl: 'http://localhost:8080/api', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'yoga-api', baseUrl: 'http://localhost:5080/api', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: false },
{ name: 'lambda-api', baseUrl: 'http://localhost:5090', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'rails-api', baseUrl: 'http://localhost:5180', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'express-api', baseUrl: 'http://localhost:3001', expectedAllowedOrigin: 'http://localhost:3000', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'koa-api', baseUrl: 'http://localhost:5010', expectedAllowedOrigin: 'http://localhost:3010', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'nuxt-api', baseUrl: 'http://localhost:5040/api', expectedAllowedOrigin: 'http://localhost:3020', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: false },
{ name: 'hono-api', baseUrl: 'http://localhost:5030', expectedAllowedOrigin: 'http://localhost:3030', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'fastapi', baseUrl: 'http://localhost:5160', expectedAllowedOrigin: 'http://localhost:3040', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true },
Comment thread
WhatIfWeDigDeeper marked this conversation as resolved.
{ name: 'nest-api', baseUrl: 'http://localhost:5050', expectedAllowedOrigin: 'http://localhost:3050', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'go-api', baseUrl: 'http://localhost:5070', expectedAllowedOrigin: 'http://localhost:3060', validatesDates: false, hasInterviewStageDates: false, hasStageHistory: false },
{ name: 'spring-api', baseUrl: 'http://localhost:8080/api', expectedAllowedOrigin: 'http://localhost:3070', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'yoga-api', baseUrl: 'http://localhost:5080/api', expectedAllowedOrigin: 'http://localhost:3080', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: false },
{ name: 'lambda-api', baseUrl: 'http://localhost:5090', expectedAllowedOrigin: 'http://localhost:3090', validatesDates: false, hasInterviewStageDates: true, hasStageHistory: true },
{ name: 'rails-api', baseUrl: 'http://localhost:5180', expectedAllowedOrigin: 'http://localhost:3000', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: true },
];

export const CSV_STACKS = ALL_STACKS.filter(s =>
Expand All @@ -33,5 +41,5 @@ export function getTargetStacks(stacks: typeof ALL_STACKS): typeof ALL_STACKS {
const stackName = process.env.STACK_NAME;
const match = stackName ? ALL_STACKS.find(s => s.name === stackName) : undefined;
if (stackName && !stacks.some(s => s.name === stackName)) return [];
return [{ ...(match ?? { name: stackName ?? 'target', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: false }), baseUrl: url }];
return [{ ...(match ?? { name: stackName ?? 'target', expectedAllowedOrigin: 'http://localhost:3000', validatesDates: true, hasInterviewStageDates: true, hasStageHistory: false }), baseUrl: url }];
}
Loading