Files
zalbot/tests/unit/foundryReward.test.ts
Kaysser Kayyali e2c92e854f
Some checks failed
tests / Unit tests (Node 22) (push) Failing after 2m13s
Add unit tests for LLM clients, persona loader, and XP/Foundry rewards
Expands the unit test suite from 320 to 380 tests (+60) and adds a
Gitea Actions CI workflow. Closes all six follow-up recommendations
from the test-architecture validation report.

New tests (tests/unit/):
  - ollamaClient.test.ts          — Ollama SDK wrapper, options passthrough
  - litellmClient.test.ts         — OpenAI SDK wrapper, model fallback
  - personaLoader.test.ts         — Zod validation + cache invalidation
  - foundryReward.test.ts         — Tool plugin: lookup, errors, partial grants
  - xpAwarder.test.ts             — Bulk XP awards + per-player skip reasons
  - redisErrorPath.test.ts        — Singleton error handler does not crash
  - messageRouterRunLLMTurn.test.ts — 18 cases for the runtime heart:
    narrative-only path, tool dispatch, filter correction, retry loop
    guard, missed-skill-check heuristic, typing indicator interval,
    LLM error fallback, archive on resolve.

Coverage (line %):
  - harness/litellmClient.ts      0 → 100
  - harness/ollamaClient.ts       0 → 100
  - harness/tools/foundryReward.ts 0 → 100
  - session/xpAwarder.ts          0 → 100
  - persona/loader.ts             0 → 100
  - db/redis.ts                   0 → 100
  - bot/handlers/messageRouter.ts 0 → 39.86 (runLLMTurn now covered)

Tooling:
  - package.json: + test:coverage, test:watch scripts
  - devDep: @vitest/coverage-v8@^3.1.0
  - tests/README.md: conventions, anti-patterns, template map
  - .gitignore: exclude coverage/
  - .gitea/workflows/test.yml: Node 22, npm cache, tsc --noEmit gate

Documentation (from earlier /bmad-document-project run, now committed):
  - docs/index.md
  - docs/project-overview.md
  - docs/architecture.md
  - docs/deployment-guide.md
  - docs/api-contracts.md
  - docs/data-models.md
  - docs/source-tree-analysis.md
  - docs/component-inventory.md
  - docs/development-guide.md
  - _bmad-output/test-artifacts/automate-validation-report.md

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-19 05:59:13 +00:00

206 lines
6.8 KiB
TypeScript

