diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3671c049..6154a4de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -385,9 +385,22 @@ jobs: echo "::error::Backend did not become healthy and ready." exit 1 - - name: Run e2e tests + # Integration suite (issue #756) runs with the rest of the e2e specs and + # emits an lcov + text coverage summary for src/. The suite discovers the + # route graph from the live application, so new endpoints are covered + # automatically, then exercises the core flows against seeded fixtures. + - name: Run e2e tests with coverage working-directory: backend - run: npm run test:e2e + run: npm run test:e2e:cov + + - name: Upload backend e2e coverage + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: backend-e2e-coverage + path: backend/coverage-e2e + if-no-files-found: warn + retention-days: 7 # The OpenAPI document (docs/openapi.json) and the API reference # (docs/api.md) are generated from the application, never edited by hand. diff --git a/backend/README.md b/backend/README.md index 065151f7..d6759cd0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -199,6 +199,25 @@ the e2e suite. | `npm run test:cov` | `jest --coverage` | Unit tests with coverage report. | | `npm run test:e2e` | `jest --config ./test/jest-e2e.json` | Run the E2E test suite. | +### API integration suite + +`test/api-integration.e2e-spec.ts` (issue #756) is part of the e2e run. It +boots the real `AppModule` against the test database and: + +1. **Discovers every mounted route** from the application's own Swagger +document and calls each one, so a new endpoint is covered automatically and +an unmounted route fails the suite. +2. **Exercises the core flows against fixtures** — puzzle discovery, admin +creation and publication, correct/incorrect submission grading, rewards and +duplicate-claim prevention, content, and categories. + +Fixtures live in `test/fixtures/test-database.fixture.ts`. `resetDatabase()` +truncates every entity table and `seedDatabase()` inserts deterministic admin, +user, puzzle, reward, category, content, and progress records. The suite needs +the same services as the rest of the e2e run (`DATABASE_*` with +`DATABASE_SYNC=true`, plus Redis); CI provides them and uploads the coverage +report as the `backend-e2e-coverage` artifact. + ### Run Modes The backend exposes three runtime modes so you can pick the right @@ -273,8 +292,11 @@ npm run test:watch # With coverage npm run test:cov -# E2E tests +# E2E tests (includes the API integration suite) npm run test:e2e + +# E2E tests + coverage report (coverage-e2e/lcov.info) +npm run test:e2e:cov ``` ## Code Quality diff --git a/backend/package.json b/backend/package.json index b915c872..31b67748 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:e2e:cov": "jest --config ./test/jest-e2e.json --coverage --collectCoverageFrom='src/**/*.ts' --coverageDirectory=../coverage-e2e --coverageReporters=text-summary --coverageReporters=lcov", "openapi:emit": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts", "openapi:docs": "ts-node -r tsconfig-paths/register scripts/generate-api-reference.ts", "openapi:generate": "npm run openapi:emit && npm run openapi:docs", diff --git a/backend/src/progress/entities/progress.entity.ts b/backend/src/progress/entities/progress.entity.ts index 4a7ca32d..2b0cd3ca 100644 --- a/backend/src/progress/entities/progress.entity.ts +++ b/backend/src/progress/entities/progress.entity.ts @@ -11,7 +11,7 @@ export class Progress { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'uuid', unique: true, length: 128 }) + @Column({ type: 'uuid', unique: true }) userId: string; @Column({ default: 0 }) diff --git a/backend/test/api-integration.e2e-spec.ts b/backend/test/api-integration.e2e-spec.ts new file mode 100644 index 00000000..6ce5d907 --- /dev/null +++ b/backend/test/api-integration.e2e-spec.ts @@ -0,0 +1,270 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { SwaggerModule } from '@nestjs/swagger'; +import { DataSource } from 'typeorm'; +import request from 'supertest'; + +import { AppModule } from '../src/app.module'; +import { AdminAuthModule } from '../src/admin/admin-auth.module'; +import { AdminRole } from '../src/admin/admin-role.enum'; +import { DOCS_ROUTE_EXCLUSIONS, buildApiPrefix } from '../src/api-prefix'; +import { buildSwaggerConfig } from '../src/swagger'; +import { + resetDatabase, + seedDatabase, + SeededFixtures, +} from './fixtures/test-database.fixture'; + +/** + * Backend API integration suite — issue #756. + * + * 1. **Route coverage**: the full route graph is read from the application's + * own Swagger document, then every documented operation is called once. + * That guarantees the suite covers every public (and admin) endpoint that + * the application actually mounts, without a hand-maintained list that can + * drift. + * 2. **Fixture-backed flows**: with the test database seeded, the core public + * flows are exercised end to end (puzzle discovery, submission grading, + * publication, rewards/claims, content, categories). + * + * Requires a reachable PostgreSQL with `DATABASE_SYNC=true` and the `DATABASE_*` + * variables set (CI provides both; see .github/workflows/build.yml). + */ +describe('API integration suite (e2e)', () => { + let app: INestApplication; + let moduleRef: TestingModule; + let dataSource: DataSource; + let fixtures: SeededFixtures; + let adminToken: string; + let http: ReturnType; + let operations: LiveOperation[] = []; + + const PLACEHOLDER_ID = '00000000-0000-4000-8000-000000000000'; + const WRITE_METHODS = ['post', 'put', 'patch', 'delete']; + const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete']; + + /** Replaces `{id}`-style path parameters with a syntactically valid value. */ + const resolvePath = (path: string) => + path.replace(/\{[^}]+\}/g, PLACEHOLDER_ID); + + interface LiveOperation { + method: string; + path: string; + } + + const liveOperations = (): LiveOperation[] => { + const document = SwaggerModule.createDocument( + app, + buildSwaggerConfig(app.get(ConfigService)), + ); + const operations: LiveOperation[] = []; + for (const [path, handlers] of Object.entries(document.paths ?? {})) { + for (const method of Object.keys(handlers)) { + if (HTTP_METHODS.includes(method)) { + operations.push({ method, path }); + } + } + } + return operations; + }; + + const frameworkNotFound = (body: unknown): boolean => { + const message = Array.isArray((body as any)?.message) + ? (body as any).message.join(' ') + : String((body as any)?.message ?? ''); + return /^Cannot (GET|POST|PUT|PATCH|DELETE)/.test(message); + }; + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleRef.createNestApplication(); + app.setGlobalPrefix(buildApiPrefix('v1'), { + exclude: DOCS_ROUTE_EXCLUSIONS, + }); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + await app.init(); + + dataSource = app.get(DataSource); + http = request(app.getHttpServer()); + operations = liveOperations(); + + await resetDatabase(dataSource); + fixtures = await seedDatabase(dataSource); + + adminToken = app + .select(AdminAuthModule) + .get(JwtService) + .sign({ sub: fixtures.adminId, role: AdminRole.ADMIN }); + }); + + afterAll(async () => { + await app?.close(); + }); + + describe('route coverage (every mounted endpoint responds and is mounted)', () => { + it('discovers operations from the live application', () => { + expect(operations.length).toBeGreaterThan(50); + expect(operations).toEqual( + expect.arrayContaining([ + { method: 'get', path: '/puzzles/active' }, + { method: 'post', path: '/puzzle-submission' }, + { method: 'get', path: '/health/live' }, + ]), + ); + }); + + it('serves every mounted endpoint', async () => { + const unmounted: string[] = []; + + for (const { method, path } of operations) { + const target = `/api/v1${resolvePath(path)}`; + const pending = (http as any)[method](target); + const response = WRITE_METHODS.includes(method) + ? await pending.send({}) + : await pending; + + // A framework "Cannot " 404 is the one response that proves + // the operation is not mounted. Anything else (a validation error, an + // auth rejection, or a handler-level 404) means the route exists. + if (response.status === 404 && frameworkNotFound(response.body)) { + unmounted.push(`${method.toUpperCase()} ${path}`); + } + } + + expect(unmounted).toEqual([]); + }); + }); + + describe('fixture-backed public flows', () => { + beforeEach(async () => { + await resetDatabase(dataSource); + fixtures = await seedDatabase(dataSource); + }); + + it('serves the liveness probe', async () => { + await http.get('/api/v1/health/live').expect(200); + }); + + it('lists only active puzzles and hides solutions', async () => { + const response = await http.get('/api/v1/puzzles/active').expect(200); + + const ids = response.body.map((puzzle: any) => puzzle.id); + expect(ids).toContain(fixtures.activePuzzleId); + expect(ids).not.toContain(fixtures.inactivePuzzleId); + for (const puzzle of response.body) { + expect(puzzle.solution).toBeUndefined(); + expect(puzzle.hint).toBeUndefined(); + } + }); + + it('creates a puzzle as an admin, grades submissions, and publishes it', async () => { + const created = await http + .post('/api/v1/admin/puzzles') + .set('Authorization', `Bearer ${adminToken}`) + .send({ + title: 'Integration Published Puzzle', + description: 'Created by the integration suite.', + difficulty: 'easy', + hint: 'Integration hint', + solution: 'integration-answer', + isActive: false, + }) + .expect(201); + + const puzzleId = created.body.id; + expect(created.body.isActive).toBe(false); + + const hidden = await http + .get('/api/v1/puzzles/active?difficulty=easy') + .expect(200); + expect(hidden.body.some((p: any) => p.id === puzzleId)).toBe(false); + + const playerId = `integration-player-${Date.now()}`; + const correct = await http + .post('/api/v1/puzzle-submission') + .send({ playerId, puzzleId, answer: 'INTEGRATION-ANSWER' }) + .expect(201); + expect(correct.body).toMatchObject({ isCorrect: true, attempts: 1 }); + + const wrong = await http + .post('/api/v1/puzzle-submission') + .send({ playerId, puzzleId, answer: 'nope' }) + .expect(201); + expect(wrong.body).toMatchObject({ isCorrect: false, attempts: 2 }); + + await http + .patch(`/api/v1/admin/puzzles/${puzzleId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ isActive: true }) + .expect(200); + + const published = await http + .get('/api/v1/puzzles/active?difficulty=easy') + .expect(200); + const found = published.body.find((p: any) => p.id === puzzleId); + expect(found).toBeDefined(); + expect(found.solution).toBeUndefined(); + expect(found.hint).toBeUndefined(); + }); + + it('rejects an anonymous admin write', async () => { + await http + .post('/api/v1/admin/puzzles') + .send({ title: 'nope' }) + .expect(401); + }); + + it('creates a reward, lists it, and prevents duplicate claims', async () => { + const reward = await http + .post('/api/v1/reward') + .send({ + name: 'Integration Reward', + description: 'Created by the integration suite.', + type: 'points', + challengeId: 'integration-claim', + metadata: { amount: 5 }, + isActive: true, + maxClaims: 5, + }) + .expect(201); + + const listed = await http.get('/api/v1/reward').expect(200); + expect(listed.body.some((r: any) => r.id === reward.body.id)).toBe(true); + + await http + .post('/api/v1/reward/claim') + .send({ userId: fixtures.userId, challengeId: 'integration-claim' }) + .expect(201); + + await http + .post('/api/v1/reward/claim') + .send({ userId: fixtures.userId, challengeId: 'integration-claim' }) + .expect(409); + }); + + it('serves seeded content and categories', async () => { + const content = await http.get('/api/v1/content').expect(200); + expect(content.body.some((c: any) => c.id === fixtures.contentId)).toBe( + true, + ); + + const categories = await http + .get('/api/v1/puzzle-categories/categories') + .expect(200); + expect( + categories.body.some((c: any) => c.id === fixtures.categoryId), + ).toBe(true); + }); + }); +}); diff --git a/backend/test/fixtures/test-database.fixture.ts b/backend/test/fixtures/test-database.fixture.ts new file mode 100644 index 00000000..064e175b --- /dev/null +++ b/backend/test/fixtures/test-database.fixture.ts @@ -0,0 +1,226 @@ +import { DataSource } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; + +import { Admin } from '../../src/admin/admin.entity'; +import { AdminRole } from '../../src/admin/admin-role.enum'; +import { User } from '../../src/auth/entities/user.entity'; +import { Puzzle } from '../../src/puzzle/puzzle.entity'; +import { PuzzleCompletion } from '../../src/puzzle-dependency/entities/puzzle-completion.entity'; +import { Reward, RewardType } from '../../src/reward/entities/reward.entity'; +import { RewardClaim } from '../../src/reward/entities/reward-claim.entity'; +import { Category } from '../../src/puzzle-category/entities/category.entity'; +import { CategoryPuzzle } from '../../src/puzzle-category/entities/puzzle.entity'; +import { Content } from '../../src/content/content.entity'; +import { Progress } from '../../src/progress/entities/progress.entity'; + +/** + * Stable identifiers so a test can reference a fixture without carrying the + * value around, while still being valid UUIDs for the uuid columns. + */ +export const TEST_IDS = { + admin: '11111111-1111-4111-8111-111111111111', + user: '22222222-2222-4222-8222-222222222222', + activePuzzle: '33333333-3333-4333-8333-333333333333', + inactivePuzzle: '44444444-4444-4444-8444-444444444444', + reward: '55555555-5555-4555-8555-555555555555', + category: '66666666-6666-4666-8666-666666666666', + categoryPuzzle: '77777777-7777-4777-8777-777777777777', + content: '88888888-8888-4888-8888-888888888888', +} as const; + +export const TEST_CREDENTIALS = { + adminEmail: 'integration-admin@stellarshunts.test', + adminPassword: 'integration-admin-password', + username: 'integration_player', + userEmail: 'integration-player@stellarshunts.test', + userPassword: 'integration-player-password', + activePuzzleSolution: 'orbit', +} as const; + +export interface SeededFixtures { + adminId: string; + userId: string; + activePuzzleId: string; + inactivePuzzleId: string; + rewardId: string; + categoryId: string; + categoryPuzzleId: string; + contentId: string; +} + +/** + * Empties every table TypeORM knows about so each suite starts from a known + * state. `CASCADE` handles foreign keys and + * `RESTART IDENTITY` keeps serial columns deterministic. + */ +export async function resetDatabase(dataSource: DataSource): Promise { + const tables = dataSource.entityMetadatas.map( + (metadata) => `"${metadata.tableName}"`, + ); + if (tables.length === 0) return; + await dataSource.query( + `TRUNCATE TABLE ${tables.join(', ')} RESTART IDENTITY CASCADE`, + ); +} + +/** + * Inserts the records the integration suite needs: an admin, a player, active + * and inactive puzzles, a reward with one unconsumed challenge, a category, + * content, completion history, and progress. + */ +export async function seedDatabase( + dataSource: DataSource, +): Promise { + const admins = dataSource.getRepository(Admin); + const users = dataSource.getRepository(User); + const puzzles = dataSource.getRepository(Puzzle); + const completions = dataSource.getRepository(PuzzleCompletion); + const rewards = dataSource.getRepository(Reward); + const categories = dataSource.getRepository(Category); + const categoryPuzzles = dataSource.getRepository(CategoryPuzzle); + const contents = dataSource.getRepository(Content); + const progress = dataSource.getRepository(Progress); + + await admins.save( + admins.create({ + id: TEST_IDS.admin, + email: TEST_CREDENTIALS.adminEmail, + password: await bcrypt.hash(TEST_CREDENTIALS.adminPassword, 10), + role: AdminRole.ADMIN, + }), + ); + + await users.save( + users.create({ + id: TEST_IDS.user, + name: 'Integration Player', + username: TEST_CREDENTIALS.username, + email: TEST_CREDENTIALS.userEmail, + password: TEST_CREDENTIALS.userPassword, + isActive: true, + }), + ); + + await puzzles.save([ + puzzles.create({ + id: TEST_IDS.activePuzzle, + title: 'Integration Active Puzzle', + description: 'An active puzzle for integration coverage.', + difficulty: 'easy', + hint: 'The answer is a celestial path.', + solution: TEST_CREDENTIALS.activePuzzleSolution, + isActive: true, + }), + puzzles.create({ + id: TEST_IDS.inactivePuzzle, + title: 'Integration Inactive Puzzle', + description: 'An unpublished puzzle for integration coverage.', + difficulty: 'hard', + solution: 'unpublished', + isActive: false, + }), + ]); + + await completions.save( + completions.create({ + userId: TEST_IDS.user, + puzzleId: TEST_IDS.activePuzzle, + }), + ); + + await rewards.save( + rewards.create({ + id: TEST_IDS.reward, + name: 'Integration Badge', + description: 'Awarded for the integration puzzle.', + type: RewardType.BADGE, + metadata: { tier: 'test' }, + challengeId: 'integration-challenge', + isActive: true, + maxClaims: 10, + currentClaims: 0, + }), + ); + + await categories.save( + categories.create({ + id: TEST_IDS.category, + name: 'Integration Category', + description: 'Seeded for integration tests.', + slug: 'integration-category', + icon: '🧩', + color: '#112233', + isActive: true, + sortOrder: 1, + }), + ); + + await categoryPuzzles.save( + categoryPuzzles.create({ + id: TEST_IDS.categoryPuzzle, + title: 'Integration Category Puzzle', + description: 'Categorised puzzle fixture.', + difficulty: 'BEGINNER', + points: 10, + isActive: true, + estimatedTime: 5, + }), + ); + + await contents.save( + contents.create({ + id: TEST_IDS.content, + title: 'Integration Article', + body: 'Seeded content for the integration suite.', + topic: 'integration', + isActive: true, + }), + ); + + await progress.save( + progress.create({ + userId: TEST_IDS.user, + completedPuzzles: 1, + totalPuzzles: 2, + }), + ); + + return { + adminId: TEST_IDS.admin, + userId: TEST_IDS.user, + activePuzzleId: TEST_IDS.activePuzzle, + inactivePuzzleId: TEST_IDS.inactivePuzzle, + rewardId: TEST_IDS.reward, + categoryId: TEST_IDS.category, + categoryPuzzleId: TEST_IDS.categoryPuzzle, + contentId: TEST_IDS.content, + }; +} + +/** Creates and connects a DataSource for the configured test database. */ +export function createTestDataSource( + overrides: Partial[0]> = {}, +): DataSource { + return new DataSource({ + type: 'postgres', + host: process.env.DATABASE_HOST || 'localhost', + port: Number(process.env.DATABASE_PORT) || 5432, + username: process.env.DATABASE_USER || 'postgres', + password: process.env.DATABASE_PASSWORD || 'postgres', + database: process.env.DATABASE_NAME || 'stellarshunts', + entities: [ + Admin, + User, + Puzzle, + PuzzleCompletion, + Reward, + RewardClaim, + Category, + CategoryPuzzle, + Content, + Progress, + ], + synchronize: true, + ...overrides, + } as any); +}