529 lines
18 KiB
TypeScript
529 lines
18 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 { HuntingModule } from './../src/hunting/hunting.module';
|
|
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 {}
|
|
|
|
@Module({})
|
|
class TestHuntingModule {}
|
|
|
|
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)
|
|
.overrideModule(HuntingModule)
|
|
.useModule(TestHuntingModule)
|
|
.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);
|
|
});
|
|
|
|
it('POST /api/travel starts a travel, rejects a concurrent start, and completes into the moved character (full happy path)', async () => {
|
|
// `GET current-location` lazily completes any overdue travel left
|
|
// behind by a previous run before we read the starting point, so
|
|
// this test is safe to re-run without a fresh seed.
|
|
const origin = await request(app.getHttpServer())
|
|
.get('/api/world/current-location')
|
|
.expect(200);
|
|
const originLocationId: string = origin.body.id;
|
|
const outbound = origin.body.connections[0];
|
|
const targetLocationId: string = outbound.targetLocation.id;
|
|
const travelDurationSeconds: number = outbound.travelDurationSeconds;
|
|
|
|
const started = await request(app.getHttpServer())
|
|
.post('/api/travel')
|
|
.send({ targetLocationId })
|
|
.expect(201);
|
|
|
|
expect(started.body).toMatchObject({
|
|
status: 'TRAVELLING',
|
|
targetLocation: { id: targetLocationId },
|
|
});
|
|
expect(typeof started.body.arrivesAt).toBe('string');
|
|
expect(Number.isNaN(Date.parse(started.body.arrivesAt))).toBe(false);
|
|
|
|
const concurrentStart = await request(app.getHttpServer())
|
|
.post('/api/travel')
|
|
.send({ targetLocationId })
|
|
.expect(409);
|
|
expect(concurrentStart.body).toMatchObject({
|
|
code: 'TRAVEL_ALREADY_ACTIVE',
|
|
});
|
|
|
|
const arrivedTravel = await pollUntilTravelCompletes(
|
|
app,
|
|
travelDurationSeconds,
|
|
);
|
|
expect(arrivedTravel).toMatchObject({
|
|
status: 'COMPLETED',
|
|
targetLocation: { id: targetLocationId },
|
|
});
|
|
|
|
const arrivedLocation = await request(app.getHttpServer())
|
|
.get('/api/world/current-location')
|
|
.expect(200);
|
|
expect(arrivedLocation.body.id).toBe(targetLocationId);
|
|
|
|
// Restore the demo character to its original location so the suite
|
|
// (and this test) stays safely re-runnable.
|
|
const returnConnection = arrivedLocation.body.connections.find(
|
|
(connection: { targetLocation: { id: string } }) =>
|
|
connection.targetLocation.id === originLocationId,
|
|
);
|
|
expect(returnConnection).toBeDefined();
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/travel')
|
|
.send({ targetLocationId: originLocationId })
|
|
.expect(201);
|
|
|
|
const returnedTravel = await pollUntilTravelCompletes(
|
|
app,
|
|
returnConnection.travelDurationSeconds,
|
|
);
|
|
expect(returnedTravel).toMatchObject({
|
|
status: 'COMPLETED',
|
|
targetLocation: { id: originLocationId },
|
|
});
|
|
|
|
const restoredLocation = await request(app.getHttpServer())
|
|
.get('/api/world/current-location')
|
|
.expect(200);
|
|
expect(restoredLocation.body.id).toBe(originLocationId);
|
|
}, 30_000);
|
|
|
|
it('POST /api/hunts starts a hunt at burned-road, and a second call supersedes the first', async () => {
|
|
// Get to burned-road (the only hunting-enabled seeded location),
|
|
// remembering the origin so we can restore it afterwards and keep
|
|
// this test safely re-runnable.
|
|
const origin = await request(app.getHttpServer())
|
|
.get('/api/world/current-location')
|
|
.expect(200);
|
|
const originLocationId: string = origin.body.id;
|
|
const originLocationKey: string = origin.body.key;
|
|
|
|
let atBurnedRoad = origin.body;
|
|
if (originLocationKey !== 'burned-road') {
|
|
const toBurnedRoad = origin.body.connections.find(
|
|
(connection: { targetLocation: { key: string } }) =>
|
|
connection.targetLocation.key === 'burned-road',
|
|
);
|
|
expect(toBurnedRoad).toBeDefined();
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/travel')
|
|
.send({ targetLocationId: toBurnedRoad.targetLocation.id })
|
|
.expect(201);
|
|
|
|
await pollUntilTravelCompletes(app, toBurnedRoad.travelDurationSeconds);
|
|
|
|
const arrived = await request(app.getHttpServer())
|
|
.get('/api/world/current-location')
|
|
.expect(200);
|
|
atBurnedRoad = arrived.body;
|
|
}
|
|
|
|
expect(atBurnedRoad.key).toBe('burned-road');
|
|
// Ordered by encounter weight, heaviest first (spec §3).
|
|
expect(atBurnedRoad.possibleMonsters).toEqual([
|
|
'Ash Rat',
|
|
'Feral Road Hound',
|
|
'Road Bandit',
|
|
'Charred Raider',
|
|
]);
|
|
|
|
const firstHunt = await request(app.getHttpServer())
|
|
.post('/api/hunts')
|
|
.expect(201);
|
|
|
|
expect(typeof firstHunt.body.id).toBe('string');
|
|
expect(firstHunt.body.location).toMatchObject({ key: 'burned-road' });
|
|
expect(firstHunt.body.encounters).toHaveLength(3);
|
|
const encounters = firstHunt.body.encounters as Array<{
|
|
id: string;
|
|
monster: { key: string; flavorText: string | null };
|
|
dangerRating: string;
|
|
encounterType: string;
|
|
}>;
|
|
for (const encounter of encounters) {
|
|
expect(typeof encounter.id).toBe('string');
|
|
expect([
|
|
'ash-rat',
|
|
'wild-road-dog',
|
|
'road-bandit',
|
|
'charred-looter',
|
|
]).toContain(encounter.monster.key);
|
|
expect(typeof encounter.monster.flavorText).toBe('string');
|
|
expect(['NORMAL', 'RARE']).toContain(encounter.encounterType);
|
|
expect([
|
|
'WEAK',
|
|
'MATCH',
|
|
'STRONG',
|
|
'VERY_DANGEROUS',
|
|
'DEADLY',
|
|
]).toContain(encounter.dangerRating);
|
|
}
|
|
|
|
// The cards are a real choice between different enemies, so no monster
|
|
// may fill two of them (spec §3).
|
|
const rolledKeys = encounters.map((encounter) => encounter.monster.key);
|
|
expect(new Set(rolledKeys).size).toBe(rolledKeys.length);
|
|
|
|
// Only the Charred Raider is marked rare, so the mark stays meaningful.
|
|
for (const encounter of encounters) {
|
|
expect(encounter.encounterType === 'RARE').toBe(
|
|
encounter.monster.key === 'charred-looter',
|
|
);
|
|
}
|
|
|
|
const capacities = await request(app.getHttpServer())
|
|
.get('/api/loot-bags/capacities')
|
|
.expect(200);
|
|
|
|
// Slice 0.7.5 §11: the carrying state is server-derived and covers every
|
|
// known loot category.
|
|
expect(
|
|
(capacities.body as Array<{ category: string }>).map(
|
|
(entry) => entry.category,
|
|
),
|
|
).toEqual(['HIDE', 'RAIDER_TROPHY']);
|
|
for (const entry of capacities.body as Array<{
|
|
current: number;
|
|
capacity: number;
|
|
}>) {
|
|
expect(entry.capacity).toBeGreaterThanOrEqual(1);
|
|
expect(entry.current).toBeGreaterThanOrEqual(0);
|
|
}
|
|
|
|
const secondHunt = await request(app.getHttpServer())
|
|
.post('/api/hunts')
|
|
.expect(201);
|
|
|
|
expect(typeof secondHunt.body.id).toBe('string');
|
|
expect(secondHunt.body.id).not.toBe(firstHunt.body.id);
|
|
|
|
// Restore the demo character to its original location so the suite
|
|
// stays safely re-runnable.
|
|
if (originLocationKey !== 'burned-road') {
|
|
const returnConnection = atBurnedRoad.connections.find(
|
|
(connection: { targetLocation: { id: string } }) =>
|
|
connection.targetLocation.id === originLocationId,
|
|
);
|
|
expect(returnConnection).toBeDefined();
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/travel')
|
|
.send({ targetLocationId: originLocationId })
|
|
.expect(201);
|
|
|
|
await pollUntilTravelCompletes(
|
|
app,
|
|
returnConnection.travelDurationSeconds,
|
|
);
|
|
}
|
|
}, 30_000);
|
|
|
|
it('sells goods to Borin in Graufurt for Silver and reputation, and frees bag capacity', async () => {
|
|
// Slice 0.8 §14: the merchant is reachable, trading is server-priced and
|
|
// atomic, and handing goods over frees carrying capacity.
|
|
interface LocationBody {
|
|
id: string;
|
|
key: string;
|
|
connections: Array<{
|
|
targetLocation: { id: string; key: string };
|
|
travelDurationSeconds: number;
|
|
}>;
|
|
}
|
|
interface Offer {
|
|
itemKey: string;
|
|
quantityCarried: number;
|
|
silverPerStep: number;
|
|
}
|
|
|
|
const origin = await request(app.getHttpServer())
|
|
.get('/api/world/current-location')
|
|
.expect(200);
|
|
const originLocation = origin.body as LocationBody;
|
|
|
|
if (originLocation.key !== 'south-gate') {
|
|
const toGate = originLocation.connections.find(
|
|
(connection) => connection.targetLocation.key === 'south-gate',
|
|
);
|
|
expect(toGate).toBeDefined();
|
|
|
|
await request(app.getHttpServer())
|
|
.post('/api/travel')
|
|
.send({ targetLocationId: toGate!.targetLocation.id })
|
|
.expect(201);
|
|
await pollUntilTravelCompletes(app, toGate!.travelDurationSeconds);
|
|
}
|
|
|
|
const interaction = await request(app.getHttpServer())
|
|
.get('/api/npcs/borin-quartermaster/interaction')
|
|
.expect(200);
|
|
const npcBody = interaction.body as {
|
|
npc: { key: string; name: string };
|
|
dialogue: { text: string } | null;
|
|
availableActions: Array<{ type: string }>;
|
|
};
|
|
|
|
expect(npcBody.npc).toMatchObject({
|
|
key: 'borin-quartermaster',
|
|
name: 'Borin',
|
|
});
|
|
// One person, several jobs -- the composition model (NPC spec §2).
|
|
expect(npcBody.availableActions.map((action) => action.type)).toEqual(
|
|
expect.arrayContaining(['TALK', 'OPEN_SHOP', 'OPEN_EXCHANGE']),
|
|
);
|
|
expect(typeof npcBody.dialogue?.text).toBe('string');
|
|
|
|
const view = await request(app.getHttpServer())
|
|
.get('/api/merchants/borin-quartermaster/trade-in')
|
|
.expect(200);
|
|
const offers = (view.body as { offers: Offer[] }).offers;
|
|
|
|
// All four Burned Road trade goods are accepted (slice §5).
|
|
expect(offers.map((offer) => offer.itemKey).sort()).toEqual([
|
|
'ash-pelt',
|
|
'bandit-insignia',
|
|
'charred-raider-insignia',
|
|
'tough-hide',
|
|
]);
|
|
|
|
// An item this merchant does not take is refused, whatever the client
|
|
// claims (slice §13).
|
|
await request(app.getHttpServer())
|
|
.post('/api/merchants/borin-quartermaster/trade-in')
|
|
.send({ items: [{ itemKey: 'worn-short-sword', quantity: 1 }] })
|
|
.expect(409);
|
|
|
|
const carried = offers.find((offer) => offer.quantityCarried > 0);
|
|
if (!carried) {
|
|
// Nothing to sell on this run. Everything above is still covered; the
|
|
// trade itself is exercised whenever a hunt has produced goods.
|
|
return;
|
|
}
|
|
|
|
const before = await request(app.getHttpServer())
|
|
.get('/api/characters/me')
|
|
.expect(200);
|
|
const silverBefore = (before.body as { silver: number }).silver;
|
|
|
|
// More than is carried must be refused outright, leaving Silver alone.
|
|
await request(app.getHttpServer())
|
|
.post('/api/merchants/borin-quartermaster/trade-in')
|
|
.send({
|
|
items: [
|
|
{ itemKey: carried.itemKey, quantity: carried.quantityCarried + 1 },
|
|
],
|
|
})
|
|
.expect(409);
|
|
|
|
const unchanged = await request(app.getHttpServer())
|
|
.get('/api/characters/me')
|
|
.expect(200);
|
|
expect((unchanged.body as { silver: number }).silver).toBe(silverBefore);
|
|
|
|
const traded = await request(app.getHttpServer())
|
|
.post('/api/merchants/borin-quartermaster/trade-in')
|
|
.send({ items: [{ itemKey: carried.itemKey, quantity: 1 }] })
|
|
.expect(201);
|
|
const result = traded.body as {
|
|
consumed: Array<{ itemKey: string; quantity: number }>;
|
|
rewards: { silver: number; regionalReputation: number };
|
|
balances: { silver: number };
|
|
capacities: Array<{ category: string }>;
|
|
};
|
|
|
|
expect(result.consumed).toEqual([
|
|
expect.objectContaining({ itemKey: carried.itemKey, quantity: 1 }),
|
|
]);
|
|
expect(result.rewards.silver).toBe(carried.silverPerStep);
|
|
expect(result.balances.silver).toBe(silverBefore + carried.silverPerStep);
|
|
expect(result.rewards.regionalReputation).toBeGreaterThan(0);
|
|
|
|
// Capacity is derived from what is carried, so the trade frees it
|
|
// immediately (slice §11).
|
|
expect(result.capacities.length).toBeGreaterThan(0);
|
|
|
|
const after = await request(app.getHttpServer())
|
|
.get('/api/merchants/borin-quartermaster/trade-in')
|
|
.expect(200);
|
|
const afterOffer = (after.body as { offers: Offer[] }).offers.find(
|
|
(offer) => offer.itemKey === carried.itemKey,
|
|
);
|
|
expect(afterOffer?.quantityCarried).toBe(carried.quantityCarried - 1);
|
|
|
|
if (originLocation.key !== 'south-gate') {
|
|
await request(app.getHttpServer())
|
|
.post('/api/travel')
|
|
.send({ targetLocationId: originLocation.id })
|
|
.expect(201);
|
|
await pollUntilTravelCompletes(app, 30);
|
|
}
|
|
}, 30_000);
|
|
|
|
async function pollUntilTravelCompletes(
|
|
application: INestApplication<App>,
|
|
travelDurationSeconds: number,
|
|
): Promise<{ status: string; targetLocation?: { id: string } }> {
|
|
const deadline = Date.now() + travelDurationSeconds * 1000 + 5_000;
|
|
let body: { status: string; targetLocation?: { id: string } };
|
|
|
|
do {
|
|
const response = await request(application.getHttpServer())
|
|
.get('/api/travel/current')
|
|
.expect(200);
|
|
body = response.body;
|
|
if (body.status === 'COMPLETED') {
|
|
return body;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
} while (Date.now() < deadline);
|
|
|
|
return body;
|
|
}
|
|
});
|
|
});
|