// Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { describe, expect, it, vi } from 'vitest'; import type { Constructor } from '@cratis/fundamentals'; import type { IClientArtifactsProvider } from '../artifacts/index.js'; import type { ChronicleConnection } from '../connection/index.js'; import { ConnectionLifecycle } from '../connection/ConnectionLifecycle.js'; import { eventType, getEventTypeFor } from '../events/eventTypeDecorator.js'; import type { EventContext } from '../events/EventContext.js'; import { filterEventsByTag } from '../events/filterEventsByTagDecorator.js'; import { tag } from '../events/tagDecorator.js'; import { reducer } from './reducer.js'; import { Reducers } from './Reducers.js'; const flush = () => new Promise(resolve => setTimeout(resolve, 0)); class ReducersSomeEventHappened { constructor(readonly value: string = '') {} } eventType('d2b2b2b2-2222-4a3c-9d3f-6f2f4a3c9d3f')(ReducersSomeEventHappened); class SomeTaggedReducerState { count = 0; } class SomeTaggedReducer { reducersSomeEventHappened(): SomeTaggedReducerState { return { count: 1 }; } } reducer('some-tagged-reducer', undefined, SomeTaggedReducerState)(SomeTaggedReducer); tag('Analytics', 'Reporting')(SomeTaggedReducer); filterEventsByTag('vip')(SomeTaggedReducer); filterEventsByTag('priority')(SomeTaggedReducer); const receivedContexts: EventContext[] = []; class CapturingReducerState { count = 0; } class CapturingReducer { reducersSomeEventHappened(_event: ReducersSomeEventHappened, current: CapturingReducerState | undefined, context: EventContext): CapturingReducerState { receivedContexts.push(context); return { count: (current?.count ?? 0) + 1 }; } } reducer('capturing-reducer', undefined, CapturingReducerState)(CapturingReducer); /** * A minimal client artifacts provider exposing only the reducer types under test — * Reducers.ts only reads .reducers and .eventTypes off this during discovery. */ function createArtifacts(reducers: Constructor[]): IClientArtifactsProvider { return { eventTypes: [], readModels: [], reactors: [], reducers, seeders: [], constraints: [], projections: [], webhooks: [], eventTypeMigrations: [], globalForHandlers: [] }; } /** * Captures the first message a reducer observation pushes onto its queue (the registration * message), by having the mocked `observe` call itself consume the queue's async iterator. */ function createConnection() { const registrationMessages: unknown[] = []; const observe = vi.fn().mockImplementation(async function* (queue: AsyncIterable) { for await (const message of queue) { registrationMessages.push(message); break; } }); const registerMany = vi.fn().mockResolvedValue({}); const connection = { reducers: { observe }, readModels: { registerMany } } as unknown as ChronicleConnection; return { connection, registrationMessages }; } describe('Reducers', () => { describe('when a reducer produces an inferred model', () => { class NamedState { static readonly readModelId = 'existing-state-id'; count = 0; } class NamedReducer {} reducer('named-reducer', undefined, NamedState)(NamedReducer); it('should register the model under its explicit identifier with a schema from its type', async () => { const { connection } = createConnection(); const registerMany = vi.spyOn(connection.readModels, 'registerMany'); const reducers = new Reducers(createArtifacts([NamedReducer]), connection, 'store', 'Default', new ConnectionLifecycle(), 'sink'); await reducers.register(); const definition = registerMany.mock.calls[0][0].ReadModels[0]; expect(definition.Type.Identifier).toBe('existing-state-id'); expect(JSON.parse(definition.Schema).properties.count.type).toBe('number'); }); }); describe('when registering an active reducer (the default)', () => { class SomeReducer {} reducer('some-active-reducer')(SomeReducer); it('should send a registration message with IsActive true', async () => { const { connection, registrationMessages } = createConnection(); const reducers = new Reducers(createArtifacts([SomeReducer]), connection, 'my-event-store', 'my-namespace', new ConnectionLifecycle(), 'default-sink'); await reducers.register(); await flush(); expect(registrationMessages).toHaveLength(1); const message = registrationMessages[0] as { Content: { Value0: { Reducer: { IsActive: boolean } } } }; expect(message.Content.Value0.Reducer.IsActive).toBe(true); }); }); describe('when registering a passive (isActive: false) reducer', () => { class SomePassiveReducer {} reducer('some-passive-reducer', undefined, undefined, false)(SomePassiveReducer); it('should send a registration message with IsActive false', async () => { const { connection, registrationMessages } = createConnection(); const reducers = new Reducers(createArtifacts([SomePassiveReducer]), connection, 'my-event-store', 'my-namespace', new ConnectionLifecycle(), 'default-sink'); await reducers.register(); await flush(); expect(registrationMessages).toHaveLength(1); const message = registrationMessages[0] as { Content: { Value0: { Reducer: { IsActive: boolean } } } }; expect(message.Content.Value0.Reducer.IsActive).toBe(false); }); }); describe('when registering a reducer that is tagged and filters by tag', () => { type TaggedRegistration = { Content: { Value0: { Reducer: { Tags: string[]; Filters: { FilterTags: string[] } } } }; }; const register = async () => { const { connection, registrationMessages } = createConnection(); const reducers = new Reducers(createArtifacts([SomeTaggedReducer]), connection, 'my-event-store', 'my-namespace', new ConnectionLifecycle(), 'default-sink'); await reducers.register(); await flush(); return (registrationMessages[0] as TaggedRegistration).Content.Value0.Reducer; }; it('should carry the tags the reducer is labeled with', async () => { expect((await register()).Tags).toEqual(['Analytics', 'Reporting']); }); it('should carry every tag it filters events by', async () => { expect((await register()).Filters.FilterTags).toEqual(['vip', 'priority']); }); }); describe('when registering a reducer that is neither tagged nor filtered', () => { class SomeUntaggedReducer {} reducer('some-untagged-reducer')(SomeUntaggedReducer); it('should send empty tag lists rather than omitting them', async () => { const { connection, registrationMessages } = createConnection(); const reducers = new Reducers(createArtifacts([SomeUntaggedReducer]), connection, 'my-event-store', 'my-namespace', new ConnectionLifecycle(), 'default-sink'); await reducers.register(); await flush(); const message = registrationMessages[0] as { Content: { Value0: { Reducer: { Tags: string[]; Filters: { FilterTags: string[] } } } }; }; expect(message.Content.Value0.Reducer.Tags).toEqual([]); expect(message.Content.Value0.Reducer.Filters.FilterTags).toEqual([]); }); }); });