diff --git a/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-4-report.md b/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-4-report.md new file mode 100644 index 0000000..923642b --- /dev/null +++ b/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-4-report.md @@ -0,0 +1,50 @@ +# Task 4: Health and demo-character API report + +## RED + +- Re-read every Markdown document below `docs/` and inspected `combat-screen.png`, `hunting-screen.png`, and `world-travel-screen.png` before implementation. +- Added focused health and character-service tests, then ran: + + ```powershell + npm test --workspace=@ashen-realms/api -- health.controller.spec.ts characters.service.spec.ts --runInBand + ``` + +- Observed the expected RED result: both suites failed because `HealthController` and `CharactersService` did not exist. + +## GREEN + +- Replaced the scaffold root controller/service with `HealthModule` and `CharactersModule`. +- `GET /api/health` returns `{ status: 'ok' }` without a database query. +- `GET /api/characters/me` obtains the fixed demo ID server-side, loads `currentLocation`, maps `baseHp` to `maxHp` and `baseAttack` to `attack`, and raises `NotFoundException` when the seed is absent. +- Preserved the existing tested `configureApplication()` global-prefix seam and enabled Nest shutdown hooks in `main.ts` without repeating prefix configuration. +- Updated the E2E assertion from the retired hello-world root route to `/api/health`; it replaces both database-dependent modules so the health test remains database independent. + +## Verification + +| Command | Result | +| --- | --- | +| `npm test --workspace=@ashen-realms/api -- health.controller.spec.ts characters.service.spec.ts --runInBand` | PASS: 2 suites, 3 tests | +| `npm run test:e2e --workspace=@ashen-realms/api -- --runInBand` | PASS: 1 suite, 2 tests | +| `npm test --workspace=@ashen-realms/api -- --runInBand` | PASS: 5 suites, 9 tests | +| `npm run build:api` | PASS | +| `apps/api/node_modules/.bin/prettier.cmd --check ` | PASS | +| `git diff --check` | PASS | + +`npx prettier` did not resolve the workspace-local executable in this environment; the checked-in workspace binary at `apps/api/node_modules/.bin/prettier.cmd` was used for the formatting check. + +## Files + +- Added `apps/api/src/health/*` and character controller/service/module plus unit tests. +- Updated `apps/api/src/app.module.ts`, `apps/api/src/main.ts`, and `apps/api/test/app.e2e-spec.ts`. +- Removed `apps/api/src/app.controller.ts`, `app.controller.spec.ts`, and `app.service.ts`. + +## Self-review and concerns + +- Confirmed the repository query loads only the required current-location relation and response does not expose persistence-only base-stat names. +- Confirmed the missing seed path is tested as a 404-producing Nest exception. +- Confirmed `/` remains a 404 while `/api/health` is available through the existing global prefix. +- No task-specific concerns remain. Pre-existing untracked `apps/web/public/images/` and `docs/references/Ashen_Realms_Visual_Asset_Style_Guide_V1.md` are intentionally excluded. + +## Commit + +- Pending: `feat: expose health and demo character APIs` diff --git a/apps/api/src/app.controller.spec.ts b/apps/api/src/app.controller.spec.ts deleted file mode 100644 index d22f389..0000000 --- a/apps/api/src/app.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/apps/api/src/app.controller.ts b/apps/api/src/app.controller.ts deleted file mode 100644 index cce879e..0000000 --- a/apps/api/src/app.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getHello(): string { - return this.appService.getHello(); - } -} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 1453aaf..6f7dd5c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { CharactersModule } from './characters/characters.module'; import { DatabaseModule } from './database/database.module'; +import { HealthModule } from './health/health.module'; @Module({ - imports: [DatabaseModule], - controllers: [AppController], - providers: [AppService], + imports: [DatabaseModule, HealthModule, CharactersModule], }) export class AppModule {} diff --git a/apps/api/src/app.service.ts b/apps/api/src/app.service.ts deleted file mode 100644 index 927d7cc..0000000 --- a/apps/api/src/app.service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class AppService { - getHello(): string { - return 'Hello World!'; - } -} diff --git a/apps/api/src/characters/characters.controller.ts b/apps/api/src/characters/characters.controller.ts new file mode 100644 index 0000000..351acd2 --- /dev/null +++ b/apps/api/src/characters/characters.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { CharactersService } from './characters.service'; + +@Controller('characters') +export class CharactersController { + constructor(private readonly charactersService: CharactersService) {} + + @Get('me') + getDemoCharacter() { + return this.charactersService.getDemoCharacter(); + } +} diff --git a/apps/api/src/characters/characters.module.ts b/apps/api/src/characters/characters.module.ts new file mode 100644 index 0000000..cb5e2c3 --- /dev/null +++ b/apps/api/src/characters/characters.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { CharactersController } from './characters.controller'; +import { CharactersService } from './characters.service'; +import { Character } from './entities/character.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Character])], + controllers: [CharactersController], + providers: [CharactersService], +}) +export class CharactersModule {} diff --git a/apps/api/src/characters/characters.service.spec.ts b/apps/api/src/characters/characters.service.spec.ts new file mode 100644 index 0000000..0f27a1f --- /dev/null +++ b/apps/api/src/characters/characters.service.spec.ts @@ -0,0 +1,58 @@ +import { NotFoundException } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { SOUTH_GATE_ID } from '../database/seeds/vertical-slice.constants'; +import { Character } from './entities/character.entity'; +import { CharactersService } from './characters.service'; + +describe('CharactersService', () => { + it('returns the demo character with its current location summary', async () => { + const repository = { + findOne: jest.fn().mockResolvedValue({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + currentHp: 100, + baseHp: 100, + baseAttack: 6, + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'S\u00fcdtor von Graufurt', + }, + }), + } as unknown as Repository; + const service = new CharactersService(repository); + + await expect(service.getDemoCharacter()).resolves.toEqual({ + id: DEMO_CHARACTER_ID, + name: 'Aric Duskwalker', + level: 1, + experience: 0, + currentHp: 100, + maxHp: 100, + attack: 6, + currentLocation: { + id: SOUTH_GATE_ID, + key: 'south-gate', + name: 'S\u00fcdtor von Graufurt', + }, + }); + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: DEMO_CHARACTER_ID }, + relations: { currentLocation: true }, + }); + }); + + it('reports a missing demo seed as not found', async () => { + const repository = { + findOne: jest.fn().mockResolvedValue(null), + } as unknown as Repository; + const service = new CharactersService(repository); + + await expect(service.getDemoCharacter()).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/apps/api/src/characters/characters.service.ts b/apps/api/src/characters/characters.service.ts new file mode 100644 index 0000000..2d76ccc --- /dev/null +++ b/apps/api/src/characters/characters.service.ts @@ -0,0 +1,39 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { Character } from './entities/character.entity'; + +@Injectable() +export class CharactersService { + constructor( + @InjectRepository(Character) + private readonly characters: Repository, + ) {} + + async getDemoCharacter() { + const character = await this.characters.findOne({ + where: { id: DEMO_CHARACTER_ID }, + relations: { currentLocation: true }, + }); + + if (!character) { + throw new NotFoundException('Demo character has not been seeded'); + } + + return { + id: character.id, + name: character.name, + level: character.level, + experience: character.experience, + currentHp: character.currentHp, + maxHp: character.baseHp, + attack: character.baseAttack, + currentLocation: { + id: character.currentLocation.id, + key: character.currentLocation.key, + name: character.currentLocation.name, + }, + }; + } +} diff --git a/apps/api/src/health/health.controller.spec.ts b/apps/api/src/health/health.controller.spec.ts new file mode 100644 index 0000000..f314352 --- /dev/null +++ b/apps/api/src/health/health.controller.spec.ts @@ -0,0 +1,7 @@ +import { HealthController } from './health.controller'; + +describe('HealthController', () => { + it('returns an ok status for liveness checks', () => { + expect(new HealthController().getHealth()).toEqual({ status: 'ok' }); + }); +}); diff --git a/apps/api/src/health/health.controller.ts b/apps/api/src/health/health.controller.ts new file mode 100644 index 0000000..4b0e136 --- /dev/null +++ b/apps/api/src/health/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + getHealth() { + return { status: 'ok' }; + } +} diff --git a/apps/api/src/health/health.module.ts b/apps/api/src/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/apps/api/src/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 7cce8dc..ed54b1e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -5,6 +5,7 @@ import { configureApplication } from './app.config'; async function bootstrap() { const app = await NestFactory.create(AppModule); configureApplication(app); + app.enableShutdownHooks(); await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts index 944f6f7..8e8165f 100644 --- a/apps/api/test/app.e2e-spec.ts +++ b/apps/api/test/app.e2e-spec.ts @@ -3,13 +3,17 @@ 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'; @Module({}) class TestDatabaseModule {} -describe('AppController (e2e)', () => { +@Module({}) +class TestCharactersModule {} + +describe('API (e2e)', () => { let app: INestApplication; beforeEach(async () => { @@ -18,6 +22,8 @@ describe('AppController (e2e)', () => { }) .overrideModule(DatabaseModule) .useModule(TestDatabaseModule) + .overrideModule(CharactersModule) + .useModule(TestCharactersModule) .compile(); app = moduleFixture.createNestApplication(); @@ -25,11 +31,11 @@ describe('AppController (e2e)', () => { await app.init(); }); - it('/api (GET)', () => { + it('/api/health (GET)', () => { return request(app.getHttpServer()) - .get('/api') + .get('/api/health') .expect(200) - .expect('Hello World!'); + .expect({ status: 'ok' }); }); it('/ (GET) is not an API route', () => {