feat: expose health and demo character APIs
This commit is contained in:
@@ -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 <task files>` | 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`
|
||||||
@@ -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>(AppController);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('root', () => {
|
|
||||||
it('should return "Hello World!"', () => {
|
|
||||||
expect(appController.getHello()).toBe('Hello World!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { CharactersModule } from './characters/characters.module';
|
||||||
import { AppService } from './app.service';
|
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule],
|
imports: [DatabaseModule, HealthModule, CharactersModule],
|
||||||
controllers: [AppController],
|
|
||||||
providers: [AppService],
|
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AppService {
|
|
||||||
getHello(): string {
|
|
||||||
return 'Hello World!';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
apps/api/src/characters/characters.controller.ts
Normal file
12
apps/api/src/characters/characters.controller.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/characters/characters.module.ts
Normal file
12
apps/api/src/characters/characters.module.ts
Normal file
@@ -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 {}
|
||||||
58
apps/api/src/characters/characters.service.spec.ts
Normal file
58
apps/api/src/characters/characters.service.spec.ts
Normal file
@@ -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<Character>;
|
||||||
|
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<Character>;
|
||||||
|
const service = new CharactersService(repository);
|
||||||
|
|
||||||
|
await expect(service.getDemoCharacter()).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/api/src/characters/characters.service.ts
Normal file
39
apps/api/src/characters/characters.service.ts
Normal file
@@ -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<Character>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/health/health.controller.spec.ts
Normal file
7
apps/api/src/health/health.controller.spec.ts
Normal file
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
9
apps/api/src/health/health.controller.ts
Normal file
9
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
@Get()
|
||||||
|
getHealth() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/health/health.module.ts
Normal file
7
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
@@ -5,6 +5,7 @@ import { configureApplication } from './app.config';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
configureApplication(app);
|
configureApplication(app);
|
||||||
|
app.enableShutdownHooks();
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import { INestApplication, Module } from '@nestjs/common';
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { App } from 'supertest/types';
|
import { App } from 'supertest/types';
|
||||||
import { AppModule } from './../src/app.module';
|
import { AppModule } from './../src/app.module';
|
||||||
|
import { CharactersModule } from './../src/characters/characters.module';
|
||||||
import { DatabaseModule } from './../src/database/database.module';
|
import { DatabaseModule } from './../src/database/database.module';
|
||||||
import { configureApplication } from './../src/app.config';
|
import { configureApplication } from './../src/app.config';
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
class TestDatabaseModule {}
|
class TestDatabaseModule {}
|
||||||
|
|
||||||
describe('AppController (e2e)', () => {
|
@Module({})
|
||||||
|
class TestCharactersModule {}
|
||||||
|
|
||||||
|
describe('API (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -18,6 +22,8 @@ describe('AppController (e2e)', () => {
|
|||||||
})
|
})
|
||||||
.overrideModule(DatabaseModule)
|
.overrideModule(DatabaseModule)
|
||||||
.useModule(TestDatabaseModule)
|
.useModule(TestDatabaseModule)
|
||||||
|
.overrideModule(CharactersModule)
|
||||||
|
.useModule(TestCharactersModule)
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
@@ -25,11 +31,11 @@ describe('AppController (e2e)', () => {
|
|||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/api (GET)', () => {
|
it('/api/health (GET)', () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.get('/api')
|
.get('/api/health')
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect('Hello World!');
|
.expect({ status: 'ok' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET) is not an API route', () => {
|
it('/ (GET) is not an API route', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user