docs: add visible slice local workflow

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>
This commit is contained in:
Bastian Wagner
2026-08-19 09:41:16 +02:00
parent 6c35c18018
commit 2f53e5ba80
7 changed files with 343 additions and 3 deletions

View File

@@ -1,6 +1,14 @@
import 'dotenv/config';
import { config } from 'dotenv';
import { resolve } from 'path';
import { DataSource } from 'typeorm';
// npm workspace scripts (`db:migrate`, `db:seed`) run with this file's
// workspace (`apps/api`) as the current working directory, not the repo
// root where the documented `.env` lives. Resolve it explicitly so the
// documented `npm run db:migrate` / `npm run db:seed` flow works regardless
// of the caller's working directory.
config({ path: resolve(__dirname, '../../../../.env') });
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl?.trim()) {
@@ -11,6 +19,6 @@ export const AppDataSource = new DataSource({
type: 'postgres',
url: databaseUrl,
entities: [__dirname + '/../**/*.entity.{ts,js}'],
migrations: [__dirname + '/migrations/*.{ts,js}'],
migrations: [__dirname + '/migrations/[0-9]*.{ts,js}'],
synchronize: false,
});

View File

@@ -1,7 +1,15 @@
import { config } from 'dotenv';
import { resolve } from 'path';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { configureApplication } from './app.config';
// `dev:api` runs via the `apps/api` npm workspace, so process.cwd() is
// `apps/api`, not the repo root where the documented `.env` lives. Resolve
// it explicitly so `npm run dev:api` picks up `DATABASE_URL` after
// `Copy-Item .env.example .env` as documented in the README.
config({ path: resolve(__dirname, '..', '..', '..', '.env') });
async function bootstrap() {
const app = await NestFactory.create(AppModule);
configureApplication(app);

View File

@@ -5,5 +5,6 @@
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
},
"testTimeout": 15000
}

View File

@@ -0,0 +1,156 @@
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);
});
});
});