Skip to content

Commit 63f73bd

Browse files
CoderCococlaude
andauthored
test: add full-stack integration test suite (real Nest + mocked AWS SDK) (#124)
Closes #75 ## Summary - Adds a **tier-2 integration test suite** (`npm run app:test:integration`) where a real Nest server runs on `:3002`, AWS SDK calls are intercepted by `aws-sdk-client-mock`, and the Vite preview on `:4174` proxies `/api` to it — no real AWS required - New `ServerMocks` Playwright fixture class + extended `test` object that resets `MockStore` before/after each spec; mock responses pushed to the server via `POST /api/test/mocks/*` - 6 spec files covering API token guard (401/200 auth), ConfigService tfstate parsing, start/stop browser flows, status-badge polling transitions, and ECS error propagation; `canRun()` spec stubbed pending Discord integration - Integration Vite config injects `VITE_STATUS_POLL_MS=3000` so poll-based assertions complete in < 10 s; Nest server startup timeout raised to 120 s for WSL2/DrvFs ESM loading latency - `CLAUDE.md` two-tier strategy table updated to three-tier; integration conventions section added; reference docs table updated - `docs/docs/components/integration-tests.md` added: architecture diagram, key-file table, mock response reference, spec inventory, design constraints ## Test plan - [ ] `npm run app:test:integration` — 11 pass, 1 skip (`canRun.spec.ts` placeholder) - [ ] `npm run app:test` (unit) — all pass, unaffected - [ ] `npm run app:test:e2e` (tier-1) — all pass, unaffected 🤖 Generated with [Claude Code](https://claude.ai/claude-code) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 00892f1 commit 63f73bd

24 files changed

Lines changed: 2078 additions & 37 deletions

.github/workflows/integration.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Integration Tests
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches: [main]
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
integration:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v5
16+
17+
- uses: actions/setup-node@v5
18+
with:
19+
node-version: '24'
20+
cache: npm
21+
cache-dependency-path: package-lock.json
22+
23+
- run: npm ci
24+
25+
- name: Install Playwright browsers
26+
run: npx playwright install chromium --with-deps
27+
working-directory: app/packages/web
28+
29+
- run: npm run app:test:integration
30+
31+
- uses: actions/upload-artifact@v4
32+
if: failure()
33+
with:
34+
name: integration-playwright-report
35+
path: app/packages/web/playwright-report/
36+
retention-days: 30

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@ app/packages/server/src/generated/tfstate.ts
3838
app/packages/web/test-results/
3939
app/packages/web/playwright-report/
4040
app/packages/web/playwright/.cache/
41+
app/packages/web/dist-integration/
4142

4243
.make

CLAUDE.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ When working in a specific area, read the relevant doc rather than relying on wh
140140
| AWS IAM deploy policy | `docs/docs/setup.md` |
141141
| Terraform variables reference | `docs/docs/components/terraform.md` |
142142
| Full setup walkthrough | `docs/docs/setup.md` |
143+
| Integration test architecture | `docs/docs/components/integration-tests.md` |
143144
| Copilot review tuning | `.github/copilot-instructions.md` |
144145
| PR creation command | `.claude/commands/pr.md` |
145146

@@ -161,14 +162,13 @@ Any time you add or remove Terraform variables, update **all four** of these in
161162

162163
### Two-tier browser testing strategy
163164

164-
The test suite has two complementary tiers:
165+
The test suite has three complementary tiers:
165166

166167
| Tier | Command | What runs | When to add specs |
167168
|------|---------|-----------|-------------------|
168169
| **Unit / integration** | `npm run app:test` | Vitest. Server-side logic, hooks, helpers run under the `node` environment; React component specs in `@gsd/web` run under `jsdom` via `environmentMatchGlobs`. No real network — AWS SDK mocked via `aws-sdk-client-mock`; the `@gsd/web` API client is stubbed via `vi.mock`. | Pure logic, hook behaviour, server controllers, **per-component React behaviour** (rendering, callbacks, internal state transitions). |
169170
| **E2E (tier 1 — #74)** | `npm run app:test:e2e` | Playwright against `vite build` + `vite preview`. Nest server never runs; every `/api/*` call is stubbed at the network layer via `page.route()`. | User-visible flows: routing, auth gate, button interactions, status-badge rendering, optimistic updates. |
170-
171-
A planned **tier 2** (#75) will add full-stack specs (real Nest + mocked AWS SDK) for scenarios that require real HTTP contract validation between the client and server. Until that lands, all browser-facing specs belong in tier 1.
171+
| **Integration (tier 2 — #75)** | `npm run app:test:integration` | Playwright against `vite build (integration config)` + `vite preview` + real Nest server on `:3002`. AWS SDK intercepted by `aws-sdk-client-mock`. Mock state pushed via `ServerMocks` fixture to `/api/test/mocks/*`. | Real HTTP contract validation, `ApiTokenGuard` behaviour, `ConfigService` tfstate parsing, start/stop flows end-to-end, error propagation from ECS through the API response. |
172172

173173
**React component unit tests (`@gsd/web`):**
174174
- Use **Vitest + jsdom + `@testing-library/react` + `@testing-library/user-event`**. `@testing-library/jest-dom` matchers (`toBeInTheDocument`, `toHaveTextContent`, etc.) are auto-loaded via `app/vitest.setup.ts`.
@@ -180,7 +180,7 @@ A planned **tier 2** (#75) will add full-stack specs (real Nest + mocked AWS SDK
180180
- Each routed page (`DashboardPage`, `CostsPage`, `DiscordPage`, `LogsPage`, `SettingsPage`) gets a co-located `*.test.tsx` that mounts the page through `renderPage()` (`app/packages/web/src/test-utils/renderPage.tsx`). The helper wraps children in `PollingProvider → GameStatusProvider → MemoryRouter` so the same provider stack the production app uses is exercised; pass `initialEntries` when the page reads `useLocation`.
181181
- Mock `../api.js` with `vi.mock` + `vi.hoisted` so the page drives off canned data instead of real fetches. Stub every method the page (and the GameStatusProvider above it) calls — at minimum `api.status` and `api.costsEstimate` — or the test will hang waiting for the polling registry to settle.
182182
- These tests are intentionally **complementary** to the e2e tier, not a replacement: the e2e specs prove the indicator + chrome render at the route level under a real Vite preview build; the unit tests pin the page's own provider wiring (e.g. that `<PollingIndicator />` resolves to "Updated …" once the mocked status poll resolves) and let us iterate on the page layout without spinning up Playwright.
183-
- Keep page-test scope tight: smoke-render each header section, exercise interactive controls that aren't already covered by a child component's unit test, and verify the polling indicator wiring. Anything that requires real HTTP belongs in the planned tier-2 specs (#75).
183+
- Keep page-test scope tight: smoke-render each header section, exercise interactive controls that aren't already covered by a child component's unit test, and verify the polling indicator wiring. Anything that requires real HTTP belongs in tier-2 integration specs (`e2e/integration-specs/`).
184184

185185
**Playwright conventions:**
186186
- Specs live under `app/packages/web/e2e/specs/`.
@@ -191,6 +191,13 @@ A planned **tier 2** (#75) will add full-stack specs (real Nest + mocked AWS SDK
191191
- Use the `authedPage` fixture (token pre-seeded in localStorage) only when a spec needs raw `Page` access (e.g. to call `stubApis` or `addInitScript`); otherwise prefer the higher-level page-object fixtures.
192192
- Stubs must cover every `/api/*` endpoint the page hits, or the catch-all returns 404 and the test will surface the missing stub quickly.
193193

194+
**Integration test conventions (tier 2):**
195+
- Specs live under `app/packages/web/e2e/integration-specs/`. Import `{ test, expect }` from `./index.js` (NOT from `@playwright/test`) — the extended `test` includes the `serverMocks`, `authedPage`, and `dashboard` fixtures.
196+
- `serverMocks` (`ServerMocks` from `e2e/fixtures/server-mocks.ts`) pushes queued responses to the test server via `POST /api/test/mocks/*`. Always include it in test parameters — it resets the MockStore before and after each spec automatically.
197+
- The test Nest server (`test-main.ts`) runs on `:3002`; the Vite integration preview (port 4174) proxies `/api` to it. Pure HTTP tests call `http://localhost:3002/api/...` directly (bypasses the proxy). Browser tests navigate to the `baseURL` (port 4174) and the proxy routes API calls.
198+
- `workers: 1` and `fullyParallel: false` in `playwright.integration.config.ts` are intentional — the shared in-process `MockStore` cannot be used concurrently.
199+
- The integration Vite build sets `VITE_STATUS_POLL_MS=3000` so pollers fire every 3 s instead of 20 s, keeping status-polling specs fast without busy-looping.
200+
194201
## Git & Branch Workflow
195202

196203
`main` is a protected branch — direct pushes are blocked. All changes go through a PR, including trivial chores (`.gitignore` entries, config tweaks). Never commit directly to `main`.

app/eslint.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export default tseslint.config(
2828
tsdoc,
2929
},
3030
rules: {
31+
'@typescript-eslint/no-unused-vars': ['error', {
32+
argsIgnorePattern: '^_',
33+
varsIgnorePattern: '^_',
34+
caughtErrorsIgnorePattern: '^_',
35+
}],
3136
'tsdoc/syntax': 'error',
3237
'jsdoc/require-jsdoc': ['error', {
3338
publicOnly: true,

app/packages/server/src/services/ConfigService.ts

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,17 @@ import { EMBEDDED_TFSTATE } from '../generated/tfstate.js';
77

88
const __dirname = dirname(fileURLToPath(import.meta.url));
99

10-
/**
11-
* Probes candidate relative paths from __dirname in order and returns the
12-
* first that exists on disk. Falls back to the first candidate when none
13-
* exist so callers get a deterministic (missing-file) path rather than
14-
* undefined behaviour.
15-
*
16-
* Needed because the depth from __dirname to the repo/workspace root differs
17-
* between environments:
18-
* - local source tree (src/services or dist/services inside repo): 5 levels up → repo root
19-
* - Docker image (dist/services inside /app): 4 levels up → /app
20-
*/
21-
function resolveRuntimePath(...relativeCandidates: string[]): string {
22-
for (const rel of relativeCandidates) {
23-
const resolved = join(__dirname, rel);
24-
if (existsSync(resolved)) return resolved;
25-
}
26-
return join(__dirname, relativeCandidates[0]);
27-
}
10+
// dist/services/ → 4 hops up = app root (repo: app/, Docker: /workspace/app/)
11+
// 5 hops up = repo/workspace root, where terraform/ lives
12+
const APP_ROOT = join(__dirname, '..', '..', '..', '..');
13+
14+
const TF_STATE_PATH =
15+
process.env['TF_STATE_PATH'] ??
16+
join(APP_ROOT, '..', 'terraform', 'terraform.tfstate');
2817

29-
const TF_STATE_PATH = resolveRuntimePath(
30-
'../../../../../terraform/terraform.tfstate',
31-
'../../../../terraform/terraform.tfstate',
32-
);
33-
const CONFIG_PATH = resolveRuntimePath(
34-
'../../../../../app/server_config.json',
35-
'../../../../server_config.json',
36-
);
18+
const SERVER_CONFIG_PATH =
19+
process.env['SERVER_CONFIG_PATH'] ??
20+
join(APP_ROOT, 'server_config.json');
3721

3822
/**
3923
* Shape of the subset of Terraform root outputs the management app consumes.
@@ -82,7 +66,8 @@ const DEFAULT_CONFIG: WatchdogConfig = {
8266
* lazily and cached in-memory until {@link ConfigService.invalidateCache}
8367
* is called.
8468
* - `server_config.json` — the user-editable file holding the watchdog
85-
* tunables and the optional API bearer token.
69+
* tunables and the optional API bearer token. Path resolved via
70+
* `SERVER_CONFIG_PATH` env var, defaulting to `<app-root>/server_config.json`.
8671
* - A handful of process env vars (`AWS_DEFAULT_REGION`, `API_TOKEN`).
8772
*
8873
* Every other service injects this one instead of touching `process.env` or
@@ -196,9 +181,9 @@ export class ConfigService {
196181
if (env !== undefined) {
197182
return env.length > 0 ? env : null;
198183
}
199-
if (!existsSync(CONFIG_PATH)) return null;
184+
if (!existsSync(SERVER_CONFIG_PATH)) return null;
200185
try {
201-
const raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) as { api_token?: unknown };
186+
const raw = JSON.parse(readFileSync(SERVER_CONFIG_PATH, 'utf-8')) as { api_token?: unknown };
202187
return typeof raw.api_token === 'string' && raw.api_token.length > 0 ? raw.api_token : null;
203188
} catch (err) {
204189
logger.warn('Could not read api_token from config file', { err });
@@ -225,9 +210,9 @@ export class ConfigService {
225210
* fresh object on every call — safe for callers to mutate.
226211
*/
227212
getConfig(): WatchdogConfig {
228-
if (!existsSync(CONFIG_PATH)) return { ...DEFAULT_CONFIG };
213+
if (!existsSync(SERVER_CONFIG_PATH)) return { ...DEFAULT_CONFIG };
229214
try {
230-
const saved = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) as Partial<WatchdogConfig>;
215+
const saved = JSON.parse(readFileSync(SERVER_CONFIG_PATH, 'utf-8')) as Partial<WatchdogConfig>;
231216
return { ...DEFAULT_CONFIG, ...saved };
232217
} catch (err) {
233218
logger.warn('Could not read config file, using defaults', { err });
@@ -241,7 +226,7 @@ export class ConfigService {
241226
* save here is not effective until the next `terraform apply`.
242227
*/
243228
saveConfig(config: WatchdogConfig): void {
244-
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
229+
writeFileSync(SERVER_CONFIG_PATH, JSON.stringify(config, null, 2));
245230
logger.info('Config saved', config);
246231
}
247232
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* Integration-test entry point for the Nest server.
3+
*
4+
* Sets up aws-sdk-client-mock interceptors BEFORE creating the Nest
5+
* application. EcsService creates its ECSClient lazily (on first request),
6+
* so patching the prototype here is sufficient — all subsequent send() calls
7+
* on any ECSClient instance will hit the mock.
8+
*
9+
* Run via: PORT=3002 NODE_ENV=test API_TOKEN=test-token
10+
* TF_STATE_PATH=<path> node dist/test-main.js
11+
*/
12+
import 'reflect-metadata';
13+
import { Module } from '@nestjs/common';
14+
import { NestFactory } from '@nestjs/core';
15+
import type { NestExpressApplication } from '@nestjs/platform-express';
16+
import { mockClient } from 'aws-sdk-client-mock';
17+
import {
18+
ECSClient,
19+
ListTasksCommand,
20+
DescribeTasksCommand,
21+
RunTaskCommand,
22+
StopTaskCommand,
23+
} from '@aws-sdk/client-ecs';
24+
import { AppModule } from './app.module.js';
25+
import { TestMocksModule } from './test-mocks/test-mocks.module.js';
26+
import { mockStore } from './test-mocks/mock-store.js';
27+
import { logger } from './logger.js';
28+
29+
// ── Patch ECSClient prototype before DI container creates any instances ──
30+
31+
const ecsMock = mockClient(ECSClient);
32+
33+
ecsMock.on(ListTasksCommand).callsFake(async () => {
34+
const next = mockStore.dequeueListTasks();
35+
if (next?.type === 'error') {
36+
throw Object.assign(new Error(next.message ?? 'Mock ListTasks error'), {
37+
name: next.code ?? 'ServiceException',
38+
});
39+
}
40+
return (next?.data as object | undefined) ?? { taskArns: [] };
41+
});
42+
43+
ecsMock.on(DescribeTasksCommand).callsFake(async () => {
44+
const next = mockStore.dequeueDescribeTasks();
45+
if (next?.type === 'error') {
46+
throw Object.assign(new Error(next.message ?? 'Mock DescribeTasks error'), {
47+
name: next.code ?? 'ServiceException',
48+
});
49+
}
50+
return (next?.data as object | undefined) ?? { tasks: [] };
51+
});
52+
53+
ecsMock.on(RunTaskCommand).callsFake(async () => {
54+
const next = mockStore.dequeueRunTask();
55+
if (next?.type === 'error') {
56+
throw Object.assign(new Error(next.message ?? 'Mock RunTask error'), {
57+
name: next.code ?? 'ServiceException',
58+
});
59+
}
60+
return (next?.data as object | undefined) ?? {
61+
tasks: [{ taskArn: 'arn:aws:ecs:us-east-1:123456789012:task/test-cluster/test-task-id' }],
62+
failures: [],
63+
};
64+
});
65+
66+
ecsMock.on(StopTaskCommand).callsFake(async () => {
67+
const next = mockStore.dequeueStopTask();
68+
if (next?.type === 'error') {
69+
throw Object.assign(new Error(next.message ?? 'Mock StopTask error'), {
70+
name: next.code ?? 'ServiceException',
71+
});
72+
}
73+
return (next?.data as object | undefined) ?? {};
74+
});
75+
76+
// ── Boot the Nest application ──
77+
78+
/** Wraps AppModule (real providers + global guard) and adds TestMocksModule. */
79+
@Module({ imports: [AppModule, TestMocksModule] })
80+
class TestAppModule {}
81+
82+
const PORT = parseInt(process.env['PORT'] ?? '3002', 10);
83+
84+
async function bootstrap(): Promise<void> {
85+
const app = await NestFactory.create<NestExpressApplication>(TestAppModule, {
86+
logger: ['error', 'warn'],
87+
});
88+
app.setGlobalPrefix('api');
89+
await app.listen(PORT);
90+
logger.info(`Integration test server running on http://localhost:${PORT}`, { port: PORT });
91+
}
92+
93+
void bootstrap();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/** Shape of a single queued mock response. */
2+
export interface MockResponse {
3+
/** 'success' returns `data`; 'error' throws an error with `code` and `message`. */
4+
type: 'success' | 'error';
5+
data?: unknown;
6+
code?: string;
7+
message?: string;
8+
}
9+
10+
/**
11+
* Singleton in-process store of queued AWS SDK mock responses.
12+
* `test-main.ts` sets up `aws-sdk-client-mock` interceptors that read from
13+
* this store via `dequeue*()`. Playwright tests push responses via the
14+
* `TestMocksController` HTTP endpoints before exercising each flow.
15+
*
16+
* Default (empty queue) behaviour per command:
17+
* - ListTasks → \{ taskArns: [] \} (no running tasks)
18+
* - DescribeTasks → \{ tasks: [] \}
19+
* - RunTask → \{ tasks: [\{ taskArn: 'arn:aws:ecs:us-east-1:123:task/test-cluster/test-task-id' \}] \}
20+
* - StopTask → \{\}
21+
*/
22+
class MockStore {
23+
private listTasksQueue: MockResponse[] = [];
24+
private describeTasksQueue: MockResponse[] = [];
25+
private runTaskQueue: MockResponse[] = [];
26+
private stopTaskQueue: MockResponse[] = [];
27+
28+
pushListTasks(r: MockResponse): void { this.listTasksQueue.push(r); }
29+
pushDescribeTasks(r: MockResponse): void { this.describeTasksQueue.push(r); }
30+
pushRunTask(r: MockResponse): void { this.runTaskQueue.push(r); }
31+
pushStopTask(r: MockResponse): void { this.stopTaskQueue.push(r); }
32+
33+
dequeueListTasks(): MockResponse | null { return this.listTasksQueue.shift() ?? null; }
34+
dequeueDescribeTasks(): MockResponse | null { return this.describeTasksQueue.shift() ?? null; }
35+
dequeueRunTask(): MockResponse | null { return this.runTaskQueue.shift() ?? null; }
36+
dequeueStopTask(): MockResponse | null { return this.stopTaskQueue.shift() ?? null; }
37+
38+
/** Clear all queues — called between tests via `POST /api/test/mocks/reset`. */
39+
reset(): void {
40+
this.listTasksQueue = [];
41+
this.describeTasksQueue = [];
42+
this.runTaskQueue = [];
43+
this.stopTaskQueue = [];
44+
}
45+
}
46+
47+
export const mockStore = new MockStore();
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { Body, Controller, Post } from '@nestjs/common';
2+
import { mockStore, type MockResponse } from './mock-store.js';
3+
4+
/**
5+
* HTTP endpoints for Playwright integration tests to control mock AWS SDK
6+
* responses. Only registered in the test binary — never imported by AppModule.
7+
*
8+
* Protected by ApiTokenGuard (the global guard from AppModule applies to all
9+
* routes) — callers must send `Authorization: Bearer test-token`.
10+
*/
11+
@Controller('test/mocks')
12+
export class TestMocksController {
13+
/** Reset all queues between test scenarios. */
14+
@Post('reset')
15+
reset(): { ok: true } {
16+
mockStore.reset();
17+
return { ok: true };
18+
}
19+
20+
/** Push a response for the next `ListTasksCommand` call. */
21+
@Post('ecs/list-tasks')
22+
pushListTasks(@Body() body: MockResponse): { ok: true } {
23+
mockStore.pushListTasks(body);
24+
return { ok: true };
25+
}
26+
27+
/** Push a response for the next `DescribeTasksCommand` call. */
28+
@Post('ecs/describe-tasks')
29+
pushDescribeTasks(@Body() body: MockResponse): { ok: true } {
30+
mockStore.pushDescribeTasks(body);
31+
return { ok: true };
32+
}
33+
34+
/** Push a response for the next `RunTaskCommand` call. */
35+
@Post('ecs/run-task')
36+
pushRunTask(@Body() body: MockResponse): { ok: true } {
37+
mockStore.pushRunTask(body);
38+
return { ok: true };
39+
}
40+
41+
/** Push a response for the next `StopTaskCommand` call. */
42+
@Post('ecs/stop-task')
43+
pushStopTask(@Body() body: MockResponse): { ok: true } {
44+
mockStore.pushStopTask(body);
45+
return { ok: true };
46+
}
47+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { TestMocksController } from './test-mocks.controller.js';
3+
4+
/** Nest module exposing mock-control endpoints. Only imported by TestAppModule in test-main.ts. */
5+
@Module({
6+
controllers: [TestMocksController],
7+
})
8+
export class TestMocksModule {}

0 commit comments

Comments
 (0)