Merge pull request '[SPA] API-client baseline for health + session read (#158)' (#163) from dev/issue-158-spa-api-client-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 1m46s
All checks were successful
CI / test-and-quality (push) Successful in 1m46s
Merged by integrator-runner
This commit was merged in pull request #163.
This commit is contained in:
1
frontend/.gitignore
vendored
Normal file
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
12
frontend/README.md
Normal file
12
frontend/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Frontend API client baseline
|
||||
|
||||
Dette er baseline-klientlaget for SPA-sporet.
|
||||
|
||||
## Kører checks lokalt
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
1454
frontend/package-lock.json
generated
Normal file
1454
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
frontend/package.json
Normal file
15
frontend/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "wpp-frontend-api-client-baseline",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"build": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
56
frontend/src/api/client.ts
Normal file
56
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ApiResult, HealthResponse, SessionDetailResponse } from './types';
|
||||
|
||||
export interface ApiClient {
|
||||
health(): Promise<ApiResult<HealthResponse>>;
|
||||
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
|
||||
}
|
||||
|
||||
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
|
||||
async function request<T>(path: string): Promise<ApiResult<T>> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${baseUrl}${path}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: { kind: 'network', status: 0, message: 'Network error while contacting API' }
|
||||
};
|
||||
}
|
||||
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: { kind: 'parse', status: response.status, message: 'Invalid JSON response from API' }
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: {
|
||||
kind: 'http',
|
||||
status: response.status,
|
||||
message: `HTTP ${response.status}`,
|
||||
payload
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, status: response.status, data: payload as T };
|
||||
}
|
||||
|
||||
return {
|
||||
health: () => request<HealthResponse>('/healthz'),
|
||||
getSession: (code: string) =>
|
||||
request<SessionDetailResponse>(`/lobby/sessions/${encodeURIComponent(code.trim().toUpperCase())}`)
|
||||
};
|
||||
}
|
||||
57
frontend/src/api/types.ts
Normal file
57
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export interface HealthResponse {
|
||||
ok: boolean;
|
||||
service: string;
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
code: string;
|
||||
status: string;
|
||||
host_id: number | null;
|
||||
current_round: number;
|
||||
players_count: number;
|
||||
}
|
||||
|
||||
export interface SessionPlayer {
|
||||
id: number;
|
||||
nickname: string;
|
||||
score: number;
|
||||
is_connected: boolean;
|
||||
}
|
||||
|
||||
export interface SessionAnswer {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SessionRoundQuestion {
|
||||
id: number;
|
||||
round_number: number;
|
||||
prompt: string;
|
||||
shown_at: string;
|
||||
answers: SessionAnswer[];
|
||||
}
|
||||
|
||||
export interface PhaseViewModel {
|
||||
phase: string;
|
||||
available_actions: string[];
|
||||
constraints: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SessionDetailResponse {
|
||||
session: SessionSummary;
|
||||
players: SessionPlayer[];
|
||||
round_question: SessionRoundQuestion | null;
|
||||
phase_view_model: PhaseViewModel;
|
||||
}
|
||||
|
||||
export type ApiErrorKind = 'network' | 'http' | 'parse';
|
||||
|
||||
export interface ApiFailure {
|
||||
kind: ApiErrorKind;
|
||||
message: string;
|
||||
status: number;
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
export type ApiResult<T> =
|
||||
| { ok: true; status: number; data: T }
|
||||
| { ok: false; status: number; error: ApiFailure };
|
||||
87
frontend/tests/api-client.integration.test.ts
Normal file
87
frontend/tests/api-client.integration.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { createApiClient } from '../src/api/client';
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
if (req.url === '/healthz') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, service: 'weirsoe-party-protocol' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/lobby/sessions/ABCD12') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
|
||||
players: [],
|
||||
round_question: null,
|
||||
phase_view_model: { phase: 'lobby', available_actions: ['start_round'], constraints: {} }
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/lobby/sessions/')) {
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Session not found' }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(500, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'unexpected route' }));
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err?: Error) => (err ? reject(err) : resolve()))
|
||||
);
|
||||
});
|
||||
|
||||
describe('createApiClient', () => {
|
||||
it('reads health + session detail through typed wrappers', async () => {
|
||||
const client = createApiClient(baseUrl);
|
||||
|
||||
const health = await client.health();
|
||||
expect(health.ok).toBe(true);
|
||||
|
||||
const session = await client.getSession('abcd12');
|
||||
expect(session.ok).toBe(true);
|
||||
if (session.ok) {
|
||||
expect(session.data.session.code).toBe('ABCD12');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns consistent HTTP error shape for 4xx/5xx', async () => {
|
||||
const client = createApiClient(baseUrl);
|
||||
|
||||
const missing = await client.getSession('missing');
|
||||
expect(missing.ok).toBe(false);
|
||||
if (!missing.ok) {
|
||||
expect(missing.status).toBe(404);
|
||||
expect(missing.error.kind).toBe('http');
|
||||
expect(missing.error.payload).toEqual({ error: 'Session not found' });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns consistent network error shape', async () => {
|
||||
const client = createApiClient('http://127.0.0.1:9');
|
||||
|
||||
const health = await client.health();
|
||||
expect(health.ok).toBe(false);
|
||||
if (!health.ok) {
|
||||
expect(health.error.kind).toBe('network');
|
||||
expect(health.status).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
12
frontend/tsconfig.json
Normal file
12
frontend/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/api", "tests"]
|
||||
}
|
||||
8
frontend/vitest.config.ts
Normal file
8
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user