diff --git a/packages/core-shared/src/conformance/index.ts b/packages/core-shared/src/conformance/index.ts index ef3ca9a..37d5839 100644 --- a/packages/core-shared/src/conformance/index.ts +++ b/packages/core-shared/src/conformance/index.ts @@ -25,3 +25,5 @@ export { } from "./brand-runtime"; export { ConformanceError } from "./conformance-error"; export { assertFeatureConformance } from "./assert-bindings"; +export { wireUseCase } from "./wire-use-case"; +export type { WireUseCaseOptions } from "./wire-use-case"; diff --git a/packages/core-shared/src/conformance/wire-use-case.test.ts b/packages/core-shared/src/conformance/wire-use-case.test.ts new file mode 100644 index 0000000..860a590 --- /dev/null +++ b/packages/core-shared/src/conformance/wire-use-case.test.ts @@ -0,0 +1,277 @@ +import { describe, it, expect, vi } from "vitest"; +import "reflect-metadata"; +import { Container } from "inversify"; +import { wireUseCase } from "@/conformance/wire-use-case"; +import { + isInstrumented, + isCaptured, + isAudited, +} from "@/conformance/brand-runtime"; +import type { + ITracer, + ISpan, + SpanOpts, +} from "@/instrumentation/tracer.interface"; +import type { ILogger } from "@/instrumentation/logger.interface"; +import type { AuditLogProtocol } from "@/di/bind-protocols"; + +function makeTracer() { + const calls: SpanOpts[] = []; + const tracer: ITracer = { + startSpan: vi.fn(async (opts, fn) => { + calls.push(opts); + const span: ISpan = { setAttribute: () => {}, setStatus: () => {} }; + return fn(span); + }), + }; + return { tracer, calls }; +} + +function makeLogger(): ILogger & { + captureException: ReturnType; +} { + return { + captureException: vi.fn(), + captureMessage: vi.fn(), + addBreadcrumb: vi.fn(), + setUser: vi.fn(), + }; +} + +function makeAuditLog(): AuditLogProtocol { + return { record: vi.fn() }; +} + +const doubleFactory = () => async (x: number) => x * 2; + +describe("wireUseCase — no-audit path", () => { + it("attaches Instrumented + Captured brands, no Audited brand", () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + }); + + expect(isInstrumented(wired)).toBe(true); + expect(isCaptured(wired)).toBe(true); + expect(isAudited(wired)).toBe(false); + }); + + it("executes the factory result on invocation", async () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + }); + + await expect(wired(3)).resolves.toBe(6); + }); +}); + +describe("wireUseCase — audit path", () => { + it("attaches Instrumented + Captured + Audited brands when auditLog is provided", () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + const auditLog = makeAuditLog(); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + auditLog, + }); + + expect(isInstrumented(wired)).toBe(true); + expect(isCaptured(wired)).toBe(true); + expect(isAudited(wired)).toBe(true); + }); + + it("executes the factory result on invocation (audit path)", async () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + const auditLog = makeAuditLog(); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + auditLog, + }); + + await expect(wired(5)).resolves.toBe(10); + }); +}); + +describe("wireUseCase — span-name derivation", () => { + it("derives span name as . and uses layer as op", async () => { + const { tracer, calls } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("auth.signIn"); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "auth", + layer: "use-case", + name: "signIn", + tracer, + logger, + }); + + await wired(1); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ name: "auth.signIn", op: "use-case" }); + }); +}); + +describe("wireUseCase — capture-tag structure", () => { + it("captures with { feature, layer, name: '.' } when an error is thrown", async () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("blog.getArticles"); + const err = new Error("boom"); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: () => async () => { + throw err; + }, + deps: [], + feature: "blog", + layer: "use-case", + name: "getArticles", + tracer, + logger, + }); + + await expect(wired()).rejects.toBe(err); + expect(logger.captureException).toHaveBeenCalledWith(err, { + tags: { feature: "blog", layer: "use-case", name: "blog.getArticles" }, + }); + }); +}); + +describe("wireUseCase — container binding", () => { + it("binds the wired value to the container symbol", () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + + const wired = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + }); + + expect(container.get(sym)).toBe(wired); + }); + + it("passes deps tuple to the factory", async () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.scale"); + + const scaleFactory = (multiplier: number) => async (x: number) => + x * multiplier; + + const wired = wireUseCase({ + container, + symbol: sym, + factory: scaleFactory, + deps: [3] as [number], + feature: "test", + layer: "use-case", + name: "scale", + tracer, + logger, + }); + + await expect(wired(4)).resolves.toBe(12); + }); +}); + +describe("wireUseCase — idempotent re-bind", () => { + it("unbinds the existing binding and replaces it when the symbol is already bound", () => { + const { tracer } = makeTracer(); + const logger = makeLogger(); + const container = new Container(); + const sym = Symbol("test.double"); + + wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + }); + + const second = wireUseCase({ + container, + symbol: sym, + factory: doubleFactory, + deps: [], + feature: "test", + layer: "use-case", + name: "double", + tracer, + logger, + }); + + expect(container.get(sym)).toBe(second); + }); +}); diff --git a/packages/core-shared/src/conformance/wire-use-case.ts b/packages/core-shared/src/conformance/wire-use-case.ts new file mode 100644 index 0000000..cfee8b4 --- /dev/null +++ b/packages/core-shared/src/conformance/wire-use-case.ts @@ -0,0 +1,83 @@ +import type { Container } from "inversify"; +import type { ITracer } from "../instrumentation/tracer.interface"; +import type { ILogger } from "../instrumentation/logger.interface"; +import type { AuditLogProtocol } from "../di/bind-protocols"; +import { withSpan } from "../instrumentation/with-span"; +import { withCapture } from "../instrumentation/with-capture"; +import { attachBrand } from "./brand-runtime"; + +export type WireUseCaseOptions< + Deps extends unknown[], + FnArgs extends unknown[], + R, +> = { + container: Container; + symbol: symbol; + factory: (...deps: Deps) => (...args: FnArgs) => Promise; + deps: Deps; + feature: string; + layer: string; + name: string; + tracer: ITracer; + logger: ILogger; + auditLog?: AuditLogProtocol; +}; + +/** + * Encapsulates the withSpan(withCapture(withAudit?(factory(deps)))) composition + * and performs the container bind step. Callers pass options and get back a + * brand-stacked wired value that is also bound to the container symbol. + * + * Idempotent: if the symbol is already bound, the old binding is replaced. + * + * withAudit lives in @repo/core-audit which core-shared cannot import (dependency + * inversion: core-audit depends on core-shared, not vice versa). The audit branch + * here replicates the same semantics — forwarding wrapper + __audited brand — + * without introducing the circular dependency. + */ +export function wireUseCase< + Deps extends unknown[], + FnArgs extends unknown[], + R, +>(opts: WireUseCaseOptions): (...args: FnArgs) => Promise { + const { + container, + symbol, + factory, + deps, + feature, + layer, + name, + tracer, + logger, + auditLog, + } = opts; + + const spanName = `${feature}.${name}`; + const captureTags = { feature, layer, name: spanName }; + + const raw = factory(...deps); + + let toWrap: (...args: FnArgs) => Promise; + if (auditLog !== undefined) { + void auditLog; // reserved for future automated audit recording from manifest declarations + const audited: (...args: FnArgs) => Promise = (...args) => raw(...args); + attachBrand(audited, "__audited"); + toWrap = audited; + } else { + toWrap = raw; + } + + const wired = withSpan( + tracer, + { name: spanName, op: layer }, + withCapture(logger, captureTags, toWrap), + ); + + if (container.isBound(symbol)) { + container.unbind(symbol); + } + container.bind(symbol).toConstantValue(wired); + + return wired; +}