fix(combat): lock character before combat row and harden reward-transaction test
Fix a lock-order inversion Task 8 introduced: performAction locked the combat row first and, inside grantVictoryRewards, the character row second -- the opposite order to startCombat (character, then combat), creating a deadlock cycle for two concurrent requests on the same character. performAction now locks the character first via the existing lockCharacter helper, matching startCombat; the later re-lock inside grantVictoryRewards is a no-op within the same transaction. Also strengthen the test that guards the transaction contract for grantVictoryRewards: expect.anything() would have passed even if the data source were handed over instead of the transaction manager, since CombatRewardService has no runtime guard against that substitution. The test now asserts on the captured argument's identity. Verified this is load-bearing by temporarily passing the data source in place of the manager and confirming the test fails. Finally, make the rollback test's unchanged-XP/silver assertions real: the fake grantVictoryRewards now writes through the transaction manager before throwing, so the assertions prove the rollback discarded those writes instead of passing vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -549,7 +549,10 @@ describe('CombatService', () => {
|
||||
});
|
||||
|
||||
it('rejects actions from a character who does not own the combat', async () => {
|
||||
const { service, combatId } = await startedCombat();
|
||||
const state = createState({
|
||||
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
|
||||
});
|
||||
const { service, combatId } = await startedCombat(state);
|
||||
|
||||
await expectCombatDomainError(
|
||||
service.performAction(
|
||||
@@ -724,9 +727,14 @@ describe('CombatService', () => {
|
||||
expect(result.status).toBe(CombatStatus.WON);
|
||||
expect(result.rewards).toEqual({ experience: 8, silver: 6, items: [] });
|
||||
expect(rewards.grantVictoryRewards).toHaveBeenCalledTimes(1);
|
||||
// The reward service receives the transaction manager, not the data source.
|
||||
// The reward service must receive the transaction manager, not the data
|
||||
// source: `expect.anything()` would pass even if the code handed over
|
||||
// `this.dataSource`, so assert on the captured argument's identity.
|
||||
const passedManager = (rewards.grantVictoryRewards as jest.Mock).mock
|
||||
.calls[0][0];
|
||||
expect(passedManager).not.toBe(dataSource);
|
||||
expect(rewards.grantVictoryRewards).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
passedManager,
|
||||
expect.objectContaining({ id: 'combat-1', status: CombatStatus.WON }),
|
||||
);
|
||||
});
|
||||
@@ -854,7 +862,22 @@ describe('CombatService', () => {
|
||||
new CombatEngineService(),
|
||||
new CharacterCombatStatsService(),
|
||||
fakeRewardService({
|
||||
grantVictoryRewards: jest.fn().mockRejectedValue(new Error('reward persistence failed')),
|
||||
// Genuinely write XP/silver through the transaction's manager
|
||||
// before failing, so the assertions below prove the rollback
|
||||
// discards those writes rather than passing vacuously because
|
||||
// nothing was ever written.
|
||||
grantVictoryRewards: jest.fn().mockImplementation(async (manager: EntityManager) => {
|
||||
const characters = manager.getRepository(Character);
|
||||
const combatCharacter = await characters.findOneBy({
|
||||
id: CHARACTER_ID,
|
||||
});
|
||||
if (combatCharacter) {
|
||||
combatCharacter.experience += 8;
|
||||
combatCharacter.silver += 6;
|
||||
await characters.save(combatCharacter);
|
||||
}
|
||||
throw new Error('reward persistence failed');
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user