import { config } from 'dotenv'; import { resolve } from 'path'; import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, Module } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; import { CharactersModule } from './../src/characters/characters.module'; import { DatabaseModule } from './../src/database/database.module'; import { configureApplication } from './../src/app.config'; import { TravelModule } from './../src/travel/travel.module'; import { WorldModule } from './../src/world/world.module'; import { DEMO_CHARACTER_ID } from './../src/demo/demo-character.constants'; import { BURNED_ROAD_ID } from './../src/database/seeds/vertical-slice.constants'; // `npm run test:e2e` runs from the `apps/api` workspace, so process.cwd() // is `apps/api`, not the repo root where the documented `.env` lives. // Load it the same way `main.ts`/`data-source.ts` do, without overriding a // `DATABASE_URL` a developer or CI already exported. config({ path: resolve(__dirname, '../../../.env') }); @Module({}) class TestDatabaseModule {} @Module({}) class TestCharactersModule {} @Module({}) class TestTravelModule {} @Module({}) class TestWorldModule {} describe('Visible vertical slice smoke (e2e)', () => { describe('without a developer database', () => { let app: INestApplication; beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }) .overrideModule(DatabaseModule) .useModule(TestDatabaseModule) .overrideModule(CharactersModule) .useModule(TestCharactersModule) .overrideModule(TravelModule) .useModule(TestTravelModule) .overrideModule(WorldModule) .useModule(TestWorldModule) .compile(); app = moduleFixture.createNestApplication(); configureApplication(app); await app.init(); }); afterEach(async () => { await app?.close(); }); it('GET /api/health returns ok', () => { return request(app.getHttpServer()) .get('/api/health') .expect(200) .expect({ status: 'ok' }); }); }); // These assertions exercise the real DatabaseModule, TypeORM entities and // the seeded demo character/world data, so they only run when a reachable // PostgreSQL database is configured (see README.md for local setup). const describeWithDatabase = process.env.DATABASE_URL ? describe : describe.skip; describeWithDatabase('against a migrated and seeded database', () => { let app: INestApplication; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = moduleFixture.createNestApplication(); configureApplication(app); await app.init(); }); afterAll(async () => { await app?.close(); }); it('GET /api/health returns ok', () => { return request(app.getHttpServer()) .get('/api/health') .expect(200) .expect({ status: 'ok' }); }); it('GET /api/characters/me returns the seeded demo character', async () => { const response = await request(app.getHttpServer()) .get('/api/characters/me') .expect(200); const body = response.body as { id: string; name: string; currentLocation: { id: string; key: string; name: string }; }; expect(body).toMatchObject({ id: DEMO_CHARACTER_ID, name: 'Aric Duskwalker', }); expect(typeof body.currentLocation.id).toBe('string'); expect(typeof body.currentLocation.key).toBe('string'); expect(typeof body.currentLocation.name).toBe('string'); }); it('GET /api/world/current-location returns the character location with its connections', async () => { const response = await request(app.getHttpServer()) .get('/api/world/current-location') .expect(200); const body = response.body as { id: string; key: string; name: string; connections: unknown[]; }; expect(typeof body.id).toBe('string'); expect(typeof body.key).toBe('string'); expect(typeof body.name).toBe('string'); expect(Array.isArray(body.connections)).toBe(true); expect(body.connections.length).toBeGreaterThan(0); }); it('GET /api/travel/current returns a known travel status', async () => { const response = await request(app.getHttpServer()) .get('/api/travel/current') .expect(200); const body = response.body as { status: string }; expect(['IDLE', 'TRAVELLING', 'COMPLETED']).toContain(body.status); }); it('POST /api/travel rejects a body carrying a non-whitelisted arrivesAt field', () => { return request(app.getHttpServer()) .post('/api/travel') .send({ targetLocationId: BURNED_ROAD_ID, arrivesAt: new Date().toISOString(), }) .expect(400); }); it( 'POST /api/travel starts a travel, rejects a concurrent start, and completes into the moved character (full happy path)', async () => { // `GET current-location` lazily completes any overdue travel left // behind by a previous run before we read the starting point, so // this test is safe to re-run without a fresh seed. const origin = await request(app.getHttpServer()) .get('/api/world/current-location') .expect(200); const originLocationId: string = origin.body.id; const outbound = origin.body.connections[0]; const targetLocationId: string = outbound.targetLocation.id; const travelDurationSeconds: number = outbound.travelDurationSeconds; const started = await request(app.getHttpServer()) .post('/api/travel') .send({ targetLocationId }) .expect(201); expect(started.body).toMatchObject({ status: 'TRAVELLING', targetLocation: { id: targetLocationId }, }); expect(typeof started.body.arrivesAt).toBe('string'); expect(Number.isNaN(Date.parse(started.body.arrivesAt))).toBe(false); const concurrentStart = await request(app.getHttpServer()) .post('/api/travel') .send({ targetLocationId }) .expect(409); expect(concurrentStart.body).toMatchObject({ code: 'TRAVEL_ALREADY_ACTIVE', }); const arrivedTravel = await pollUntilTravelCompletes( app, travelDurationSeconds, ); expect(arrivedTravel).toMatchObject({ status: 'COMPLETED', targetLocation: { id: targetLocationId }, }); const arrivedLocation = await request(app.getHttpServer()) .get('/api/world/current-location') .expect(200); expect(arrivedLocation.body.id).toBe(targetLocationId); // Restore the demo character to its original location so the suite // (and this test) stays safely re-runnable. const returnConnection = arrivedLocation.body.connections.find( (connection: { targetLocation: { id: string } }) => connection.targetLocation.id === originLocationId, ); expect(returnConnection).toBeDefined(); await request(app.getHttpServer()) .post('/api/travel') .send({ targetLocationId: originLocationId }) .expect(201); const returnedTravel = await pollUntilTravelCompletes( app, returnConnection.travelDurationSeconds, ); expect(returnedTravel).toMatchObject({ status: 'COMPLETED', targetLocation: { id: originLocationId }, }); const restoredLocation = await request(app.getHttpServer()) .get('/api/world/current-location') .expect(200); expect(restoredLocation.body.id).toBe(originLocationId); }, 30_000, ); async function pollUntilTravelCompletes( application: INestApplication, travelDurationSeconds: number, ): Promise<{ status: string; targetLocation?: { id: string } }> { const deadline = Date.now() + travelDurationSeconds * 1000 + 5_000; let body: { status: string; targetLocation?: { id: string } }; do { const response = await request(application.getHttpServer()) .get('/api/travel/current') .expect(200); body = response.body; if (body.status === 'COMPLETED') { return body; } await new Promise((resolve) => setTimeout(resolve, 500)); } while (Date.now() < deadline); return body; } }); });