import { CommandBus, CqrsModule, IQueryHandler, QueryBus, QueryHandler, } from '@nestjs/cqrs'; import { Test } from '@nestjs/testing'; import { BusinessRuleError, NotFoundError, ValidationError, } from '@task-stream/api/shared/domain'; import { IssueReadModel, StatusCategory, } from '@task-stream/api/tracker/domain'; import { MyDayRecord, MyDaysRepository } from '../ports/my-days.repository'; import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query'; import { AddToMyDayCommand, GetMyDayQuery, MAX_MY_DAY, MY_DAY, MyDay, RemoveFromMyDayCommand, } from './my-day'; class InMemoryDays extends MyDaysRepository { rows: MyDayRecord[] = []; async find(userId: string, day: string) { return this.rows.find((r) => r.userId === userId && r.day === day) ?? null; } async latestBefore(userId: string, day: string) { return ( this.rows .filter((r) => r.userId === userId && r.day < day) .sort((a, b) => b.day.localeCompare(a.day))[0] ?? null ); } async save(record: MyDayRecord) { this.rows = [ ...this.rows.filter( (r) => !(r.userId === record.userId && r.day === record.day), ), record, ]; } } /** Ana reads i0…i11; i3 is done. */ const issues = new Map(); @QueryHandler(GetIssuesByIdsQuery) class FakeIssuesByIds implements IQueryHandler { async execute(query: GetIssuesByIdsQuery) { return query.viewerId === 'ana' ? query.ids.flatMap((id) => issues.get(id) ?? []) : []; } } const day = (offset: number) => new Date(Date.now() + offset * 86_400_000).toISOString().slice(0, 10); async function setup() { issues.clear(); for (let i = 0; i < 12; i++) issues.set(`i${i}`, { id: `i${i}`, key: `WEB-${i}`, statusCategory: i === 3 ? StatusCategory.DONE : StatusCategory.TODO, trashedAt: null, } as IssueReadModel); const rows = new InMemoryDays(); const moduleRef = await Test.createTestingModule({ imports: [CqrsModule.forRoot()], providers: [ ...MY_DAY, FakeIssuesByIds, { provide: MyDaysRepository, useValue: rows }, ], }).compile(); await moduleRef.init(); const commands = moduleRef.get(CommandBus); const queries = moduleRef.get(QueryBus); return { rows, commands, keys: async (d: string) => { const myDay: MyDay = await queries.execute(new GetMyDayQuery('ana', d)); return { issues: myDay.issues.map((i) => i.key), leftover: myDay.leftover?.issues.map((i) => i.key) ?? null, }; }, }; } describe('Mi día', () => { it('keeps the tickets picked for today, in order, once each', async () => { const { commands, keys } = await setup(); await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i2'])); await commands.execute(new AddToMyDayCommand('ana', day(0), ['i2', 'i3'])); await commands.execute(new RemoveFromMyDayCommand('ana', day(0), 'i1')); expect((await keys(day(0))).issues).toEqual(['WEB-2', 'WEB-3']); }); it(`takes ${MAX_MY_DAY} at most, only readable tickets, only around today`, async () => { const { commands } = await setup(); const many = Array.from({ length: MAX_MY_DAY + 1 }, (_, i) => `i${i}`); await expect( commands.execute(new AddToMyDayCommand('ana', day(0), many)), ).rejects.toBeInstanceOf(BusinessRuleError); await expect( commands.execute(new AddToMyDayCommand('ana', day(0), ['nope'])), ).rejects.toBeInstanceOf(NotFoundError); await expect( commands.execute(new AddToMyDayCommand('ana', day(5), ['i1'])), ).rejects.toBeInstanceOf(ValidationError); await expect( commands.execute(new AddToMyDayCommand('ana', '20-10-2026', ['i1'])), ).rejects.toBeInstanceOf(ValidationError); }); it('offers what was left unfinished on the last day, but not the done ones', async () => { const { commands, rows, keys } = await setup(); rows.rows.push({ userId: 'ana', day: day(-1), issueIds: ['i1', 'i3', 'i4'], updatedAt: '', }); expect(await keys(day(0))).toEqual({ issues: [], leftover: ['WEB-1', 'WEB-4'], }); // Carried over: no longer offered. await commands.execute(new AddToMyDayCommand('ana', day(0), ['i1', 'i4'])); expect(await keys(day(0))).toEqual({ issues: ['WEB-1', 'WEB-4'], leftover: null, }); }); });