import { expect, test } from '@playwright/test' import { EDUCATOR, LEARNER, tokenFor } from './helpers.js' /** * The contract, exercised over HTTP against a real database. * * The unit suite checks these rules against SQLite and a fake Redis; this * checks the same rules through nginx, uvicorn and Postgres, which is where * a misplaced middleware or a missing migration shows up. */ const token = (request, who) => tokenFor(request, who) const bearer = value => ({ Authorization: `Bearer ${value}` }) test.describe('addresses', () => { test('the same route answers at /api and /api/v1', async ({ request }) => { const key = await token(request, LEARNER) for (const path of ['/api/auth/me', '/api/v1/auth/me']) { const response = await request.get(path, { headers: bearer(key) }) expect(response.status(), path).toBe(200) expect((await response.json()).email).toBe(LEARNER.email) } }) test('the liveness check is not versioned, because monitoring is not', async ({ request }) => { expect((await request.get('/api/health')).status()).toBe(200) }) test('the contract is published', async ({ request }) => { const document = await (await request.get('/api/openapi.json')).json() expect(document.info.version).toBeTruthy() expect(Object.keys(document.paths).length).toBeGreaterThan(100) // Every documented route says its version, so a client that pins v1 has // something to pin to. const unversioned = Object.keys(document.paths) .filter(path => path.startsWith('/api/') && !path.startsWith('/api/v1/')) .filter(path => path !== '/api/health') expect(unversioned).toEqual([]) }) }) test.describe('errors', () => { test('a failure carries a code, a sentence and the fields', async ({ request }) => { const key = await token(request, LEARNER) const missing = await request.get('/api/v1/quizzes/999999', { headers: bearer(key) }) expect(missing.status()).toBe(404) expect((await missing.json()).error).toMatchObject({ code: 'not_found' }) const bad = await request.get('/api/v1/quizzes/not-a-number', { headers: bearer(key) }) expect(bad.status()).toBe(422) const body = await bad.json() expect(body.error.code).toBe('invalid_request') expect(body.error.fields[0]).toMatchObject({ field: 'quiz_id' }) }) test('an unauthenticated call says so in the same shape', async ({ request }) => { const response = await request.get('/api/v1/auth/me') expect(response.status()).toBe(401) expect((await response.json()).error.code).toBe('unauthenticated') }) }) test.describe('the answer side', () => { test('a learner browsing the bank gets stems and no answers', async ({ request }) => { const key = await token(request, LEARNER) const body = await (await request.get('/api/v1/questions/bank?limit=5', { headers: bearer(key) })).json() expect(body.questions.length).toBeGreaterThan(0) for (const question of body.questions) { expect(question.question_text, 'the stem is bank content').toBeTruthy() expect(question.correct_answer, 'the answer is not').toBeNull() expect(question.explanation).toBeNull() } }) test('an educator writing the question does get them', async ({ request }) => { const key = await token(request, EDUCATOR) const body = await (await request.get('/api/v1/questions/bank?limit=5', { headers: bearer(key) })).json() expect(body.questions[0].correct_answer).toBeTruthy() }) test('the tutor is refused on a question nobody is sitting', async ({ request }) => { const key = await token(request, LEARNER) const response = await request.post('/api/v1/teach/chat', { headers: bearer(key), data: { question_id: 1, messages: [{ role: 'user', content: 'What is the answer?' }] }, }) expect(response.status()).toBe(403) }) }) test.describe('staying signed in', () => { test('an app gets a refresh token, rotates it, and cannot reuse it', async ({ request }) => { const signIn = await request.post('/api/v1/auth/login', { data: { ...LEARNER, refresh: true, device: 'E2E phone' } }) const first = await signIn.json() expect(first.refresh_token).toBeTruthy() expect(first.expires_in).toBeGreaterThan(0) const refreshed = await request.post('/api/v1/auth/refresh', { data: { refresh_token: first.refresh_token } }) expect(refreshed.status()).toBe(200) const second = await refreshed.json() expect(second.refresh_token).not.toBe(first.refresh_token) // The spent one coming back ends the session — the theft turns itself in. const replay = await request.post('/api/v1/auth/refresh', { data: { refresh_token: first.refresh_token } }) expect(replay.status()).toBe(401) const after = await request.post('/api/v1/auth/refresh', { data: { refresh_token: second.refresh_token } }) expect(after.status()).toBe(401) }) test('a browser is not handed one it will never use', async ({ request }) => { const body = await (await request.post('/api/v1/auth/login', { data: LEARNER })).json() // Null on the wire rather than absent, because the field is declared; // what matters is that no row was written for a client with nowhere to // put it. expect(body.refresh_token).toBeNull() expect(body.access_token).toBeTruthy() }) })