test(shops): make the gate's guards fail when they are removed

This commit is contained in:
Bastian Wagner
2026-08-22 18:52:23 +02:00
parent 7a5e800a18
commit 80be829309

View File

@@ -23,22 +23,44 @@ interface Fixture {
silver?: number; silver?: number;
shopEnabled?: boolean; shopEnabled?: boolean;
hasShop?: boolean; hasShop?: boolean;
/** The fake engine's blanket answer for every condition it is handed. */
offerUnlocked?: boolean; offerUnlocked?: boolean;
/**
* Condition types the fake engine says do *not* hold, whatever
* `offerUnlocked` says. Lets one list mix a met requirement with an unmet
* one, which is what distinguishes "reputation is short" from "something
* else is".
*/
unmetConditionTypes?: GameConditionType[];
offerRepeatable?: boolean; offerRepeatable?: boolean;
offerQuantity?: number; offerQuantity?: number;
ownedPotions?: number | null; ownedPotions?: number | null;
/** Replaces the single item offer with one that sells the trophy pouch. */ /** Replaces the single item offer with one that sells the trophy pouch. */
bagOffer?: boolean; bagOffer?: boolean;
/** Bag offers are one-off by nature; a test can lift that to reach the
* bag-specific quantity rule underneath it. */
bagRepeatable?: boolean;
/** Whether the character already holds the bag the offer sells. */ /** Whether the character already holds the bag the offer sells. */
ownsBag?: boolean; ownsBag?: boolean;
/** Conditions on the offer, so a test can gate it on reputation. */ /** Conditions on the offer, so a test can gate it on reputation. */
conditions?: GameCondition[]; conditions?: GameCondition[];
/** The alternative way in (slice §7). */ /** The alternative way in (slice §7). */
bypassConditions?: GameCondition[]; bypassConditions?: GameCondition[];
/** Which of `conditions` / `bypassConditions` the fake engine says hold. */ /** Whether the fake engine says the bypass list holds. */
bypassPasses?: boolean; bypassPasses?: boolean;
} }
/**
* What the fake engine reports as measured, per condition type.
*
* Only conditions with a scale have one: a flag is set or it is not, and the
* real engine reports `actual: null` for it.
*/
const FAKE_MEASURED_VALUE: Partial<Record<GameConditionType, number>> = {
[GameConditionType.REGION_REPUTATION]: 14,
[GameConditionType.WORLD_RENOWN]: 14,
};
function createWorld(fixture: Fixture = {}) { function createWorld(fixture: Fixture = {}) {
const character = { const character = {
id: CHARACTER_ID, id: CHARACTER_ID,
@@ -76,7 +98,7 @@ function createWorld(fixture: Fixture = {}) {
currencyType: 'SILVER', currencyType: 'SILVER',
price: 40, price: 40,
quantity: 1, quantity: 1,
repeatable: false, repeatable: fixture.bagRepeatable ?? false,
sortOrder: 1, sortOrder: 1,
conditions: fixture.conditions ?? [], conditions: fixture.conditions ?? [],
bypassConditions: fixture.bypassConditions ?? [], bypassConditions: fixture.bypassConditions ?? [],
@@ -184,25 +206,40 @@ function createWorld(fixture: Fixture = {}) {
run(manager), run(manager),
} as unknown as DataSource; } as unknown as DataSource;
/** One condition's verdict, so `evaluate` and `describe` cannot disagree. */
const conditionHolds = (condition: GameCondition) => {
if (fixture.unmetConditionTypes?.includes(condition.type)) {
return false;
}
return fixture.offerUnlocked ?? true;
};
const conditions = { const conditions = {
evaluate: jest.fn( evaluate: jest.fn(
(_context: unknown, list: GameCondition[] | undefined) => { (_context: unknown, list: GameCondition[] | undefined) => {
// Mirrors the real engine (game-condition.service.ts): an AND over
// nothing holds, so an empty list is not a gate at all. Keeping this
// faithful is what makes the service's `bypass.length > 0` guard
// load-bearing rather than decorative.
if (!list || list.length === 0) {
return Promise.resolve(true);
}
// The fixture distinguishes the two lists by identity, so a test can say // The fixture distinguishes the two lists by identity, so a test can say
// "the gate is shut but the bypass is open". // "the gate is shut but the bypass is open".
if (list === fixture.bypassConditions) { if (list === fixture.bypassConditions) {
return Promise.resolve(fixture.bypassPasses ?? false); return Promise.resolve(fixture.bypassPasses ?? false);
} }
// `offerUnlocked` is the fixture's switch for the offer's own gate, // Answered from the list's own contents, so handing the gate the wrong
// whatever the conditions expressing it happen to be. // list cannot pass unnoticed.
return Promise.resolve(fixture.offerUnlocked ?? true); return Promise.resolve(list.every(conditionHolds));
}, },
), ),
describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) => describe: jest.fn((_context: unknown, list: GameCondition[] | undefined) =>
Promise.resolve( Promise.resolve(
(list ?? []).map((condition) => ({ (list ?? []).map((condition) => ({
condition, condition,
met: fixture.offerUnlocked ?? true, met: conditionHolds(condition),
actual: 14, actual: FAKE_MEASURED_VALUE[condition.type] ?? null,
})), })),
), ),
), ),
@@ -291,8 +328,19 @@ describe('ShopService', () => {
it('refuses a locked offer even when the request asks for it directly', async () => { 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 // 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 // protection (NPC spec §33: "gesperrtes Item kann nicht direkt über API
// gekauft werden"). // gekauft werden"). Gated on a flag rather than reputation, because a
const world = createWorld({ offerUnlocked: false, silver: 1000 }); // non-reputation gate is what keeps the generic code in play.
const world = createWorld({
offerUnlocked: false,
silver: 1000,
conditions: [
{
type: GameConditionType.FLAG_SET,
key: 'vouched-for-by-the-warden',
value: true,
},
],
});
await expect( await expect(
world.service.purchase( world.service.purchase(
@@ -304,6 +352,37 @@ describe('ShopService', () => {
).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' }); ).rejects.toMatchObject({ code: 'SHOP_OFFER_LOCKED' });
expect(world.character.silver).toBe(1000); expect(world.character.silver).toBe(1000);
expect(world.grantedItems).toHaveLength(0);
});
it('keeps an offer shut when its bypass list is empty', async () => {
// An empty condition list is an AND over nothing, so it *holds*. Without
// the service's emptiness guard, every offer in the database -- whose
// `bypass_conditions` default to '[]' -- would open (slice §7).
const world = createWorld({
offerUnlocked: false,
silver: 1000,
conditions: [
{
type: GameConditionType.FLAG_SET,
key: 'vouched-for-by-the-warden',
value: true,
},
],
bypassConditions: [],
});
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);
expect(world.grantedItems).toHaveLength(0);
}); });
it('refuses an item the shop does not stock', async () => { it('refuses an item the shop does not stock', async () => {
@@ -312,6 +391,9 @@ describe('ShopService', () => {
await expect( await expect(
world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1), world.service.purchase(CHARACTER_ID, MERCHANT_KEY, 'ash-blade', 1),
).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' }); ).rejects.toMatchObject({ code: 'SHOP_OFFER_NOT_FOUND' });
expect(world.character.silver).toBe(100);
expect(world.grantedItems).toHaveLength(0);
}); });
it('refuses a non-positive quantity', async () => { it('refuses a non-positive quantity', async () => {
@@ -325,6 +407,9 @@ describe('ShopService', () => {
0, 0,
), ),
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
expect(world.character.silver).toBe(100);
expect(world.grantedItems).toHaveLength(0);
}); });
it('refuses to buy a one-off offer more than once in a request', async () => { it('refuses to buy a one-off offer more than once in a request', async () => {
@@ -338,6 +423,9 @@ describe('ShopService', () => {
2, 2,
), ),
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' }); ).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
expect(world.character.silver).toBe(100);
expect(world.grantedItems).toHaveLength(0);
}); });
it('refuses a closed shop', async () => { it('refuses a closed shop', async () => {
@@ -439,6 +527,30 @@ describe('ShopService', () => {
).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' }); ).rejects.toMatchObject({ code: 'SHOP_BAG_ALREADY_OWNED' });
expect(world.character.silver).toBe(100); expect(world.character.silver).toBe(100);
expect(world.grantedBags).toHaveLength(0);
});
it('refuses to sell two of the same bag in one request', async () => {
// A bag is one object, not a stack: the second grants nothing. The offer
// is made repeatable here so the bag rule is what answers, not the
// one-off rule that normally sits in front of it.
const world = createWorld({
bagOffer: true,
bagRepeatable: true,
silver: 1000,
});
await expect(
world.service.purchase(
CHARACTER_ID,
MERCHANT_KEY,
'basic-trophy-pouch',
2,
),
).rejects.toMatchObject({ code: 'SHOP_INVALID_QUANTITY' });
expect(world.character.silver).toBe(1000);
expect(world.grantedBags).toHaveLength(0);
}); });
it('names reputation as the reason when a reputation gate is what blocks', async () => { it('names reputation as the reason when a reputation gate is what blocks', async () => {
@@ -463,6 +575,44 @@ describe('ShopService', () => {
1, 1,
), ),
).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' }); ).rejects.toMatchObject({ code: 'MERCHANT_REPUTATION_TOO_LOW' });
expect(world.character.silver).toBe(1000);
expect(world.grantedItems).toHaveLength(0);
});
it('does not blame reputation when reputation is not what is short', async () => {
// The standing is already earned; a quest flag is the thing missing.
// Telling this player to go and earn reputation would send them after
// something they already have (slice §6).
const world = createWorld({
silver: 1000,
unmetConditionTypes: [GameConditionType.FLAG_SET],
conditions: [
{
type: GameConditionType.REGION_REPUTATION,
key: 'border-guard',
operator: ComparisonOperator.GTE,
value: 10,
},
{
type: GameConditionType.FLAG_SET,
key: 'vouched-for-by-the-warden',
value: true,
},
],
});
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);
expect(world.grantedItems).toHaveLength(0);
}); });
it('opens an offer whose bypass holds even though its conditions do not', async () => { it('opens an offer whose bypass holds even though its conditions do not', async () => {
@@ -515,6 +665,7 @@ describe('ShopService', () => {
), ),
).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' }); ).rejects.toMatchObject({ code: 'SHOP_INSUFFICIENT_SILVER' });
expect(world.character.silver).toBe(10);
expect(world.grantedBags).toHaveLength(0); expect(world.grantedBags).toHaveLength(0);
}); });
}); });