Files
ashen-realms/apps/api/test/visible-slice.e2e-spec.ts
Bastian Wagner bb307da0ff feat: wire HuntingController/HuntingModule and enrich current-location with monster pool
Registers HuntingModule (POST /api/hunts) into the DI graph alongside its
new entities, and adds possibleMonsters (enabled LocationMonster pool,
weight-descending, empty when hunting is disabled) to
WorldService.getCurrentLocation. Also updates the pre-existing
DB-less app.e2e-spec.ts to override HuntingModule the same way the other
feature modules already are, since it now needs a real DataSource.
2026-08-19 12:53:09 +02:00

346 lines
12 KiB
TypeScript

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<App>;
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<App>;
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');
expect(atBurnedRoad.possibleMonsters).toEqual([
'Aschenratte',
'Straßenräuber',
]);
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);
for (const encounter of firstHunt.body.encounters as Array<{
id: string;
monster: { key: string };
dangerRating: string;
}>) {
expect(typeof encounter.id).toBe('string');
expect(['ash-rat', 'road-bandit']).toContain(encounter.monster.key);
expect([
'WEAK',
'MATCH',
'STRONG',
'VERY_DANGEROUS',
'DEADLY',
]).toContain(encounter.dangerRating);
}
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);
async function pollUntilTravelCompletes(
application: INestApplication<App>,
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;
}
});
});