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 { HuntingModule } from './../src/hunting/hunting.module'; 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 {} @Module({}) class TestHuntingModule {} 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) .overrideModule(HuntingModule) .useModule(TestHuntingModule) .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); it('POST /api/hunts starts a hunt at burned-road, and a second call supersedes the first', async () => { // Get to burned-road (the only hunting-enabled seeded location), // remembering the origin so we can restore it afterwards and keep // this test safely re-runnable. const origin = await request(app.getHttpServer()) .get('/api/world/current-location') .expect(200); const originLocationId: string = origin.body.id; const originLocationKey: string = origin.body.key; let atBurnedRoad = origin.body; if (originLocationKey !== 'burned-road') { const toBurnedRoad = origin.body.connections.find( (connection: { targetLocation: { key: string } }) => connection.targetLocation.key === 'burned-road', ); expect(toBurnedRoad).toBeDefined(); await request(app.getHttpServer()) .post('/api/travel') .send({ targetLocationId: toBurnedRoad.targetLocation.id }) .expect(201); await pollUntilTravelCompletes(app, toBurnedRoad.travelDurationSeconds); const arrived = await request(app.getHttpServer()) .get('/api/world/current-location') .expect(200); atBurnedRoad = arrived.body; } expect(atBurnedRoad.key).toBe('burned-road'); // Ordered by encounter weight, heaviest first (spec §3). expect(atBurnedRoad.possibleMonsters).toEqual([ 'Ash Rat', 'Feral Road Hound', 'Road Bandit', 'Charred Raider', ]); const firstHunt = await request(app.getHttpServer()) .post('/api/hunts') .expect(201); expect(typeof firstHunt.body.id).toBe('string'); expect(firstHunt.body.location).toMatchObject({ key: 'burned-road' }); expect(firstHunt.body.encounters).toHaveLength(3); const encounters = firstHunt.body.encounters as Array<{ id: string; monster: { key: string; flavorText: string | null }; dangerRating: string; encounterType: string; }>; for (const encounter of encounters) { expect(typeof encounter.id).toBe('string'); expect([ 'ash-rat', 'wild-road-dog', 'road-bandit', 'charred-looter', ]).toContain(encounter.monster.key); expect(typeof encounter.monster.flavorText).toBe('string'); expect(['NORMAL', 'RARE']).toContain(encounter.encounterType); expect([ 'WEAK', 'MATCH', 'STRONG', 'VERY_DANGEROUS', 'DEADLY', ]).toContain(encounter.dangerRating); } // The cards are a real choice between different enemies, so no monster // may fill two of them (spec §3). const rolledKeys = encounters.map((encounter) => encounter.monster.key); expect(new Set(rolledKeys).size).toBe(rolledKeys.length); // Only the Charred Raider is marked rare, so the mark stays meaningful. for (const encounter of encounters) { expect(encounter.encounterType === 'RARE').toBe( encounter.monster.key === 'charred-looter', ); } const capacities = await request(app.getHttpServer()) .get('/api/loot-bags/capacities') .expect(200); // Slice 0.7.5 §11: the carrying state is server-derived and covers every // known loot category. expect( (capacities.body as Array<{ category: string }>).map( (entry) => entry.category, ), ).toEqual(['HIDE', 'RAIDER_TROPHY']); for (const entry of capacities.body as Array<{ current: number; capacity: number; }>) { expect(entry.capacity).toBeGreaterThanOrEqual(1); expect(entry.current).toBeGreaterThanOrEqual(0); } const secondHunt = await request(app.getHttpServer()) .post('/api/hunts') .expect(201); expect(typeof secondHunt.body.id).toBe('string'); expect(secondHunt.body.id).not.toBe(firstHunt.body.id); // Restore the demo character to its original location so the suite // stays safely re-runnable. if (originLocationKey !== 'burned-road') { const returnConnection = atBurnedRoad.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); await pollUntilTravelCompletes( app, returnConnection.travelDurationSeconds, ); } }, 30_000); it('sells goods to Borin in Graufurt for Silver and reputation, and frees bag capacity', async () => { // Slice 0.8 §14: the merchant is reachable, trading is server-priced and // atomic, and handing goods over frees carrying capacity. interface LocationBody { id: string; key: string; connections: Array<{ targetLocation: { id: string; key: string }; travelDurationSeconds: number; }>; } interface Offer { itemKey: string; quantityCarried: number; silverPerStep: number; } const origin = await request(app.getHttpServer()) .get('/api/world/current-location') .expect(200); const originLocation = origin.body as LocationBody; if (originLocation.key !== 'south-gate') { const toGate = originLocation.connections.find( (connection) => connection.targetLocation.key === 'south-gate', ); expect(toGate).toBeDefined(); await request(app.getHttpServer()) .post('/api/travel') .send({ targetLocationId: toGate!.targetLocation.id }) .expect(201); await pollUntilTravelCompletes(app, toGate!.travelDurationSeconds); } const interaction = await request(app.getHttpServer()) .get('/api/npcs/borin-quartermaster/interaction') .expect(200); const npcBody = interaction.body as { npc: { key: string; name: string }; dialogue: { text: string } | null; availableActions: Array<{ type: string }>; }; expect(npcBody.npc).toMatchObject({ key: 'borin-quartermaster', name: 'Borin', }); // One person, several jobs -- the composition model (NPC spec §2). expect(npcBody.availableActions.map((action) => action.type)).toEqual( expect.arrayContaining(['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE']), ); expect(typeof npcBody.dialogue?.text).toBe('string'); const view = await request(app.getHttpServer()) .get('/api/merchants/borin-quartermaster/trade-in') .expect(200); const offers = (view.body as { offers: Offer[] }).offers; // All four Burned Road trade goods are accepted (slice §5). expect(offers.map((offer) => offer.itemKey).sort()).toEqual([ 'ash-pelt', 'bandit-insignia', 'charred-raider-insignia', 'tough-hide', ]); // An item this merchant does not take is refused, whatever the client // claims (slice §13). await request(app.getHttpServer()) .post('/api/merchants/borin-quartermaster/trade-in') .send({ items: [{ itemKey: 'worn-short-sword', quantity: 1 }] }) .expect(409); const carried = offers.find((offer) => offer.quantityCarried > 0); if (!carried) { // Nothing to sell on this run. Everything above is still covered; the // trade itself is exercised whenever a hunt has produced goods. return; } const before = await request(app.getHttpServer()) .get('/api/characters/me') .expect(200); const silverBefore = (before.body as { silver: number }).silver; // More than is carried must be refused outright, leaving Silver alone. await request(app.getHttpServer()) .post('/api/merchants/borin-quartermaster/trade-in') .send({ items: [ { itemKey: carried.itemKey, quantity: carried.quantityCarried + 1 }, ], }) .expect(409); const unchanged = await request(app.getHttpServer()) .get('/api/characters/me') .expect(200); expect((unchanged.body as { silver: number }).silver).toBe(silverBefore); const traded = await request(app.getHttpServer()) .post('/api/merchants/borin-quartermaster/trade-in') .send({ items: [{ itemKey: carried.itemKey, quantity: 1 }] }) .expect(201); const result = traded.body as { consumed: Array<{ itemKey: string; quantity: number }>; rewards: { silver: number; regionalReputation: number }; balances: { silver: number }; capacities: Array<{ category: string }>; }; expect(result.consumed).toEqual([ expect.objectContaining({ itemKey: carried.itemKey, quantity: 1 }), ]); expect(result.rewards.silver).toBe(carried.silverPerStep); expect(result.balances.silver).toBe(silverBefore + carried.silverPerStep); expect(result.rewards.regionalReputation).toBeGreaterThan(0); // Capacity is derived from what is carried, so the trade frees it // immediately (slice §11). expect(result.capacities.length).toBeGreaterThan(0); const after = await request(app.getHttpServer()) .get('/api/merchants/borin-quartermaster/trade-in') .expect(200); const afterOffer = (after.body as { offers: Offer[] }).offers.find( (offer) => offer.itemKey === carried.itemKey, ); expect(afterOffer?.quantityCarried).toBe(carried.quantityCarried - 1); if (originLocation.key !== 'south-gate') { await request(app.getHttpServer()) .post('/api/travel') .send({ targetLocationId: originLocation.id }) .expect(201); await pollUntilTravelCompletes(app, 30); } }, 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; } }); });