Document the migration/seed/API/web workflow in a root README, add an API e2e smoke test that always checks /api/health and additionally exercises the real DatabaseModule/seeded data plus the arrivesAt validation rejection when DATABASE_URL is available, and add a `test:e2e` root script. Also fix `.env` loading so the documented root-level `.env` is actually found: `main.ts` never loaded dotenv at all, and `data-source.ts` loaded it relative to `process.cwd()`, which is `apps/api` (not the repo root) whenever npm runs a `--workspace` script. Both now resolve the repo-root `.env` explicitly. Also narrowed the CLI migrations glob to numeric-prefixed files so it no longer tries to load the colocated `*.migration.spec.ts` as a migration. Verified end-to-end against a real PostgreSQL instance: migrate, seed twice (idempotent), start the API, and exercise every documented route including a rejected POST /api/travel body containing arrivesAt. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
157 lines
5.0 KiB
TypeScript
157 lines
5.0 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 { 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<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)
|
|
.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);
|
|
});
|
|
});
|
|
});
|