docs
This commit is contained in:
264
apps/api/src/shops/shop.service.spec.ts
Normal file
264
apps/api/src/shops/shop.service.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { DataSource, EntityManager } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import { GameConditionService } from '../conditions/game-condition.service';
|
||||
import { CharacterItem } from '../items/entities/character-item.entity';
|
||||
import { NpcService } from '../npcs/npc.service';
|
||||
import { NpcShop } from './entities/npc-shop.entity';
|
||||
import { ShopOffer } from './entities/shop-offer.entity';
|
||||
import { ShopService } from './shop.service';
|
||||
|
||||
const CHARACTER_ID = 'character-1';
|
||||
const MERCHANT_KEY = 'borin-quartermaster';
|
||||
|
||||
interface Fixture {
|
||||
silver?: number;
|
||||
shopEnabled?: boolean;
|
||||
hasShop?: boolean;
|
||||
offerUnlocked?: boolean;
|
||||
offerRepeatable?: boolean;
|
||||
offerQuantity?: number;
|
||||
ownedPotions?: number | null;
|
||||
}
|
||||
|
||||
function createWorld(fixture: Fixture = {}) {
|
||||
const character = {
|
||||
id: CHARACTER_ID,
|
||||
silver: fixture.silver ?? 100,
|
||||
} as Character;
|
||||
|
||||
const grantedItems: Array<Record<string, unknown>> = [];
|
||||
const owned =
|
||||
fixture.ownedPotions === undefined || fixture.ownedPotions === null
|
||||
? null
|
||||
: {
|
||||
characterId: CHARACTER_ID,
|
||||
itemDefinitionId: 'item-potion',
|
||||
quantity: fixture.ownedPotions,
|
||||
};
|
||||
|
||||
const offers = [
|
||||
{
|
||||
id: 'offer-1',
|
||||
shopId: 'shop-1',
|
||||
itemDefinitionId: 'item-potion',
|
||||
currencyType: 'SILVER',
|
||||
price: 12,
|
||||
quantity: fixture.offerQuantity ?? 1,
|
||||
repeatable: fixture.offerRepeatable ?? true,
|
||||
sortOrder: 1,
|
||||
conditions: [],
|
||||
enabled: true,
|
||||
itemDefinition: {
|
||||
id: 'item-potion',
|
||||
key: 'small-healing-potion',
|
||||
name: 'Small Healing Potion',
|
||||
description: 'A bitter draught.',
|
||||
iconPath: '/images/items/potion.png',
|
||||
},
|
||||
},
|
||||
] as unknown as ShopOffer[];
|
||||
|
||||
const repositories = (entity: unknown) => {
|
||||
if (entity === Character) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(character),
|
||||
findOneBy: () => Promise.resolve(character),
|
||||
save: (row: Character) => Promise.resolve(row),
|
||||
};
|
||||
}
|
||||
if (entity === NpcShop) {
|
||||
return {
|
||||
findOneBy: () =>
|
||||
Promise.resolve(
|
||||
(fixture.hasShop ?? true)
|
||||
? {
|
||||
id: 'shop-1',
|
||||
key: 'borin-supplies',
|
||||
name: "Quartermaster's Supplies",
|
||||
enabled: fixture.shopEnabled ?? true,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (entity === ShopOffer) {
|
||||
return { find: () => Promise.resolve(offers) };
|
||||
}
|
||||
if (entity === CharacterItem) {
|
||||
return {
|
||||
findOne: () => Promise.resolve(owned),
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: async (row: Record<string, unknown>) => {
|
||||
grantedItems.push(row);
|
||||
return row;
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error('Unexpected repository');
|
||||
};
|
||||
|
||||
const manager = { getRepository: repositories } as unknown as EntityManager;
|
||||
const dataSource = {
|
||||
getRepository: repositories,
|
||||
transaction: async <T>(run: (m: EntityManager) => Promise<T>) =>
|
||||
run(manager),
|
||||
} as unknown as DataSource;
|
||||
|
||||
const conditions = {
|
||||
evaluate: jest.fn(() => Promise.resolve(fixture.offerUnlocked ?? true)),
|
||||
} as unknown as GameConditionService;
|
||||
|
||||
const npcs = {
|
||||
requireReachableNpc: jest.fn(() => Promise.resolve({ id: 'npc-1' })),
|
||||
} as unknown as NpcService;
|
||||
|
||||
return {
|
||||
service: new ShopService(dataSource, conditions, npcs),
|
||||
character,
|
||||
grantedItems,
|
||||
owned,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ShopService', () => {
|
||||
it('lists offers with the price the server holds', async () => {
|
||||
const world = createWorld({ silver: 100 });
|
||||
|
||||
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
|
||||
|
||||
expect(view.silver).toBe(100);
|
||||
expect(view.offers[0]).toMatchObject({
|
||||
itemKey: 'small-healing-potion',
|
||||
price: 12,
|
||||
unlocked: true,
|
||||
affordable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks an offer the character cannot afford without hiding it', async () => {
|
||||
const world = createWorld({ silver: 3 });
|
||||
|
||||
const view = await world.service.getShopView(CHARACTER_ID, MERCHANT_KEY);
|
||||
|
||||
expect(view.offers[0]).toMatchObject({ unlocked: true, affordable: false });
|
||||
});
|
||||
|
||||
it('debits Silver and grants the item', async () => {
|
||||
const world = createWorld({ silver: 50 });
|
||||
|
||||
const result = await world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.silverSpent).toBe(24);
|
||||
expect(world.character.silver).toBe(26);
|
||||
expect(world.grantedItems[0]).toMatchObject({ quantity: 2 });
|
||||
});
|
||||
|
||||
it('stacks onto an existing pile rather than starting a second one', async () => {
|
||||
const world = createWorld({ silver: 50, ownedPotions: 3 });
|
||||
|
||||
await world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
);
|
||||
|
||||
expect(world.grantedItems[0]).toMatchObject({ quantity: 4 });
|
||||
});
|
||||
|
||||
it('refuses a purchase the character cannot afford, leaving Silver intact', async () => {
|
||||
const world = createWorld({ silver: 5 });
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
|
||||
|
||||
expect(world.character.silver).toBe(5);
|
||||
expect(world.grantedItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('refuses a locked offer even when the request asks for it directly', async () => {
|
||||
// The gate is enforced server-side, so hiding it in the UI is not the
|
||||
// protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API
|
||||
// gekauft werden").
|
||||
const world = createWorld({ offerUnlocked: false, silver: 1000 });
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
1,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
|
||||
|
||||
expect(world.character.silver).toBe(1000);
|
||||
});
|
||||
|
||||
it('refuses an item the shop does not stock', async () => {
|
||||
const world = createWorld();
|
||||
|
||||
await expect(
|
||||
world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1),
|
||||
).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' });
|
||||
});
|
||||
|
||||
it('refuses a non-positive quantity', async () => {
|
||||
const world = createWorld();
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
0,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
||||
});
|
||||
|
||||
it('refuses to buy a one-off offer more than once in a request', async () => {
|
||||
const world = createWorld({ offerRepeatable: false });
|
||||
|
||||
await expect(
|
||||
world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
2,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
|
||||
});
|
||||
|
||||
it('refuses a closed shop', async () => {
|
||||
const world = createWorld({ shopEnabled: false });
|
||||
|
||||
await expect(
|
||||
world.service.getShopView(CHARACTER_ID, MERCHANT_KEY),
|
||||
).rejects.toMatchObject({ code: 'SHOP_DISABLED' });
|
||||
});
|
||||
|
||||
it('grants the offer bundle size, not the request count', async () => {
|
||||
// An offer that sells three at a time, bought twice, is six items.
|
||||
const world = createWorld({ silver: 100, offerQuantity: 3 });
|
||||
|
||||
const result = await world.service.purchase(
|
||||
CHARACTER_ID,
|
||||
MERCHANT_KEY,
|
||||
'small-healing-potion',
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result.quantity).toBe(6);
|
||||
expect(result.silverSpent).toBe(24);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user