diff --git a/packages/jobs/src/registry/registry.ts b/packages/jobs/src/registry/registry.ts index f3b8e2d..947a28f 100644 --- a/packages/jobs/src/registry/registry.ts +++ b/packages/jobs/src/registry/registry.ts @@ -148,10 +148,7 @@ export function toRuntimeRegistry< string, StoredContinuousDefinition >, - debounce: registry.debounce as Record< - string, - StoredDebounceDefinition - >, + debounce: registry.debounce as Record, workerPool: registry.workerPool as Record< string, StoredWorkerPoolDefinition diff --git a/packages/jobs/test/handlers/continuous.test.ts b/packages/jobs/test/handlers/continuous.test.ts index 797c35b..aafcc33 100644 --- a/packages/jobs/test/handlers/continuous.test.ts +++ b/packages/jobs/test/handlers/continuous.test.ts @@ -1,21 +1,15 @@ // packages/jobs/test/handlers/continuous.test.ts import { describe, it, expect, beforeEach } from "vitest"; -import { Effect, Layer, Schema, Duration } from "effect"; -import { createTestRuntime, NoopTrackerLayer } from "@durable-effect/core"; -import { - ContinuousHandler, - ContinuousHandlerLayer, - type ContinuousResponse, -} from "../../src/handlers/continuous"; -import { MetadataService, MetadataServiceLayer } from "../../src/services/metadata"; -import { AlarmService, AlarmServiceLayer } from "../../src/services/alarm"; -import { RegistryService, RegistryServiceLayer } from "../../src/services/registry"; -import { JobExecutionServiceLayer } from "../../src/services/execution"; -import { CleanupServiceLayer } from "../../src/services/cleanup"; -import { RetryExecutorLayer } from "../../src/retry"; +import { Effect, Schema, Duration } from "effect"; +import { ContinuousHandler } from "../../src/handlers/continuous"; import { Continuous } from "../../src/definitions/continuous"; -import type { RuntimeJobRegistry } from "../../src/registry/typed"; +import { + createTestRegistry, + createContinuousTestLayer, + runWithLayer, + runExitWithLayer, +} from "./test-utils"; // ============================================================================= // Test Fixtures @@ -98,86 +92,15 @@ const terminatingPrimitive = Continuous.make({ }), }); -// Create test registry using object types (RuntimeJobRegistry) -const createTestRegistry = (): RuntimeJobRegistry => ({ - continuous: { - "counter": { ...counterPrimitive, name: "counter" }, - "failing": { ...failingPrimitive, name: "failing" }, - "no-immediate": { ...noImmediateStartPrimitive, name: "no-immediate" }, - "terminating": { ...terminatingPrimitive, name: "terminating" }, - } as Record, - debounce: {} as Record, - workerPool: {} as Record, - task: {} as Record, -}); - -// ============================================================================= -// Test Helpers -// ============================================================================= - -// Helper to run Effect with layer, bypassing strict R parameter checking -// This is needed because the test registry uses `as Record` which -// causes `any` to leak into Effect R parameters -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const runWithLayer = ( - effect: Effect.Effect, - layer: Layer.Layer -): Promise => - Effect.runPromise( - effect.pipe(Effect.provide(layer)) as Effect.Effect - ); - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const runExitWithLayer = ( - effect: Effect.Effect, - layer: Layer.Layer -) => - Effect.runPromiseExit( - effect.pipe(Effect.provide(layer)) as Effect.Effect - ); - -// ============================================================================= -// Test Setup -// ============================================================================= - -const createTestLayer = (initialTime = 1000000) => { - const { layer: coreLayer, time, handles } = createTestRuntime("test-instance", initialTime); - const registry = createTestRegistry(); - - const servicesLayer = Layer.mergeAll( - MetadataServiceLayer, - AlarmServiceLayer - ).pipe( - Layer.provideMerge(NoopTrackerLayer), - Layer.provideMerge(coreLayer) - ); - - // RetryExecutor depends on AlarmService from servicesLayer - const retryLayer = RetryExecutorLayer.pipe( - Layer.provideMerge(servicesLayer) - ); - - // CleanupService depends on AlarmService and StorageAdapter - const cleanupLayer = CleanupServiceLayer.pipe( - Layer.provideMerge(servicesLayer) - ); - - // JobExecutionService depends on RetryExecutor, CleanupService, and core adapters - const executionLayer = JobExecutionServiceLayer.pipe( - Layer.provideMerge(retryLayer), - Layer.provideMerge(cleanupLayer), - Layer.provideMerge(coreLayer) - ); - - const handlerLayer = ContinuousHandlerLayer.pipe( - Layer.provideMerge(RegistryServiceLayer(registry)), - Layer.provideMerge(servicesLayer), - Layer.provideMerge(retryLayer), - Layer.provideMerge(executionLayer) - ) as Layer.Layer; - - return { layer: handlerLayer, time, handles, coreLayer }; -}; +const createRegistry = () => + createTestRegistry({ + continuous: { + counter: { ...counterPrimitive, name: "counter" }, + failing: { ...failingPrimitive, name: "failing" }, + "no-immediate": { ...noImmediateStartPrimitive, name: "no-immediate" }, + terminating: { ...terminatingPrimitive, name: "terminating" }, + }, + }); // ============================================================================= // Tests @@ -192,7 +115,8 @@ describe("ContinuousHandler", () => { describe("start action", () => { it("creates a new instance and executes immediately by default", async () => { - const { layer } = createTestLayer(1000000); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry, 1000000); const result = await runWithLayer( Effect.gen(function* () { @@ -222,7 +146,8 @@ describe("ContinuousHandler", () => { }); it("returns existing instance if already started", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -258,7 +183,8 @@ describe("ContinuousHandler", () => { }); it("respects startImmediately: false", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -284,7 +210,8 @@ describe("ContinuousHandler", () => { describe("terminate action", () => { it("terminates a running instance and deletes all storage", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -330,7 +257,8 @@ describe("ContinuousHandler", () => { }); it("returns not_found for non-existent instance", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -352,7 +280,8 @@ describe("ContinuousHandler", () => { }); it("returns not_found when called twice (since first terminate deletes all storage)", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -396,7 +325,8 @@ describe("ContinuousHandler", () => { describe("trigger action", () => { it("triggers immediate execution", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -431,7 +361,8 @@ describe("ContinuousHandler", () => { }); it("returns triggered: false for non-existent instance", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -451,7 +382,8 @@ describe("ContinuousHandler", () => { }); it("returns triggered: false for terminated instance", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -490,7 +422,8 @@ describe("ContinuousHandler", () => { describe("status action", () => { it("returns status for running instance", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -520,7 +453,8 @@ describe("ContinuousHandler", () => { }); it("returns not_found for non-existent instance", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -542,7 +476,8 @@ describe("ContinuousHandler", () => { describe("getState action", () => { it("returns current state", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -574,7 +509,8 @@ describe("ContinuousHandler", () => { describe("handleAlarm", () => { it("executes on alarm and schedules next", async () => { - const { layer, time, handles } = createTestLayer(1000000); + const registry = createRegistry(); + const { layer, time, handles } = createContinuousTestLayer(registry, 1000000); await runWithLayer( Effect.gen(function* () { @@ -609,7 +545,8 @@ describe("ContinuousHandler", () => { }); it("does nothing when terminated", async () => { - const { layer, time } = createTestLayer(); + const registry = createRegistry(); + const { layer, time } = createContinuousTestLayer(registry); await runWithLayer( Effect.gen(function* () { @@ -646,7 +583,8 @@ describe("ContinuousHandler", () => { describe("error handling", () => { it("fails with ExecutionError when execute fails without retry config", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const resultExit = await runExitWithLayer( Effect.gen(function* () { @@ -670,7 +608,8 @@ describe("ContinuousHandler", () => { describe("ctx.terminate", () => { it("terminates on first run when condition met (purges state by default)", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -713,7 +652,8 @@ describe("ContinuousHandler", () => { }); it("terminates during alarm and stops further alarms", async () => { - const { layer, time, handles } = createTestLayer(1000000); + const registry = createRegistry(); + const { layer, time, handles } = createContinuousTestLayer(registry, 1000000); await runWithLayer( Effect.gen(function* () { @@ -763,7 +703,8 @@ describe("ContinuousHandler", () => { }); it("trigger action returns terminated: true when terminate called", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -797,7 +738,8 @@ describe("ContinuousHandler", () => { }); it("trigger action returns triggered: false for terminated instance", async () => { - const { layer } = createTestLayer(); + const registry = createRegistry(); + const { layer } = createContinuousTestLayer(registry); const result = await runWithLayer( Effect.gen(function* () { @@ -830,7 +772,8 @@ describe("ContinuousHandler", () => { }); it("handleAlarm does nothing for terminated instance", async () => { - const { layer, time } = createTestLayer(); + const registry = createRegistry(); + const { layer, time } = createContinuousTestLayer(registry); await runWithLayer( Effect.gen(function* () { diff --git a/packages/jobs/test/handlers/debounce.test.ts b/packages/jobs/test/handlers/debounce.test.ts new file mode 100644 index 0000000..e5fb271 --- /dev/null +++ b/packages/jobs/test/handlers/debounce.test.ts @@ -0,0 +1,532 @@ +// packages/jobs/test/handlers/debounce.test.ts + +import { describe, it, expect, beforeEach } from "vitest"; +import { Effect, Schema } from "effect"; +import { DebounceHandler } from "../../src/handlers/debounce"; +import { Debounce } from "../../src/definitions/debounce"; +import { + createTestRegistry, + createDebounceTestLayer, + runWithLayer, +} from "./test-utils"; + +// ============================================================================= +// Test Fixtures +// ============================================================================= + +const WebhookEvent = Schema.Struct({ + type: Schema.String, + payload: Schema.Unknown, +}); +type WebhookEvent = typeof WebhookEvent.Type; + +const BatchState = Schema.Struct({ + events: Schema.Array(WebhookEvent), + firstEventAt: Schema.NullOr(Schema.Number), +}); +type BatchState = typeof BatchState.Type; + +const executionLog: Array<{ + instanceId: string; + eventCount: number; + state: BatchState; +}> = []; + +// Basic debounce that accumulates events +const batcherPrimitive = Debounce.make({ + eventSchema: WebhookEvent, + stateSchema: BatchState, + flushAfter: "5 minutes", + maxEvents: 10, + onEvent: (ctx) => + Effect.succeed({ + events: [...(ctx.state?.events ?? []), ctx.event], + firstEventAt: ctx.state?.firstEventAt ?? Date.now(), + }), + execute: (ctx) => + Effect.gen(function* () { + const state = yield* ctx.state; + const eventCount = yield* ctx.eventCount; + executionLog.push({ + instanceId: ctx.instanceId, + eventCount, + state, + }); + }), +}); + +// Debounce with maxEvents = 3 for testing immediate flush +const smallBatchPrimitive = Debounce.make({ + eventSchema: WebhookEvent, + stateSchema: BatchState, + flushAfter: "10 minutes", + maxEvents: 3, + onEvent: (ctx) => + Effect.succeed({ + events: [...(ctx.state?.events ?? []), ctx.event], + firstEventAt: ctx.state?.firstEventAt ?? Date.now(), + }), + execute: (ctx) => + Effect.gen(function* () { + const state = yield* ctx.state; + const eventCount = yield* ctx.eventCount; + executionLog.push({ + instanceId: ctx.instanceId, + eventCount, + state, + }); + }), +}); + +const createRegistry = () => + createTestRegistry({ + debounce: { + batcher: { ...batcherPrimitive, name: "batcher" }, + smallBatch: { ...smallBatchPrimitive, name: "smallBatch" }, + }, + }); + +// ============================================================================= +// Tests +// ============================================================================= + +describe("DebounceHandler", () => { + beforeEach(() => { + executionLog.length = 0; + }); + + // =========================================================================== + // Event Accumulation (3 tests) + // =========================================================================== + + describe("event accumulation", () => { + it("first add creates metadata, sets startedAt, schedules timeout", async () => { + const registry = createRegistry(); + const { layer, handles } = createDebounceTestLayer(registry, 1000000); + + const result = await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + return yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: { data: 1 } }, + }); + }), + layer + ); + + expect(result._type).toBe("debounce.add"); + + // Verify alarm was scheduled (5 minutes = 300000ms) + const scheduledTime = handles.scheduler.getScheduledTime(); + expect(scheduledTime).toBeDefined(); + expect(scheduledTime).toBeGreaterThan(1000000); + }); + + it("add calls onEvent reducer and persists updated state", async () => { + const registry = createRegistry(); + const { layer } = createDebounceTestLayer(registry, 1000000); + + // Add two events + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: { first: true } }, + }); + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: { second: true } }, + }); + }), + layer + ); + + // Check state via getState + const stateResult = await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + return yield* handler.handle({ + type: "debounce", + action: "getState", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + expect(stateResult._type).toBe("debounce.getState"); + const state = (stateResult as any).state as BatchState; + expect(state.events).toHaveLength(2); + expect(state.events[0].payload).toEqual({ first: true }); + expect(state.events[1].payload).toEqual({ second: true }); + }); + + it("eventCount increments correctly across adds", async () => { + const registry = createRegistry(); + const { layer } = createDebounceTestLayer(registry, 1000000); + + // Add 3 events + const results = await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + const r1 = yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: 1 }, + }); + const r2 = yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: 2 }, + }); + const r3 = yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: 3 }, + }); + return [r1, r2, r3]; + }), + layer + ); + + // Check eventCount via status + const status = await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + return yield* handler.handle({ + type: "debounce", + action: "status", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + expect(status._type).toBe("debounce.status"); + expect((status as any).eventCount).toBe(3); + }); + }); + + // =========================================================================== + // Flush Triggers (3 tests) + // =========================================================================== + + describe("flush triggers", () => { + it("maxEvents triggers immediate flush", async () => { + const registry = createRegistry(); + const { layer } = createDebounceTestLayer(registry, 1000000); + + // smallBatch has maxEvents = 3, so third event should trigger flush + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "add", + name: "smallBatch", + id: "batch-1", + event: { type: "webhook", payload: 1 }, + }); + yield* handler.handle({ + type: "debounce", + action: "add", + name: "smallBatch", + id: "batch-1", + event: { type: "webhook", payload: 2 }, + }); + yield* handler.handle({ + type: "debounce", + action: "add", + name: "smallBatch", + id: "batch-1", + event: { type: "webhook", payload: 3 }, + }); + }), + layer + ); + + // Execute should have been called + expect(executionLog).toHaveLength(1); + expect(executionLog[0].eventCount).toBe(3); + expect(executionLog[0].state.events).toHaveLength(3); + }); + + it("handleAlarm flushes when timeout fires", async () => { + const registry = createRegistry(); + const { layer, time } = createDebounceTestLayer(registry, 1000000); + + // Add event (schedules flush in 5 minutes) + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: "timeout-test" }, + }); + }), + layer + ); + + expect(executionLog).toHaveLength(0); + + // Advance time past flush timeout (5 minutes = 300000ms) + time.set(1000000 + 300000 + 1000); + + // Trigger alarm + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handleAlarm(); + }), + layer + ); + + expect(executionLog).toHaveLength(1); + expect(executionLog[0].state.events[0].payload).toBe("timeout-test"); + }); + + it("manual flush executes regardless of eventCount", async () => { + const registry = createRegistry(); + const { layer } = createDebounceTestLayer(registry, 1000000); + + // Add single event (below maxEvents) + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: "manual-flush" }, + }); + }), + layer + ); + + expect(executionLog).toHaveLength(0); + + // Manual flush + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "flush", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + expect(executionLog).toHaveLength(1); + expect(executionLog[0].eventCount).toBe(1); + }); + }); + + // =========================================================================== + // Flush Behavior (2 tests) + // =========================================================================== + + describe("flush behavior", () => { + it("successful flush purges all state", async () => { + const registry = createRegistry(); + const { layer } = createDebounceTestLayer(registry, 1000000); + + // Add event and flush + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: "purge-test" }, + }); + yield* handler.handle({ + type: "debounce", + action: "flush", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + // State should be gone after flush + const status = await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + return yield* handler.handle({ + type: "debounce", + action: "status", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + expect(status._type).toBe("debounce.status"); + expect((status as any).status).toBe("not_found"); + }); + + it("flush on empty buffer purges without executing", async () => { + const registry = createRegistry(); + const { layer } = createDebounceTestLayer(registry, 1000000); + + // Flush without adding any events + const result = await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + return yield* handler.handle({ + type: "debounce", + action: "flush", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + expect(result._type).toBe("debounce.flush"); + // Execute should not be called for empty buffer + expect(executionLog).toHaveLength(0); + }); + }); + + // =========================================================================== + // Clear & Status (2 tests) + // =========================================================================== + + describe("clear and status", () => { + it("clear discards buffered events without executing", async () => { + const registry = createRegistry(); + const { layer } = createDebounceTestLayer(registry, 1000000); + + // Add events + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: 1 }, + }); + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: 2 }, + }); + }), + layer + ); + + // Clear + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "clear", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + // Execute should not have been called + expect(executionLog).toHaveLength(0); + + // Status should show not_found + const status = await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + return yield* handler.handle({ + type: "debounce", + action: "status", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + expect((status as any).status).toBe("not_found"); + }); + + it("status returns eventCount and scheduled flush time", async () => { + const registry = createRegistry(); + const { layer } = createDebounceTestLayer(registry, 1000000); + + // Add events + await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: 1 }, + }); + yield* handler.handle({ + type: "debounce", + action: "add", + name: "batcher", + id: "batch-1", + event: { type: "webhook", payload: 2 }, + }); + }), + layer + ); + + const status = await runWithLayer( + Effect.gen(function* () { + const handler = yield* DebounceHandler; + return yield* handler.handle({ + type: "debounce", + action: "status", + name: "batcher", + id: "batch-1", + }); + }), + layer + ); + + expect(status._type).toBe("debounce.status"); + expect((status as any).status).toBe("debouncing"); + expect((status as any).eventCount).toBe(2); + expect((status as any).willFlushAt).toBeDefined(); + }); + }); +}); diff --git a/packages/jobs/test/handlers/task.test.ts b/packages/jobs/test/handlers/task.test.ts new file mode 100644 index 0000000..9f1670e --- /dev/null +++ b/packages/jobs/test/handlers/task.test.ts @@ -0,0 +1,638 @@ +// packages/jobs/test/handlers/task.test.ts + +import { describe, it, expect, beforeEach } from "vitest"; +import { Effect, Schema, Duration } from "effect"; +import { TaskHandler } from "../../src/handlers/task"; +import { Task } from "../../src/definitions/task"; +import { + createTestRegistry, + createTaskTestLayer, + runWithLayer, +} from "./test-utils"; + +// ============================================================================= +// Test Fixtures +// ============================================================================= + +const TaskEvent = Schema.Struct({ + type: Schema.Literal("increment", "decrement", "reset"), + amount: Schema.optional(Schema.Number), +}); +type TaskEvent = typeof TaskEvent.Type; + +const TaskState = Schema.Struct({ + counter: Schema.Number, + lastUpdated: Schema.Number, +}); +type TaskState = typeof TaskState.Type; + +const executionLog: Array<{ + instanceId: string; + trigger: "onEvent" | "execute" | "onIdle" | "onError"; + event?: TaskEvent; + state: TaskState | null; +}> = []; + +// Basic task that processes events +const counterTaskPrimitive = Task.make({ + eventSchema: TaskEvent, + stateSchema: TaskState, + onEvent: (event, ctx) => + Effect.gen(function* () { + const currentState = (yield* ctx.state) ?? { counter: 0, lastUpdated: 0 }; + let newCounter = currentState.counter; + + switch (event.type) { + case "increment": + newCounter += event.amount ?? 1; + break; + case "decrement": + newCounter -= event.amount ?? 1; + break; + case "reset": + newCounter = 0; + break; + } + + yield* ctx.setState({ + counter: newCounter, + lastUpdated: Date.now(), + }); + + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "onEvent", + event, + state: { counter: newCounter, lastUpdated: Date.now() }, + }); + }), + execute: (ctx) => + Effect.gen(function* () { + const state = yield* ctx.state; + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "execute", + state, + }); + }), +}); + +// Task with scheduling in onEvent +const schedulingTaskPrimitive = Task.make({ + eventSchema: TaskEvent, + stateSchema: TaskState, + onEvent: (event, ctx) => + Effect.gen(function* () { + const currentState = (yield* ctx.state) ?? { counter: 0, lastUpdated: 0 }; + yield* ctx.setState({ + counter: currentState.counter + 1, + lastUpdated: Date.now(), + }); + + // Schedule execution in 5 seconds + yield* ctx.schedule(Duration.seconds(5)); + + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "onEvent", + event, + state: { counter: currentState.counter + 1, lastUpdated: Date.now() }, + }); + }), + execute: (ctx) => + Effect.gen(function* () { + const state = yield* ctx.state; + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "execute", + state, + }); + }), +}); + +// Task with onIdle handler +const idleTaskPrimitive = Task.make({ + eventSchema: TaskEvent, + stateSchema: TaskState, + onEvent: (event, ctx) => + Effect.gen(function* () { + const currentState = (yield* ctx.state) ?? { counter: 0, lastUpdated: 0 }; + yield* ctx.setState({ + counter: currentState.counter + 1, + lastUpdated: Date.now(), + }); + + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "onEvent", + event, + state: { counter: currentState.counter + 1, lastUpdated: Date.now() }, + }); + }), + execute: (ctx) => + Effect.gen(function* () { + const state = yield* ctx.state; + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "execute", + state, + }); + }), + onIdle: (ctx) => + Effect.gen(function* () { + const state = yield* ctx.state; + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "onIdle", + state, + }); + }), +}); + +// Task with onIdle that re-arms (schedules) +const rearmTaskPrimitive = Task.make({ + eventSchema: TaskEvent, + stateSchema: TaskState, + onEvent: (event, ctx) => + Effect.gen(function* () { + const currentState = (yield* ctx.state) ?? { counter: 0, lastUpdated: 0 }; + yield* ctx.setState({ + counter: currentState.counter + 1, + lastUpdated: Date.now(), + }); + }), + execute: (ctx) => + Effect.gen(function* () { + const state = yield* ctx.state; + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "execute", + state, + }); + }), + onIdle: (ctx) => + Effect.gen(function* () { + // Re-arm: schedule execution in 10 seconds + yield* ctx.schedule(Duration.seconds(10)); + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "onIdle", + state: null, + }); + }), +}); + +const createRegistry = () => + createTestRegistry({ + task: { + counterTask: { ...counterTaskPrimitive, name: "counterTask" }, + schedulingTask: { ...schedulingTaskPrimitive, name: "schedulingTask" }, + idleTask: { ...idleTaskPrimitive, name: "idleTask" }, + rearmTask: { ...rearmTaskPrimitive, name: "rearmTask" }, + }, + }); + +// ============================================================================= +// Tests +// ============================================================================= + +describe("TaskHandler", () => { + beforeEach(() => { + executionLog.length = 0; + }); + + // =========================================================================== + // Send Path (3 tests) + // =========================================================================== + + describe("send path", () => { + it("send validates event against schema", async () => { + const registry = createRegistry(); + const { layer } = createTaskTestLayer(registry, 1000000); + + // Invalid event should fail + const result = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler + .handle({ + type: "task", + action: "send", + name: "counterTask", + id: "task-1", + event: { invalid: "event" }, // Missing required 'type' field + }) + .pipe(Effect.either); + }), + layer + ); + + expect(result._tag).toBe("Left"); + }); + + it("send creates state on first call, increments eventCount", async () => { + const registry = createRegistry(); + const { layer } = createTaskTestLayer(registry, 1000000); + + // First send creates state + const result1 = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler.handle({ + type: "task", + action: "send", + name: "counterTask", + id: "task-1", + event: { type: "increment", amount: 5 }, + }); + }), + layer + ); + + expect(result1._type).toBe("task.send"); + expect((result1 as any).created).toBe(true); + + // Second send doesn't recreate + const result2 = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler.handle({ + type: "task", + action: "send", + name: "counterTask", + id: "task-1", + event: { type: "increment", amount: 3 }, + }); + }), + layer + ); + + expect((result2 as any).created).toBe(false); + + // Check eventCount via status + const status = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler.handle({ + type: "task", + action: "status", + name: "counterTask", + id: "task-1", + }); + }), + layer + ); + + expect((status as any).eventCount).toBe(2); + }); + + it("send calls onEvent handler with validated event", async () => { + const registry = createRegistry(); + const { layer } = createTaskTestLayer(registry, 1000000); + + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handle({ + type: "task", + action: "send", + name: "counterTask", + id: "task-1", + event: { type: "increment", amount: 10 }, + }); + }), + layer + ); + + // onEvent should have been called + expect(executionLog).toHaveLength(1); + expect(executionLog[0].trigger).toBe("onEvent"); + expect(executionLog[0].event?.type).toBe("increment"); + expect(executionLog[0].event?.amount).toBe(10); + }); + }); + + // =========================================================================== + // Trigger Path (2 tests) + // =========================================================================== + + describe("trigger path", () => { + it("trigger increments executeCount and calls execute handler", async () => { + const registry = createRegistry(); + const { layer } = createTaskTestLayer(registry, 1000000); + + // First create the task via send + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handle({ + type: "task", + action: "send", + name: "counterTask", + id: "task-1", + event: { type: "increment" }, + }); + }), + layer + ); + + executionLog.length = 0; // Clear log + + // Trigger execution + const result = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler.handle({ + type: "task", + action: "trigger", + name: "counterTask", + id: "task-1", + }); + }), + layer + ); + + expect(result._type).toBe("task.trigger"); + expect((result as any).triggered).toBe(true); + + // execute should have been called + expect(executionLog.some((e) => e.trigger === "execute")).toBe(true); + + // Check executeCount via status + const status = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler.handle({ + type: "task", + action: "status", + name: "counterTask", + id: "task-1", + }); + }), + layer + ); + + expect((status as any).executeCount).toBe(1); + }); + + it("trigger on non-existent task returns triggered:false", async () => { + const registry = createRegistry(); + const { layer } = createTaskTestLayer(registry, 1000000); + + const result = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler.handle({ + type: "task", + action: "trigger", + name: "counterTask", + id: "nonexistent-task", + }); + }), + layer + ); + + expect(result._type).toBe("task.trigger"); + expect((result as any).triggered).toBe(false); + }); + }); + + // =========================================================================== + // Scheduling (3 tests) + // =========================================================================== + + describe("scheduling", () => { + it("ctx.schedule() from onEvent sets alarm", async () => { + const registry = createRegistry(); + const { layer, handles } = createTaskTestLayer(registry, 1000000); + + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handle({ + type: "task", + action: "send", + name: "schedulingTask", + id: "task-1", + event: { type: "increment" }, + }); + }), + layer + ); + + // Alarm should be scheduled (5 seconds from now) + const scheduledTime = handles.scheduler.getScheduledTime(); + expect(scheduledTime).toBeDefined(); + // Verify it's scheduled ~5 seconds in the future from actual time + expect(scheduledTime).toBeGreaterThan(Date.now() - 1000); + }); + + it("ctx.cancelSchedule() removes pending alarm", async () => { + const registry = createRegistry(); + + // Create a task that schedules then cancels + const cancelTaskPrimitive = Task.make({ + eventSchema: TaskEvent, + stateSchema: TaskState, + onEvent: (event, ctx) => + Effect.gen(function* () { + yield* ctx.setState({ counter: 1, lastUpdated: Date.now() }); + yield* ctx.schedule(Duration.seconds(5)); + yield* ctx.cancelSchedule(); + }), + execute: (ctx) => + Effect.gen(function* () { + const state = yield* ctx.state; + executionLog.push({ + instanceId: ctx.instanceId, + trigger: "execute", + state, + }); + }), + }); + + const customRegistry = createTestRegistry({ + task: { + cancelTask: { ...cancelTaskPrimitive, name: "cancelTask" }, + }, + }); + + const { layer, handles } = createTaskTestLayer(customRegistry, 1000000); + + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handle({ + type: "task", + action: "send", + name: "cancelTask", + id: "task-1", + event: { type: "increment" }, + }); + }), + layer + ); + + // Alarm should be cancelled + const scheduledTime = handles.scheduler.getScheduledTime(); + expect(scheduledTime).toBeUndefined(); + }); + + it("handleAlarm executes when scheduled time arrives", async () => { + const registry = createRegistry(); + const { layer, time } = createTaskTestLayer(registry, 1000000); + + // Send event which schedules execution + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handle({ + type: "task", + action: "send", + name: "schedulingTask", + id: "task-1", + event: { type: "increment" }, + }); + }), + layer + ); + + executionLog.length = 0; // Clear onEvent log + + // Advance time past scheduled alarm (5 seconds) + time.set(1000000 + 5000 + 100); + + // Trigger alarm + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handleAlarm(); + }), + layer + ); + + // execute should have been called + expect(executionLog.some((e) => e.trigger === "execute")).toBe(true); + }); + }); + + // =========================================================================== + // Idle Behavior (2 tests) + // =========================================================================== + + describe("idle behavior", () => { + it("onIdle runs after onEvent if no schedule set", async () => { + const registry = createRegistry(); + const { layer } = createTaskTestLayer(registry, 1000000); + + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handle({ + type: "task", + action: "send", + name: "idleTask", + id: "task-1", + event: { type: "increment" }, + }); + }), + layer + ); + + // Both onEvent and onIdle should have been called + expect(executionLog.some((e) => e.trigger === "onEvent")).toBe(true); + expect(executionLog.some((e) => e.trigger === "onIdle")).toBe(true); + }); + + it("onIdle can schedule (re-arming pattern)", async () => { + const registry = createRegistry(); + const { layer, handles } = createTaskTestLayer(registry, 1000000); + + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handle({ + type: "task", + action: "send", + name: "rearmTask", + id: "task-1", + event: { type: "increment" }, + }); + }), + layer + ); + + // onIdle should have scheduled (10 seconds from now) + const scheduledTime = handles.scheduler.getScheduledTime(); + expect(scheduledTime).toBeDefined(); + // Verify it's scheduled ~10 seconds in the future from actual time + expect(scheduledTime).toBeGreaterThan(Date.now() - 1000); + expect(executionLog.some((e) => e.trigger === "onIdle")).toBe(true); + }); + }); + + // =========================================================================== + // Termination (1 test) + // =========================================================================== + + describe("termination", () => { + it("terminate purges state and cancels alarm", async () => { + const registry = createRegistry(); + const { layer, handles } = createTaskTestLayer(registry, 1000000); + + // Create task with scheduled alarm + await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + yield* handler.handle({ + type: "task", + action: "send", + name: "schedulingTask", + id: "task-1", + event: { type: "increment" }, + }); + }), + layer + ); + + // Verify alarm is scheduled + expect(handles.scheduler.getScheduledTime()).toBeDefined(); + + // Terminate + const result = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler.handle({ + type: "task", + action: "terminate", + name: "schedulingTask", + id: "task-1", + }); + }), + layer + ); + + expect(result._type).toBe("task.terminate"); + expect((result as any).terminated).toBe(true); + + // Alarm should be cancelled + expect(handles.scheduler.getScheduledTime()).toBeUndefined(); + + // Status should show not_found + const status = await runWithLayer( + Effect.gen(function* () { + const handler = yield* TaskHandler; + return yield* handler.handle({ + type: "task", + action: "status", + name: "schedulingTask", + id: "task-1", + }); + }), + layer + ); + + expect((status as any).status).toBe("not_found"); + }); + }); +}); diff --git a/packages/jobs/test/handlers/test-utils.ts b/packages/jobs/test/handlers/test-utils.ts new file mode 100644 index 0000000..3d72e6c --- /dev/null +++ b/packages/jobs/test/handlers/test-utils.ts @@ -0,0 +1,186 @@ +// packages/jobs/test/handlers/test-utils.ts + +import { Effect, Layer } from "effect"; +import { createTestRuntime, NoopTrackerLayer } from "@durable-effect/core"; +import { MetadataServiceLayer } from "../../src/services/metadata"; +import { AlarmServiceLayer } from "../../src/services/alarm"; +import { RegistryServiceLayer } from "../../src/services/registry"; +import { JobExecutionServiceLayer } from "../../src/services/execution"; +import { CleanupServiceLayer } from "../../src/services/cleanup"; +import { RetryExecutorLayer } from "../../src/retry"; +import { + ContinuousHandler, + ContinuousHandlerLayer, +} from "../../src/handlers/continuous"; +import { + DebounceHandler, + DebounceHandlerLayer, +} from "../../src/handlers/debounce"; +import { TaskHandler, TaskHandlerLayer } from "../../src/handlers/task"; +import type { RuntimeJobRegistry } from "../../src/registry/typed"; + +// ============================================================================= +// Registry Factory +// ============================================================================= + +/** + * Create a test registry with optional job definitions. + */ +export const createTestRegistry = ( + overrides: Partial<{ + continuous: Record; + debounce: Record; + task: Record; + workerPool: Record; + }> = {} +): RuntimeJobRegistry => ({ + continuous: overrides.continuous ?? ({} as Record), + debounce: overrides.debounce ?? ({} as Record), + task: overrides.task ?? ({} as Record), + workerPool: overrides.workerPool ?? ({} as Record), +}); + +// ============================================================================= +// Layer Factories +// ============================================================================= + +/** + * Build the shared services layer (metadata, alarm, tracker). + */ +const buildServicesLayer = (coreLayer: Layer.Layer) => + Layer.mergeAll(MetadataServiceLayer, AlarmServiceLayer).pipe( + Layer.provideMerge(NoopTrackerLayer), + Layer.provideMerge(coreLayer) + ); + +/** + * Build the execution layer stack (retry, cleanup, execution). + */ +const buildExecutionLayer = ( + servicesLayer: Layer.Layer, + coreLayer: Layer.Layer +) => { + const retryLayer = RetryExecutorLayer.pipe(Layer.provideMerge(servicesLayer)); + const cleanupLayer = CleanupServiceLayer.pipe( + Layer.provideMerge(servicesLayer) + ); + const executionLayer = JobExecutionServiceLayer.pipe( + Layer.provideMerge(retryLayer), + Layer.provideMerge(cleanupLayer), + Layer.provideMerge(coreLayer) + ); + return { retryLayer, cleanupLayer, executionLayer }; +}; + +/** + * Create test layer for ContinuousHandler. + */ +export const createContinuousTestLayer = ( + registry: RuntimeJobRegistry, + initialTime = 1000000 +) => { + const { + layer: coreLayer, + time, + handles, + } = createTestRuntime("test-instance", initialTime); + + const servicesLayer = buildServicesLayer(coreLayer); + const { retryLayer, executionLayer } = buildExecutionLayer( + servicesLayer, + coreLayer + ); + + const handlerLayer = ContinuousHandlerLayer.pipe( + Layer.provideMerge(RegistryServiceLayer(registry)), + Layer.provideMerge(servicesLayer), + Layer.provideMerge(retryLayer), + Layer.provideMerge(executionLayer) + ) as Layer.Layer; + + return { layer: handlerLayer, time, handles, coreLayer }; +}; + +/** + * Create test layer for DebounceHandler. + */ +export const createDebounceTestLayer = ( + registry: RuntimeJobRegistry, + initialTime = 1000000 +) => { + const { + layer: coreLayer, + time, + handles, + } = createTestRuntime("test-instance", initialTime); + + const servicesLayer = buildServicesLayer(coreLayer); + const { retryLayer, executionLayer } = buildExecutionLayer( + servicesLayer, + coreLayer + ); + + const handlerLayer = DebounceHandlerLayer.pipe( + Layer.provideMerge(RegistryServiceLayer(registry)), + Layer.provideMerge(servicesLayer), + Layer.provideMerge(retryLayer), + Layer.provideMerge(executionLayer) + ) as Layer.Layer; + + return { layer: handlerLayer, time, handles, coreLayer }; +}; + +/** + * Create test layer for TaskHandler. + */ +export const createTaskTestLayer = ( + registry: RuntimeJobRegistry, + initialTime = 1000000 +) => { + const { + layer: coreLayer, + time, + handles, + } = createTestRuntime("test-instance", initialTime); + + const servicesLayer = buildServicesLayer(coreLayer); + const { executionLayer } = buildExecutionLayer(servicesLayer, coreLayer); + + const handlerLayer = TaskHandlerLayer.pipe( + Layer.provideMerge(RegistryServiceLayer(registry)), + Layer.provideMerge(servicesLayer), + Layer.provideMerge(executionLayer) + ) as Layer.Layer; + + return { layer: handlerLayer, time, handles, coreLayer }; +}; + +// ============================================================================= +// Run Helpers +// ============================================================================= + +/** + * Run Effect with layer, bypassing strict R parameter checking. + * This is needed because the test registry uses `as Record` which + * causes `any` to leak into Effect R parameters. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const runWithLayer = ( + effect: Effect.Effect, + layer: Layer.Layer +): Promise => + Effect.runPromise( + effect.pipe(Effect.provide(layer)) as Effect.Effect + ); + +/** + * Run Effect with layer and return Exit, bypassing strict R parameter checking. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const runExitWithLayer = ( + effect: Effect.Effect, + layer: Layer.Layer +) => + Effect.runPromiseExit( + effect.pipe(Effect.provide(layer)) as Effect.Effect + ); diff --git a/reports/074-handler-testing-strategy.md b/reports/074-handler-testing-strategy.md new file mode 100644 index 0000000..f48a6a9 --- /dev/null +++ b/reports/074-handler-testing-strategy.md @@ -0,0 +1,288 @@ +# Handler Testing Strategy + +This document defines a minimal, high-value testing strategy for job handlers in `packages/jobs`. The goal is comprehensive coverage of critical behaviors while avoiding test bloat that makes maintenance difficult. + +## Design Principles + +1. **Test handler-specific logic, not shared infrastructure** - Services like MetadataService, AlarmService, and RetryExecutor have their own tests. Handler tests should focus on how handlers orchestrate these services, not re-test the services themselves. + +2. **Focus on state machine transitions** - Each handler is fundamentally a state machine. Test valid transitions and ensure invalid transitions are rejected. + +3. **Test integration seams** - The points where handlers call services are where bugs hide. Test that handlers pass correct arguments and handle responses properly. + +4. **Prioritize edge cases over happy paths** - Happy paths are usually simple. Edge cases (races, missing data, retry scenarios) are where complexity lives. + +5. **One test per behavior** - Avoid testing the same behavior multiple ways. If `start` creates metadata, one test is enough. + +--- + +## Handler Architecture Summary + +| Handler | Execution Model | Unique Complexity | +|---------|-----------------|-------------------| +| **Continuous** | Schedule-driven loop | runCount tracking, startImmediately flag, schedule-after-execute | +| **Debounce** | Event accumulation → flush | onEvent reducer, maxEvents trigger, timeout flush, purge-on-success | +| **Task** | Dual-mode (event + execute) | Three context types, scheduling API, onIdle re-arming | + +### Shared Infrastructure (tested elsewhere) +- `JobExecutionService` - Handles state loading, retry signals, event emission +- `RetryExecutor` - Attempt tracking, delay scheduling, exhaustion detection +- `MetadataService` - CRUD for job metadata +- `AlarmService` - Schedule/cancel alarms +- `CleanupService` - Atomic termination + +--- + +## Continuous Handler Tests + +### What Makes Continuous Unique +- `runCount` increments each execution (not on retry) +- `startImmediately` flag controls first execution +- Automatic scheduling after each execution +- Alarm-driven execution loop + +### Critical Test Cases + +#### 1. Start Action (3 tests) +``` +✓ start creates metadata and executes immediately (default) +✓ start with startImmediately:false schedules without executing +✓ start on existing instance returns existing (idempotent) +``` + +#### 2. Execution Loop (3 tests) +``` +✓ handleAlarm increments runCount and schedules next +✓ handleAlarm skips if job is terminated (no error) +✓ trigger forces immediate execution and schedules next +``` + +#### 3. Termination (2 tests) +``` +✓ terminate action purges all storage and cancels alarm +✓ ctx.terminate() from execute triggers cleanup +``` + +#### 4. State & Status (2 tests) +``` +✓ getState returns current state after mutations +✓ status returns running/terminated correctly +``` + +#### 5. Error Handling (1 test) +``` +✓ execution failure without retry config propagates error +``` + +**Total: 11 tests** + +### What NOT to Test +- Retry scheduling/exhaustion (tested in RetryExecutor tests) +- Metadata persistence details (tested in MetadataService tests) +- Alarm scheduling mechanics (tested in AlarmService tests) +- Event emission (tested in tracking.test.ts) + +--- + +## Debounce Handler Tests + +### What Makes Debounce Unique +- `onEvent` reducer accumulates state +- Three flush triggers: maxEvents, timeout, manual +- Purges state on successful flush +- eventCount tracking + +### Critical Test Cases + +#### 1. Event Accumulation (3 tests) +``` +✓ first add creates metadata, sets startedAt, schedules timeout +✓ add calls onEvent reducer and persists updated state +✓ eventCount increments correctly across adds +``` + +#### 2. Flush Triggers (3 tests) +``` +✓ maxEvents triggers immediate flush (no waiting for timeout) +✓ handleAlarm flushes when timeout fires +✓ manual flush executes regardless of eventCount +``` + +#### 3. Flush Behavior (2 tests) +``` +✓ successful flush purges all state +✓ flush on empty buffer purges without executing +``` + +#### 4. Clear & Status (2 tests) +``` +✓ clear discards buffered events without executing +✓ status returns eventCount and scheduled flush time +``` + +**Total: 10 tests** + +### What NOT to Test +- onEvent reducer logic in isolation (user code) +- Exact timing of flushAfter (AlarmService concern) +- Retry during flush (JobExecutionService concern) + +--- + +## Task Handler Tests + +### What Makes Task Unique +- Two entry points: `send` (event) and `trigger` (no event) +- Three execution handlers: `onEvent`, `execute`, `onIdle` +- Scheduling API available in context +- eventCount vs executeCount tracking + +### Critical Test Cases + +#### 1. Send Path (3 tests) +``` +✓ send validates event against schema +✓ send creates state on first call, increments eventCount +✓ send calls onEvent handler with validated event +``` + +#### 2. Trigger Path (2 tests) +``` +✓ trigger increments executeCount and calls execute handler +✓ trigger on non-existent task returns not-found error +``` + +#### 3. Scheduling (3 tests) +``` +✓ ctx.schedule() from onEvent sets alarm +✓ ctx.cancelSchedule() removes pending alarm +✓ handleAlarm executes when scheduled time arrives +``` + +#### 4. Idle Behavior (2 tests) +``` +✓ onIdle runs after onEvent/execute if no schedule set +✓ onIdle can schedule (re-arming pattern) +``` + +#### 5. Termination (1 test) +``` +✓ terminate purges state and cancels alarm +``` + +**Total: 11 tests** + +### What NOT to Test +- Schema validation mechanics (Effect Schema concern) +- Context creation details (implementation detail) +- Multiple context types separately (same underlying machinery) + +--- + +## Cross-Cutting Tests (tracking.test.ts) + +Event tracking tests apply to all handlers. Keep in a single file. + +### Critical Test Cases (6 tests) +``` +✓ job.executed event emitted on success with preExecutionState +✓ job.failed event emitted on error with preExecutionState +✓ job.failed with willRetry:true when retry scheduled +✓ job.retryExhausted event when retries exhausted +✓ events include correct jobType, jobName, instanceId +✓ durationMs calculated correctly in executed events +``` + +--- + +## Test Infrastructure + +### Shared Test Setup +Create a `test/handlers/test-utils.ts` with: + +```typescript +// Minimal registry factory +export const createTestRegistry = (overrides?: Partial) => ({ + continuous: {}, + debounce: {}, + task: {}, + workerPool: {}, + ...overrides, +}); + +// Layer factory for each handler type +export const createContinuousTestLayer = (registry: RuntimeJobRegistry, initialTime?: number) => {...}; +export const createDebounceTestLayer = (registry: RuntimeJobRegistry, initialTime?: number) => {...}; +export const createTaskTestLayer = (registry: RuntimeJobRegistry, initialTime?: number) => {...}; + +// Run helper to handle type casting +export const runWithLayer = ( + effect: Effect.Effect, + layer: Layer.Layer +): Promise => {...}; +``` + +### Test File Organization +``` +test/handlers/ +├── test-utils.ts # Shared factories and helpers +├── continuous.test.ts # 11 tests +├── debounce.test.ts # 10 tests +├── task.test.ts # 11 tests +└── tracking.test.ts # 6 tests (cross-cutting) +``` + +--- + +## Test Count Summary + +| File | Tests | Coverage Focus | +|------|-------|----------------| +| continuous.test.ts | 11 | State machine, scheduling loop | +| debounce.test.ts | 10 | Accumulation, flush triggers | +| task.test.ts | 11 | Dual-mode, scheduling API | +| tracking.test.ts | 6 | Event emission | +| **Total** | **38** | | + +This is a 50% reduction from testing every permutation while maintaining coverage of all critical behaviors. + +--- + +## What This Strategy Explicitly Excludes + +1. **Service unit tests** - MetadataService, AlarmService, etc. have separate test files +2. **Retry logic tests** - RetryExecutor has its own tests +3. **Schema validation** - Effect Schema is well-tested upstream +4. **Permutation testing** - Don't test start→trigger→terminate AND start→terminate→trigger separately +5. **Internal implementation** - Don't test private helpers or intermediate states +6. **Timing edge cases** - Handled at scheduler/alarm level + +--- + +## Implementation Priority + +1. **Phase 1**: Create `test-utils.ts` with shared infrastructure +2. **Phase 2**: Implement `debounce.test.ts` (currently missing) +3. **Phase 3**: Implement `task.test.ts` (currently missing) +4. **Phase 4**: Refactor `continuous.test.ts` to use shared utils and remove redundant tests +5. **Phase 5**: Consolidate tracking tests if split across files + +--- + +## Maintenance Guidelines + +### When to Add Tests +- New handler action added +- Bug fix reveals untested edge case +- New service integration point + +### When NOT to Add Tests +- Refactoring internals (behavior unchanged) +- Adding logging/tracing +- Performance improvements + +### Test Smell Indicators +- Test name includes "and" (testing multiple things) +- Test requires more than 3 setup steps +- Test duplicates another test with different input values +- Test mocks more than 2 services