import { HttpException } from '@nestjs/common'; import { DataSource, EntityManager, EntityTarget } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; import { CharacterReputation } from './entities/character-reputation.entity'; import { ReputationFaction } from './entities/reputation-faction.entity'; import { ReputationDomainError } from './reputation.errors'; import { ReputationService } from './reputation.service'; const FACTION_ID = '80000000-0000-4000-8000-000000000001'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; interface State { characters: Character[]; factions: ReputationFaction[]; characterReputation: CharacterReputation[]; } class FakeRepository { constructor( private readonly rows: T[], private readonly prefix: string, private readonly inTransaction: boolean, ) {} findOne(options: { where: Partial; lock?: { mode: string }; }): Promise { if (options.lock && !this.inTransaction) { throw new Error('Pessimistic locks require a transaction'); } return Promise.resolve( this.rows.find((row) => this.matches(row, options.where)) ?? null, ); } findOneBy(where: Partial): Promise { return Promise.resolve( this.rows.find((row) => this.matches(row, where)) ?? null, ); } find(options: { where?: Partial } = {}): Promise { return Promise.resolve( options.where ? this.rows.filter((row) => this.matches(row, options.where!)) : [...this.rows], ); } create(values: Partial): T { return { ...values } as T; } save(entity: T): Promise { if (!entity.id) { entity.id = `${this.prefix}-${this.rows.length + 1}`; } const index = this.rows.findIndex((row) => row.id === entity.id); if (index === -1) { this.rows.push(entity); } else { this.rows[index] = entity; } return Promise.resolve(entity); } private matches(row: T, where: Partial): boolean { return Object.entries(where).every( ([key, value]) => row[key as keyof T] === value, ); } } class FakeDataSource { constructor(public state: State) {} getRepository(target: EntityTarget) { return this.repoFor(target, false); } async transaction( work: (manager: EntityManager) => Promise, ): Promise { return work({ getRepository: (target: EntityTarget) => this.repoFor(target, true), } as unknown as EntityManager); } private repoFor( target: EntityTarget, inTransaction: boolean, ) { if (target === Character) return new FakeRepository( this.state.characters, 'character', inTransaction, ) as never; if (target === ReputationFaction) return new FakeRepository( this.state.factions, 'faction', inTransaction, ) as never; if (target === CharacterReputation) return new FakeRepository( this.state.characterReputation, 'char-rep', inTransaction, ) as never; throw new Error('Unsupported repository'); } } function character(overrides: Partial = {}): Character { return { id: CHARACTER_ID, ...overrides } as Character; } function faction( overrides: Partial = {}, ): ReputationFaction { return { id: FACTION_ID, key: 'border-guard', name: 'Grenzwacht', description: '', regionKey: 'ashen-fields', enabled: true, ...overrides, } as ReputationFaction; } function createState(overrides: Partial = {}): State { return { characters: [character()], factions: [faction()], characterReputation: [], ...overrides, }; } function createService(state: State) { const dataSource = new FakeDataSource(state); return { service: new ReputationService(dataSource as unknown as DataSource), state, }; } async function expectReputationDomainError( promise: Promise, code: string, ): Promise { let error: unknown; try { await promise; } catch (cause) { error = cause; } expect(error).toBeInstanceOf(ReputationDomainError); if (!(error instanceof ReputationDomainError)) { throw new Error('Expected ReputationDomainError'); } expect(error.code).toBe(code); } /** * `characterNotFound` is re-exported from `travel.errors` and returns a * `TravelDomainError`, not a `ReputationDomainError` -- so this asserts on * the thrown HttpException's response shape (its `code`/status) rather than * its class, matching this codebase's convention for shared error helpers. */ async function expectHttpErrorWithCode( promise: Promise, code: string, status: number, ): Promise { let error: unknown; try { await promise; } catch (cause) { error = cause; } expect(error).toBeInstanceOf(HttpException); if (!(error instanceof HttpException)) { throw new Error('Expected HttpException'); } expect(error.getStatus()).toBe(status); expect(error.getResponse()).toMatchObject({ code }); } describe('ReputationService', () => { describe('grantReputation', () => { it('creates a reputation row starting from 0 on the first grant', async () => { const { service, state } = createService(createState()); const result = await service.grantReputation( CHARACTER_ID, 'border-guard', 40, ); expect(result).toEqual({ factionKey: 'border-guard', previousReputation: 0, newReputation: 40, previousRank: 'STRANGER', newRank: 'STRANGER', rankChanged: false, }); expect(state.characterReputation).toHaveLength(1); expect(state.characterReputation[0].reputation).toBe(40); }); it('accumulates onto an existing reputation row', async () => { const state = createState({ characterReputation: [ { id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 90, } as CharacterReputation, ], }); const { service } = createService(state); const result = await service.grantReputation( CHARACTER_ID, 'border-guard', 20, ); expect(result.previousReputation).toBe(90); expect(result.newReputation).toBe(110); }); it('reports rankChanged when a grant crosses a threshold', async () => { const state = createState({ characterReputation: [ { id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 90, } as CharacterReputation, ], }); const { service } = createService(state); const result = await service.grantReputation( CHARACTER_ID, 'border-guard', 20, ); expect(result.previousRank).toBe('STRANGER'); expect(result.newRank).toBe('TOLERATED'); expect(result.rankChanged).toBe(true); }); it('reports rankChanged: false when a grant stays within the same rank', async () => { const state = createState({ characterReputation: [ { id: 'existing', characterId: CHARACTER_ID, factionId: FACTION_ID, reputation: 120, } as CharacterReputation, ], }); const { service } = createService(state); const result = await service.grantReputation( CHARACTER_ID, 'border-guard', 10, ); expect(result.rankChanged).toBe(false); }); it('rejects an unknown faction key', async () => { const { service } = createService(createState()); await expectReputationDomainError( service.grantReputation(CHARACTER_ID, 'unknown-faction', 10), 'REPUTATION_FACTION_NOT_FOUND', ); }); it('rejects an unknown character', async () => { const { service } = createService(createState()); await expectHttpErrorWithCode( service.grantReputation('unknown-character', 'border-guard', 10), 'CHARACTER_NOT_FOUND', 404, ); }); }); describe('getCharacterReputation', () => { it('returns 0 Reputation / Stranger for a faction the character has never interacted with', async () => { const { service } = createService(createState()); const result = await service.getCharacterReputation(CHARACTER_ID); expect(result).toEqual([ { factionKey: 'border-guard', factionName: 'Grenzwacht', reputation: 0, rank: 'STRANGER', rankLabel: 'Fremder', nextThreshold: 100, }, ]); }); it('reflects a persisted grant', async () => { const state = createState(); const { service } = createService(state); await service.grantReputation(CHARACTER_ID, 'border-guard', 300); const result = await service.getCharacterReputation(CHARACTER_ID); expect(result[0]).toEqual({ factionKey: 'border-guard', factionName: 'Grenzwacht', reputation: 300, rank: 'KNOWN', rankLabel: 'Bekannt', nextThreshold: 500, }); }); it('omits a disabled faction', async () => { const state = createState({ factions: [faction({ enabled: false })] }); const { service } = createService(state); const result = await service.getCharacterReputation(CHARACTER_ID); expect(result).toEqual([]); }); }); });