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:
@@ -1,6 +1,10 @@
|
|||||||
|
# Copy this file to `.env` (repo root) and adjust as needed. See README.md
|
||||||
|
# for the full local setup workflow (install, migrate, seed, run api/web).
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Requires a reachable PostgreSQL server with a database named `ashen_realms`
|
||||||
|
# owned by this user/password (or your own local equivalent connection string).
|
||||||
DATABASE_URL=postgresql://ashen:ashen@localhost:5432/ashen_realms
|
DATABASE_URL=postgresql://ashen:ashen@localhost:5432/ashen_realms
|
||||||
|
|
||||||
JWT_ACCESS_SECRET=change-me
|
JWT_ACCESS_SECRET=change-me
|
||||||
|
|||||||
162
README.md
Normal file
162
README.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# Ashen Realms
|
||||||
|
|
||||||
|
A dark-fantasy browser RPG built as an npm-workspace modular monolith:
|
||||||
|
|
||||||
|
- `apps/web` — Angular 22 single-page application
|
||||||
|
- `apps/api` — NestJS 11 + TypeORM + PostgreSQL API
|
||||||
|
- `packages/*` — reserved shared boundaries (currently unused)
|
||||||
|
|
||||||
|
This README documents the first visible vertical slice: a server-authoritative
|
||||||
|
world/travel loop between two locations (Südtor von Graufurt and Verbrannte
|
||||||
|
Straße) for one demo character, with no login required.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js and npm (workspace-aware npm, matching the versions used by CI/your
|
||||||
|
toolchain).
|
||||||
|
- A reachable PostgreSQL server (PostgreSQL 13 or newer — the schema uses the
|
||||||
|
built-in `gen_random_uuid()`, which needs no extensions) with a database
|
||||||
|
named `ashen_realms`. Create it once, e.g.:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
psql -h localhost -U postgres -c "CREATE DATABASE ashen_realms;"
|
||||||
|
psql -h localhost -U postgres -c "CREATE USER ashen WITH PASSWORD 'ashen';"
|
||||||
|
psql -h localhost -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE ashen_realms TO ashen;"
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust user/password/host to match your own local PostgreSQL instance; just
|
||||||
|
keep `DATABASE_URL` (below) pointed at it.
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
Run from the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
Copy-Item .env.example .env
|
||||||
|
npm run db:migrate
|
||||||
|
npm run db:seed
|
||||||
|
npm run dev:api
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `dev:api` and `dev:web` in separate terminals — both watch and keep
|
||||||
|
running. Once both are up:
|
||||||
|
|
||||||
|
- API: `http://localhost:3000/api/...` (see routes below)
|
||||||
|
- Web: `http://localhost:4200`, which proxies `/api/*` requests to
|
||||||
|
`http://localhost:3000` in development (see `apps/web/proxy.conf.json`)
|
||||||
|
|
||||||
|
Open `http://localhost:4200/world` to see Aric Duskwalker at the Südtor von
|
||||||
|
Graufurt and travel to the Verbrannte Straße and back.
|
||||||
|
|
||||||
|
### Configuration (`.env`)
|
||||||
|
|
||||||
|
`.env.example` (repo root) documents every variable. Copy it to `.env` at the
|
||||||
|
repo root — `.env` is git-ignored and must never be committed. The important
|
||||||
|
one for local setup is:
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL=postgresql://ashen:ashen@localhost:5432/ashen_realms
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the default local PostgreSQL connection string: database name
|
||||||
|
`ashen_realms`, default port `5432`. Edit it if your local PostgreSQL uses a
|
||||||
|
different user, password, host, or port. All API workspace scripts
|
||||||
|
(`db:migrate`, `db:seed`, `dev:api`, and `apps/api`'s own `test:e2e`) resolve
|
||||||
|
this `.env` from the repository root regardless of which workspace directory
|
||||||
|
npm runs the underlying command in.
|
||||||
|
|
||||||
|
`synchronize` is always `false`; schema changes only happen through the
|
||||||
|
checked-in migration at
|
||||||
|
`apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts`.
|
||||||
|
The seed (`apps/api/src/database/seeds/vertical-slice.seed.ts`) is idempotent:
|
||||||
|
running `npm run db:seed` multiple times upserts the same two locations, two
|
||||||
|
connections, and one demo character without creating duplicates or resetting
|
||||||
|
the character's current (live) location.
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
| Service | Port | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| API (NestJS) | `3000` | All routes are under `/api` (e.g. `/api/health`). |
|
||||||
|
| Web (Angular dev server) | `4200` | Proxies `/api/*` to the API in development. |
|
||||||
|
|
||||||
|
## Available routes (first slice)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health
|
||||||
|
GET /api/characters/me
|
||||||
|
GET /api/world/current-location
|
||||||
|
GET /api/travel/current
|
||||||
|
POST /api/travel { "targetLocationId": "<uuid>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /api/travel` accepts exactly `targetLocationId`; the global validation
|
||||||
|
pipe rejects any other property (e.g. a client-supplied `arrivesAt`) with
|
||||||
|
`400 Bad Request`, since `arrivesAt` is always server-derived.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
Run these from the repository root unless noted otherwise.
|
||||||
|
|
||||||
|
| Script | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev:web` | Start the Angular dev server (port 4200). |
|
||||||
|
| `npm run dev:api` | Start the NestJS API in watch mode (port 3000). |
|
||||||
|
| `npm run build:web` | Production build of the Angular app. |
|
||||||
|
| `npm run build:api` | Production build of the NestJS app. |
|
||||||
|
| `npm run build` | Both builds. |
|
||||||
|
| `npm test` | Unit tests for every workspace. |
|
||||||
|
| `npm run test:e2e` | API end-to-end/smoke tests (see below). |
|
||||||
|
| `npm run db:migrate` | Run pending TypeORM migrations against `DATABASE_URL`. |
|
||||||
|
| `npm run db:revert` | Revert the last migration. |
|
||||||
|
| `npm run db:seed` | Run the idempotent demo-content seed. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Unit tests never require a database:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test --workspace=@ashen-realms/api -- --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
The API end-to-end smoke test (`apps/api/test/visible-slice.e2e-spec.ts`)
|
||||||
|
always asserts `GET /api/health`. When `DATABASE_URL` is set and reachable,
|
||||||
|
it additionally boots the real `AppModule` (real database, entities, and
|
||||||
|
controllers) and asserts the seeded character/world responses and the
|
||||||
|
`arrivesAt` validation rejection. Those database-backed assertions are
|
||||||
|
skipped — not failed — when no database is configured, so `npm run test:e2e`
|
||||||
|
is safe to run without any local PostgreSQL setup too:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run test:e2e --workspace=@ashen-realms/api -- --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Both builds:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build:api
|
||||||
|
npm run build:web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known limitations of this first vertical slice
|
||||||
|
|
||||||
|
This slice deliberately excludes (per
|
||||||
|
`docs/superpowers/specs/2026-08-18-first-visible-vertical-slice-design.md`):
|
||||||
|
|
||||||
|
- Authentication, user accounts, JWTs, or any login/registration flow — there
|
||||||
|
is exactly one hardcoded demo character (Aric Duskwalker).
|
||||||
|
- Hunting, combat, loot, inventory, equipment, quests, merchants, and
|
||||||
|
currencies.
|
||||||
|
- Realtime features: WebSockets, chat, guilds, CMS, object storage, or a
|
||||||
|
multi-service/microservice architecture.
|
||||||
|
- Ambush resolution: `ambushChance` is stored on each connection and surfaced
|
||||||
|
only as a coarse `LOW`/`HIGH` danger rating; it is never rolled.
|
||||||
|
- Production containerization or NestJS static hosting.
|
||||||
|
- Client-side clock skew can shift how the travel countdown *displays*, but
|
||||||
|
arrival is always confirmed by the server (`GET /api/travel/current` /
|
||||||
|
`GET /api/world/current-location`), never inferred locally.
|
||||||
|
|
||||||
|
Only two locations and one directed pair of connections exist
|
||||||
|
(`south-gate` ⇄ `burned-road`), each with a fixed 10-second travel duration.
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import 'dotenv/config';
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { DataSource } from 'typeorm';
|
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;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
if (!databaseUrl?.trim()) {
|
if (!databaseUrl?.trim()) {
|
||||||
@@ -11,6 +19,6 @@ export const AppDataSource = new DataSource({
|
|||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
url: databaseUrl,
|
url: databaseUrl,
|
||||||
entities: [__dirname + '/../**/*.entity.{ts,js}'],
|
entities: [__dirname + '/../**/*.entity.{ts,js}'],
|
||||||
migrations: [__dirname + '/migrations/*.{ts,js}'],
|
migrations: [__dirname + '/migrations/[0-9]*.{ts,js}'],
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { configureApplication } from './app.config';
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
configureApplication(app);
|
configureApplication(app);
|
||||||
|
|||||||
@@ -5,5 +5,6 @@
|
|||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
}
|
},
|
||||||
|
"testTimeout": 15000
|
||||||
}
|
}
|
||||||
|
|||||||
156
apps/api/test/visible-slice.e2e-spec.ts
Normal file
156
apps/api/test/visible-slice.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"build:api": "npm run build --workspace=@ashen-realms/api",
|
"build:api": "npm run build --workspace=@ashen-realms/api",
|
||||||
"build": "npm run build:web && npm run build:api",
|
"build": "npm run build:web && npm run build:api",
|
||||||
"test": "npm run test --workspaces --if-present",
|
"test": "npm run test --workspaces --if-present",
|
||||||
|
"test:e2e": "npm run test:e2e --workspace=@ashen-realms/api --",
|
||||||
"db:migrate": "npm run typeorm --workspace=@ashen-realms/api -- migration:run -d src/database/data-source.ts",
|
"db:migrate": "npm run typeorm --workspace=@ashen-realms/api -- migration:run -d src/database/data-source.ts",
|
||||||
"db:revert": "npm run typeorm --workspace=@ashen-realms/api -- migration:revert -d src/database/data-source.ts",
|
"db:revert": "npm run typeorm --workspace=@ashen-realms/api -- migration:revert -d src/database/data-source.ts",
|
||||||
"db:seed": "npm run seed --workspace=@ashen-realms/api"
|
"db:seed": "npm run seed --workspace=@ashen-realms/api"
|
||||||
|
|||||||
Reference in New Issue
Block a user