import assert from 'node:assert/strict'; import test from 'node:test'; import { createSend } from '../src/chat'; import { isChannelPointsAffiliateRefusal, MemoryIntentStore, RedemptionManager, type RedemptionApi, type RedemptionReward, type RedemptionStatus, } from '../src/redemptions'; import { FakeChatSender, FakeSimClient } from './helpers'; class MockRedemptionApi implements RedemptionApi { rewards: RedemptionReward[] = []; createRewardCalls = 0; statusUpdates: { rewardId: string; redemptionIds: string[]; status: RedemptionStatus }[] = []; /** Number of times updateRedemptionStatus should throw before succeeding. 0 = never fails. */ failStatusUpdatesRemaining = 0; async findRewardByTitle(title: string): Promise { return this.rewards.find((r) => r.title === title) ?? null; } async createReward(title: string): Promise { this.createRewardCalls += 1; const reward = { id: `reward-${this.createRewardCalls}`, title }; this.rewards.push(reward); return reward; } async updateRedemptionStatus(rewardId: string, redemptionIds: string[], status: RedemptionStatus): Promise { if (this.failStatusUpdatesRemaining > 0) { this.failStatusUpdatesRemaining -= 1; throw new Error('Twitch API unavailable'); } this.statusUpdates.push({ rewardId, redemptionIds, status }); } } function makeManager(overrides: { api?: MockRedemptionApi; sim?: FakeSimClient; sender?: FakeChatSender } = {}) { const api = overrides.api ?? new MockRedemptionApi(); const sim = overrides.sim ?? new FakeSimClient(); const sender = overrides.sender ?? new FakeChatSender(); const store = new MemoryIntentStore(); const errors: string[] = []; const manager = new RedemptionManager({ sim, api, store, send: createSend(sender), rewardTitle: 'Sugar', logError: (message) => errors.push(message), }); return { manager, api, sim, sender, store, errors }; } void test('start() creates the Sugar reward when none exists', async () => { const { manager, api } = makeManager(); const reward = await manager.start(); assert.equal(reward.title, 'Sugar'); assert.equal(api.createRewardCalls, 1); }); void test('start() is idempotent: finds an existing reward by title instead of creating a duplicate', async () => { const api = new MockRedemptionApi(); api.rewards.push({ id: 'existing-reward', title: 'Sugar' }); const { manager } = makeManager({ api }); const reward = await manager.start(); assert.equal(reward.id, 'existing-reward'); assert.equal(api.createRewardCalls, 0); }); void test('a successful redemption calls sim.stimulate with source points and fulfils it', async () => { const { manager, api, sim, sender } = makeManager(); await manager.start(); const status = await manager.handleRedemptionAdd('redemption-1', manager.currentRewardId!, 'fly_fan_42'); assert.equal(status, 'FULFILLED'); assert.deepEqual(sim.stimulateCalls[0], { by: 'fly_fan_42', source: 'points' }); assert.deepEqual(api.statusUpdates, [{ rewardId: manager.currentRewardId, redemptionIds: ['redemption-1'], status: 'FULFILLED' }]); assert.match(sender.sent[0]!, /fly_fan_42/); }); void test('validates a hostile redeemer name before it reaches the sim', async () => { const { manager, sim } = makeManager(); await manager.start(); await manager.handleRedemptionAdd('redemption-1', manager.currentRewardId!, '