import { vi, describe, it, expect, beforeEach } from 'vitest';
// ── registry mocks ───────────────────────────────────────────────────────────
const { mockGet: mockCharacterGet } = vi.hoisted(() => ({
mockGet: vi.fn(),
}));
vi.mock('../../src/session/characterRegistry.js', () => ({
characterRegistry: { get: mockCharacterGet },
}));
const { mockModifyExperience, mockGiveItem } = vi.hoisted(() => ({
mockModifyExperience: vi.fn(),
mockGiveItem: vi.fn(),
}));
vi.mock('../../src/vtt/foundryClient.js', () => ({
modifyExperience: mockModifyExperience,
giveItem: mockGiveItem,
}));
import { dispatchTool } from '../../src/harness/toolDispatcher.js';
import { mockSession } from '../fixtures/spec.js';
function makeThread() {
return { send: vi.fn().mockResolvedValue({ id: 'msg-1' }) };
}
const playerSession = {
...mockSession,
players: {
'user-1': { discordId: 'user-1', dndName: 'Aelindra' },
},
};
beforeEach(() => {
vi.clearAllMocks();
mockModifyExperience.mockResolvedValue(undefined);
mockGiveItem.mockResolvedValue(undefined);
});
describe('dispatchTool — foundry_reward', () => {
it('awards both XP and item to a registered Foundry-linked player', async () => {
mockCharacterGet.mockResolvedValue({
discordId: 'user-1',
dndName: 'Aelindra',
source: 'foundry',
foundryActorUuid: 'Actor.abc',
});
const result = await dispatchTool(
{
tool: 'foundry_reward',
args: {
player_discord_name: 'Aelindra',
xp_amount: 50,
item_name: 'Potion of Healing',
reason: 'Caught the thief.',
},
},
{ session: playerSession, thread: makeThread() as any },
);
expect(result.systemMessage).toContain('[FOUNDRY REWARD]');
expect(result.systemMessage).toContain('Aelindra');
expect(result.systemMessage).toContain('Potion of Healing');
expect(result.systemMessage).toContain('50 XP');
expect(result.systemMessage).toContain('Caught the thief.');
expect(mockModifyExperience).toHaveBeenCalledWith('Actor.abc', 50);
expect(mockGiveItem).toHaveBeenCalledWith('Actor.abc', 'Potion of Healing');
});
it('matches player name case-insensitively', async () => {
mockCharacterGet.mockResolvedValue({
discordId: 'user-1',
dndName: 'Aelindra',
source: 'foundry',
foundryActorUuid: 'Actor.abc',
});
await dispatchTool(
{
tool: 'foundry_reward',
args: { player_discord_name: 'aelindra', xp_amount: 10, reason: 'good roleplay' },
},
{ session: playerSession, thread: makeThread() as any },
);
expect(mockModifyExperience).toHaveBeenCalledWith('Actor.abc', 10);
});
it('awards only XP when item_name is omitted', async () => {
mockCharacterGet.mockResolvedValue({
discordId: 'user-1', dndName: 'Aelindra', source: 'foundry', foundryActorUuid: 'Actor.abc',
});
await dispatchTool(
{
tool: 'foundry_reward',
args: { player_discord_name: 'Aelindra', xp_amount: 25, reason: 'milestone' },
},
{ session: playerSession, thread: makeThread() as any },
);
expect(mockModifyExperience).toHaveBeenCalledWith('Actor.abc', 25);
expect(mockGiveItem).not.toHaveBeenCalled();
});
it('awards only an item when xp_amount is zero', async () => {
mockCharacterGet.mockResolvedValue({
discordId: 'user-1', dndName: 'Aelindra', source: 'foundry', foundryActorUuid: 'Actor.abc',
});
await dispatchTool(
{
tool: 'foundry_reward',
args: { player_discord_name: 'Aelindra', xp_amount: 0, item_name: 'Gold Piece', reason: 'tip' },
},
{ session: playerSession, thread: makeThread() as any },
);
expect(mockGiveItem).toHaveBeenCalledWith('Actor.abc', 'Gold Piece');
expect(mockModifyExperience).not.toHaveBeenCalled();
});
it('skips XP when xp_amount is missing', async () => {
mockCharacterGet.mockResolvedValue({
discordId: 'user-1', dndName: 'Aelindra', source: 'foundry', foundryActorUuid: 'Actor.abc',
});
await dispatchTool(
{
tool: 'foundry_reward',
args: { player_discord_name: 'Aelindra', item_name: 'Ring', reason: 'find' },
},
{ session: playerSession, thread: makeThread() as any },
);
expect(mockGiveItem).toHaveBeenCalledWith('Actor.abc', 'Ring');
expect(mockModifyExperience).not.toHaveBeenCalled();
});
it('returns a "no player" system message and does not call Foundry when the player is not in the session', async () => {
const result = await dispatchTool(
{
tool: 'foundry_reward',
args: { player_discord_name: 'Nobody', xp_amount: 5, reason: 'typo' },
},
{ session: playerSession, thread: makeThread() as any },
);
expect(result.systemMessage).toContain('No player found matching "Nobody"');
expect(mockCharacterGet).not.toHaveBeenCalled();
expect(mockModifyExperience).not.toHaveBeenCalled();
expect(mockGiveItem).not.toHaveBeenCalled();
});
it('returns a "no character record" message when the player has no Foundry UUID', async () => {
mockCharacterGet.mockResolvedValue({
discordId: 'user-1', dndName: 'Aelindra', source: 'custom', /* no foundryActorUuid */
});
const result = await dispatchTool(
{
tool: 'foundry_reward',
args: { player_discord_name: 'Aelindra', xp_amount: 5, reason: 'try' },
},
{ session: playerSession, thread: makeThread() as any },
);
expect(result.systemMessage).toContain('No character record found for this player');
expect(mockModifyExperience).not.toHaveBeenCalled();
expect(mockGiveItem).not.toHaveBeenCalled();
});
it('returns a "no character record" message when the player has no profile at all', async () => {
mockCharacterGet.mockResolvedValue(null);
const result = await dispatchTool(
{
tool: 'foundry_reward',
args: { player_discord_name: 'Aelindra', xp_amount: 5, reason: 'try' },
},
{ session: playerSession, thread: makeThread() as any },
);
expect(result.systemMessage).toContain('No character record found for this player');
expect(mockModifyExperience).not.toHaveBeenCalled();
});
it('catches errors from characterRegistry and returns the friendly error', async () => {
mockCharacterGet.mockRejectedValue(new Error('redis down'));
const result = await dispatchTool(
{
tool: 'foundry_reward',
args: { player_discord_name: 'Aelindra', xp_amount: 5, reason: 'try' },
},
{ session: playerSession, thread: makeThread() as any },
);
expect(result.systemMessage).toContain('Character records are inaccessible');
expect(mockModifyExperience).not.toHaveBeenCalled();
});
});