diff --git a/packages/workspaces/src/__contracts__/workspace-repository.contract.ts b/packages/workspaces/src/__contracts__/workspace-repository.contract.ts index 68682fe..5b40238 100644 --- a/packages/workspaces/src/__contracts__/workspace-repository.contract.ts +++ b/packages/workspaces/src/__contracts__/workspace-repository.contract.ts @@ -69,6 +69,29 @@ export const workspaceRepositoryContract = expect(result).toBeNull(); }); + describe("listWorkspaces", () => { + it("returns every seeded workspace", async () => { + const result = await repo.listWorkspaces(); + expect(result.map((w) => w.id).sort()).toEqual(["seed-1", "seed-2"]); + }); + + it("includes newly created workspaces with their persisted status", async () => { + const created = await repo.createWorkspace(CREATE_DATA); + const result = await repo.listWorkspaces(); + const listed = result.find((w) => w.id === created.id); + expect(listed).toEqual(created); + }); + + it("never returns credentials", async () => { + await repo.createWorkspace(CREATE_DATA); + const result = await repo.listWorkspaces(); + for (const workspace of result) { + expect(workspace).not.toHaveProperty("credential"); + } + expect(JSON.stringify(result)).not.toContain(CONTRACT_WORKSPACE_PAT); + }); + }); + describe("createWorkspace", () => { it("creates a workspace with initial status 'created'", async () => { const created = await repo.createWorkspace(CREATE_DATA); diff --git a/packages/workspaces/src/application/repositories/workspace.repository.interface.ts b/packages/workspaces/src/application/repositories/workspace.repository.interface.ts index a7dff7f..4f29a3d 100644 --- a/packages/workspaces/src/application/repositories/workspace.repository.interface.ts +++ b/packages/workspaces/src/application/repositories/workspace.repository.interface.ts @@ -14,6 +14,8 @@ export type CreateWorkspaceData = { export interface IWorkspaceRepository { getWorkspace(id: string): Promise; + /** Returns every persisted workspace (credential-free by construction). */ + listWorkspaces(): Promise; /** Persists a new workspace with initial status "created". */ createWorkspace(data: CreateWorkspaceData): Promise; /** diff --git a/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts b/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts index 20166c1..043eab5 100644 --- a/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts +++ b/packages/workspaces/src/application/use-cases/connect-workspace.use-case.test.ts @@ -48,6 +48,7 @@ describe("connectWorkspaceUseCase", () => { const leakyRepo = { getWorkspace: async () => null, getDecryptedCredential: async () => null, + listWorkspaces: async () => [], createWorkspace: async () => ({ id: "ws-1", name: "Acme Web", @@ -101,6 +102,7 @@ describe("connectWorkspaceUseCase", () => { const failingRepo = { getWorkspace: async () => null, getDecryptedCredential: async () => null, + listWorkspaces: async () => [], createWorkspace: async () => { throw new Error("boom"); }, @@ -123,6 +125,7 @@ describe("connectWorkspaceUseCase", () => { const malformedRepo = { getWorkspace: async () => null, getDecryptedCredential: async () => null, + listWorkspaces: async () => [], createWorkspace: async () => ({ id: "", name: "x" }) as never, }; const useCase = connectWorkspaceUseCase(malformedRepo); diff --git a/packages/workspaces/src/application/use-cases/get-workspace-status.use-case.test.ts b/packages/workspaces/src/application/use-cases/get-workspace-status.use-case.test.ts new file mode 100644 index 0000000..14f976e --- /dev/null +++ b/packages/workspaces/src/application/use-cases/get-workspace-status.use-case.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; +import { getWorkspaceStatusUseCase } from "@/application/use-cases/get-workspace-status.use-case"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import { WorkspaceNotFoundError } from "@/entities/errors/workspace"; +import type { Workspace } from "@/entities/models/workspace"; + +const SEED = new Map([ + [ + "ws-1", + { + id: "ws-1", + name: "One", + gitUrl: "https://github.com/acme/one.git", + status: "connecting", + }, + ], +]); + +describe("getWorkspaceStatusUseCase", () => { + it("returns the persisted status for an existing workspace", async () => { + const repo = new MockWorkspaceRepository(new Map(SEED)); + const useCase = getWorkspaceStatusUseCase(repo); + + const result = await useCase({ id: "ws-1" }); + + expect(result).toEqual({ id: "ws-1", status: "connecting" }); + }); + + it("returns only id + status — no other workspace fields, never a credential", async () => { + const repo = new MockWorkspaceRepository(new Map(SEED)); + const useCase = getWorkspaceStatusUseCase(repo); + + const result = await useCase({ id: "ws-1" }); + + expect(Object.keys(result).sort()).toEqual(["id", "status"]); + }); + + it("throws WorkspaceNotFoundError for an unknown id", async () => { + const repo = new MockWorkspaceRepository(new Map()); + const useCase = getWorkspaceStatusUseCase(repo); + + await expect(useCase({ id: "missing" })).rejects.toBeInstanceOf( + WorkspaceNotFoundError, + ); + }); + + it("throws ZodError when the repository returns a malformed status", async () => { + const malformedRepo = { + getWorkspace: async () => + ({ id: "ws-1", status: "not-a-status" }) as never, + createWorkspace: async () => ({}) as never, + listWorkspaces: async () => [], + getDecryptedCredential: async () => null, + }; + const useCase = getWorkspaceStatusUseCase(malformedRepo); + + await expect(useCase({ id: "ws-1" })).rejects.toBeInstanceOf(ZodError); + }); +}); diff --git a/packages/workspaces/src/application/use-cases/get-workspace-status.use-case.ts b/packages/workspaces/src/application/use-cases/get-workspace-status.use-case.ts new file mode 100644 index 0000000..0e14cf0 --- /dev/null +++ b/packages/workspaces/src/application/use-cases/get-workspace-status.use-case.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +import { WorkspaceNotFoundError } from "../../entities/errors/workspace"; +import { workspaceSchema } from "../../entities/models/workspace"; +import type { IWorkspaceRepository } from "../repositories/workspace.repository.interface"; + +// ── Input ──────────────────────────────────────────────────────────────── +export const getWorkspaceStatusInputSchema = z + .object({ + id: z.string().min(1), + }) + .strict(); +export type GetWorkspaceStatusInput = z.infer< + typeof getWorkspaceStatusInputSchema +>; + +// ── Output ─────────────────────────────────────────────────────────────── +// Persisted state only in this story — runner-driven transitions (story 07) +// extend the status enum, not this shape. +export const getWorkspaceStatusOutputSchema = workspaceSchema.pick({ + id: true, + status: true, +}); +export type GetWorkspaceStatusOutput = z.infer< + typeof getWorkspaceStatusOutputSchema +>; + +// ── Use case ───────────────────────────────────────────────────────────── +export type IGetWorkspaceStatusUseCase = ReturnType< + typeof getWorkspaceStatusUseCase +>; + +export const getWorkspaceStatusUseCase = + (workspaceRepository: IWorkspaceRepository) => + async (input: GetWorkspaceStatusInput): Promise => { + const workspace = await workspaceRepository.getWorkspace(input.id); + if (!workspace) { + throw new WorkspaceNotFoundError(`Workspace not found: ${input.id}`); + } + return getWorkspaceStatusOutputSchema.parse({ + id: workspace.id, + status: workspace.status, + }); + }; diff --git a/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts b/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts index 3d748ce..11183f1 100644 --- a/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts +++ b/packages/workspaces/src/application/use-cases/get-workspace.use-case.test.ts @@ -26,6 +26,7 @@ describe("getWorkspaceUseCase", () => { getWorkspace: async () => ({ id: "", name: "x" }) as never, createWorkspace: async () => ({ id: "", name: "x" }) as never, getDecryptedCredential: async () => null, + listWorkspaces: async () => [], }; const useCase = getWorkspaceUseCase(malformedRepo); await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError); diff --git a/packages/workspaces/src/application/use-cases/list-workspaces.use-case.test.ts b/packages/workspaces/src/application/use-cases/list-workspaces.use-case.test.ts new file mode 100644 index 0000000..ea6d2a5 --- /dev/null +++ b/packages/workspaces/src/application/use-cases/list-workspaces.use-case.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { ZodError } from "zod"; +import { listWorkspacesUseCase } from "@/application/use-cases/list-workspaces.use-case"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import { CONTRACT_WORKSPACE_SEED } from "@/__contracts__/workspace-repository.contract"; + +describe("listWorkspacesUseCase", () => { + it("returns every persisted workspace", async () => { + const repo = new MockWorkspaceRepository(new Map(CONTRACT_WORKSPACE_SEED)); + const useCase = listWorkspacesUseCase(repo); + + const result = await useCase({}); + + expect(result.map((w) => w.id).sort()).toEqual(["seed-1", "seed-2"]); + }); + + it("returns an empty array when nothing is persisted", async () => { + const repo = new MockWorkspaceRepository(new Map()); + const useCase = listWorkspacesUseCase(repo); + + await expect(useCase({})).resolves.toEqual([]); + }); + + it("includes newly connected workspaces with their persisted status", async () => { + const repo = new MockWorkspaceRepository(new Map()); + const created = await repo.createWorkspace({ + name: "Acme Web", + gitUrl: "https://github.com/acme/web.git", + credential: "ghp_secret", + }); + const useCase = listWorkspacesUseCase(repo); + + const result = await useCase({}); + + expect(result).toEqual([created]); + expect(result[0]!.status).toBe("created"); + }); + + it("never includes credentials in the output", async () => { + const repo = new MockWorkspaceRepository(new Map()); + await repo.createWorkspace({ + name: "Acme Web", + gitUrl: "https://github.com/acme/web.git", + credential: "ghp_secret", + }); + const useCase = listWorkspacesUseCase(repo); + + const result = await useCase({}); + + expect(JSON.stringify(result)).not.toContain("ghp_secret"); + expect(result[0]).not.toHaveProperty("credential"); + }); + + it("throws ZodError when the repository returns malformed data", async () => { + const malformedRepo = { + getWorkspace: async () => null, + createWorkspace: async () => ({}) as never, + listWorkspaces: async () => [{ id: "", name: "x" }] as never, + getDecryptedCredential: async () => null, + }; + const useCase = listWorkspacesUseCase(malformedRepo); + + await expect(useCase({})).rejects.toBeInstanceOf(ZodError); + }); +}); diff --git a/packages/workspaces/src/application/use-cases/list-workspaces.use-case.ts b/packages/workspaces/src/application/use-cases/list-workspaces.use-case.ts new file mode 100644 index 0000000..1a215db --- /dev/null +++ b/packages/workspaces/src/application/use-cases/list-workspaces.use-case.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { workspaceSchema } from "../../entities/models/workspace"; +import type { IWorkspaceRepository } from "../repositories/workspace.repository.interface"; + +// ── Input ──────────────────────────────────────────────────────────────── +// Void input — no filters in this story. +export const listWorkspacesInputSchema = z.object({}).strict(); +export type ListWorkspacesInput = z.infer; + +// ── Output ─────────────────────────────────────────────────────────────── +// Array of credential-free workspace entities (persisted state only). +export const listWorkspacesOutputSchema = z.array(workspaceSchema); +export type ListWorkspacesOutput = z.infer; + +// ── Use case ───────────────────────────────────────────────────────────── +export type IListWorkspacesUseCase = ReturnType; + +export const listWorkspacesUseCase = + (workspaceRepository: IWorkspaceRepository) => + async (_input: ListWorkspacesInput): Promise => { + const workspaces = await workspaceRepository.listWorkspaces(); + return listWorkspacesOutputSchema.parse(workspaces); + }; diff --git a/packages/workspaces/src/di/bind-controller.ts b/packages/workspaces/src/di/bind-controller.ts new file mode 100644 index 0000000..b823e5f --- /dev/null +++ b/packages/workspaces/src/di/bind-controller.ts @@ -0,0 +1,43 @@ +import { + withSpan, + withCapture, + type ITracer, + type ILogger, +} from "@repo/core-shared/instrumentation"; +import { workspacesContainer } from "./container"; + +/** + * Binds a controller wrapped with the span + capture sandwich at bind time + * (withSpan outermost, per template convention). Shared by bind-production + * and bind-dev-seed so the wrapping stays identical in both modes. + * + * Idempotent: rebinding replaces the previous constant value. + */ +export function bindWorkspacesController( + tracer: ITracer, + logger: ILogger, + symbol: symbol, + name: string, + controller: (...args: Args) => Promise, +): void { + if (workspacesContainer.isBound(symbol)) { + workspacesContainer.unbind(symbol); + } + workspacesContainer + .bind(symbol) + .toConstantValue( + withSpan( + tracer, + { name: `workspaces.${name}`, op: "controller" }, + withCapture( + logger, + { + feature: "workspaces", + layer: "controller", + name: `workspaces.${name}`, + }, + controller, + ), + ), + ); +} diff --git a/packages/workspaces/src/di/bind-dev-seed.ts b/packages/workspaces/src/di/bind-dev-seed.ts index 073f86c..105bc73 100644 --- a/packages/workspaces/src/di/bind-dev-seed.ts +++ b/packages/workspaces/src/di/bind-dev-seed.ts @@ -1,6 +1,4 @@ import { - withSpan, - withCapture, INSTRUMENTATION_SYMBOLS, type ITracer, type ILogger, @@ -12,13 +10,18 @@ import { } from "@repo/core-shared/conformance"; import { workspacesManifest } from "../feature.manifest"; import { workspacesContainer } from "./container"; +import { bindWorkspacesController } from "./bind-controller"; import { WORKSPACES_SYMBOLS } from "./symbols"; import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock"; import { buildDevWorkspaceMap } from "../__seeds__/dev"; import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case"; import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case"; +import { getWorkspaceStatusUseCase } from "../application/use-cases/get-workspace-status.use-case"; +import { listWorkspacesUseCase } from "../application/use-cases/list-workspaces.use-case"; import { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller"; import { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller"; +import { getWorkspaceStatusController } from "../interface-adapters/controllers/get-workspace-status.controller"; +import { listWorkspacesController } from "../interface-adapters/controllers/list-workspaces.controller"; import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface"; /** @@ -87,49 +90,60 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise { logger, }); - if ( - workspacesContainer.isBound(WORKSPACES_SYMBOLS.IConnectWorkspaceController) - ) { - workspacesContainer.unbind(WORKSPACES_SYMBOLS.IConnectWorkspaceController); - } - workspacesContainer - .bind(WORKSPACES_SYMBOLS.IConnectWorkspaceController) - .toConstantValue( - withSpan( - tracer, - { name: "workspaces.connectWorkspace", op: "controller" }, - withCapture( - logger, - { - feature: "workspaces", - layer: "controller", - name: "workspaces.connectWorkspace", - }, - connectWorkspaceController(wrappedConnectWorkspace), - ), - ), - ); + const wrappedGetWorkspaceStatus = wireUseCase({ + container: workspacesContainer, + symbol: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase, + factory: getWorkspaceStatusUseCase, + deps: [repo], + feature: "workspaces", + layer: "use-case", + name: "getWorkspaceStatus", + tracer, + logger, + }); + + const wrappedListWorkspaces = wireUseCase({ + container: workspacesContainer, + symbol: WORKSPACES_SYMBOLS.IListWorkspacesUseCase, + factory: listWorkspacesUseCase, + deps: [repo], + feature: "workspaces", + layer: "use-case", + name: "listWorkspaces", + tracer, + logger, + }); + + // Controllers — wrapped with the span + capture sandwich at bind time + bindWorkspacesController( + tracer, + logger, + WORKSPACES_SYMBOLS.IConnectWorkspaceController, + "connectWorkspace", + connectWorkspaceController(wrappedConnectWorkspace), + ); + bindWorkspacesController( + tracer, + logger, + WORKSPACES_SYMBOLS.IGetWorkspaceController, + "getWorkspace", + getWorkspaceController(wrappedGetWorkspace), + ); + bindWorkspacesController( + tracer, + logger, + WORKSPACES_SYMBOLS.IGetWorkspaceStatusController, + "getWorkspaceStatus", + getWorkspaceStatusController(wrappedGetWorkspaceStatus), + ); + bindWorkspacesController( + tracer, + logger, + WORKSPACES_SYMBOLS.IListWorkspacesController, + "listWorkspaces", + listWorkspacesController(wrappedListWorkspaces), + ); - if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) { - workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController); - } - workspacesContainer - .bind(WORKSPACES_SYMBOLS.IGetWorkspaceController) - .toConstantValue( - withSpan( - tracer, - { name: "workspaces.getWorkspace", op: "controller" }, - withCapture( - logger, - { - feature: "workspaces", - layer: "controller", - name: "workspaces.getWorkspace", - }, - getWorkspaceController(wrappedGetWorkspace), - ), - ), - ); // bus + queue are passed through; generated handlers consume them at the anchors below. void bus; void queue; @@ -146,6 +160,8 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise { { connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase, getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + getWorkspaceStatus: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase, + listWorkspaces: WORKSPACES_SYMBOLS.IListWorkspacesUseCase, }, ctx, ); diff --git a/packages/workspaces/src/di/bind-production.ts b/packages/workspaces/src/di/bind-production.ts index 654d37e..0e8d466 100644 --- a/packages/workspaces/src/di/bind-production.ts +++ b/packages/workspaces/src/di/bind-production.ts @@ -1,6 +1,4 @@ import { - withSpan, - withCapture, INSTRUMENTATION_SYMBOLS, type ITracer, type ILogger, @@ -11,14 +9,19 @@ import { wireUseCase, } from "@repo/core-shared/conformance"; import { workspacesContainer } from "./container"; +import { bindWorkspacesController } from "./bind-controller"; import { WORKSPACES_SYMBOLS } from "./symbols"; import { workspacesManifest } from "../feature.manifest"; import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository"; import { requireVeectSecret } from "../infrastructure/crypto/credential-cipher"; import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case"; import { getWorkspaceUseCase } from "../application/use-cases/get-workspace.use-case"; +import { getWorkspaceStatusUseCase } from "../application/use-cases/get-workspace-status.use-case"; +import { listWorkspacesUseCase } from "../application/use-cases/list-workspaces.use-case"; import { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller"; import { getWorkspaceController } from "../interface-adapters/controllers/get-workspace.controller"; +import { getWorkspaceStatusController } from "../interface-adapters/controllers/get-workspace-status.controller"; +import { listWorkspacesController } from "../interface-adapters/controllers/list-workspaces.controller"; export function bindProductionWorkspaces(ctx: BindProductionContext): void { const { @@ -89,50 +92,60 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void { logger, }); - // Controllers — wrapped with span at bind time - if ( - workspacesContainer.isBound(WORKSPACES_SYMBOLS.IConnectWorkspaceController) - ) { - workspacesContainer.unbind(WORKSPACES_SYMBOLS.IConnectWorkspaceController); - } - workspacesContainer - .bind(WORKSPACES_SYMBOLS.IConnectWorkspaceController) - .toConstantValue( - withSpan( - tracer, - { name: "workspaces.connectWorkspace", op: "controller" }, - withCapture( - logger, - { - feature: "workspaces", - layer: "controller", - name: "workspaces.connectWorkspace", - }, - connectWorkspaceController(wrappedConnectWorkspace), - ), - ), - ); + const wrappedGetWorkspaceStatus = wireUseCase({ + container: workspacesContainer, + symbol: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase, + factory: getWorkspaceStatusUseCase, + deps: [repo], + feature: "workspaces", + layer: "use-case", + name: "getWorkspaceStatus", + tracer, + logger, + }); + + const wrappedListWorkspaces = wireUseCase({ + container: workspacesContainer, + symbol: WORKSPACES_SYMBOLS.IListWorkspacesUseCase, + factory: listWorkspacesUseCase, + deps: [repo], + feature: "workspaces", + layer: "use-case", + name: "listWorkspaces", + tracer, + logger, + }); + + // Controllers — wrapped with the span + capture sandwich at bind time + bindWorkspacesController( + tracer, + logger, + WORKSPACES_SYMBOLS.IConnectWorkspaceController, + "connectWorkspace", + connectWorkspaceController(wrappedConnectWorkspace), + ); + bindWorkspacesController( + tracer, + logger, + WORKSPACES_SYMBOLS.IGetWorkspaceController, + "getWorkspace", + getWorkspaceController(wrappedGetWorkspace), + ); + bindWorkspacesController( + tracer, + logger, + WORKSPACES_SYMBOLS.IGetWorkspaceStatusController, + "getWorkspaceStatus", + getWorkspaceStatusController(wrappedGetWorkspaceStatus), + ); + bindWorkspacesController( + tracer, + logger, + WORKSPACES_SYMBOLS.IListWorkspacesController, + "listWorkspaces", + listWorkspacesController(wrappedListWorkspaces), + ); - if (workspacesContainer.isBound(WORKSPACES_SYMBOLS.IGetWorkspaceController)) { - workspacesContainer.unbind(WORKSPACES_SYMBOLS.IGetWorkspaceController); - } - workspacesContainer - .bind(WORKSPACES_SYMBOLS.IGetWorkspaceController) - .toConstantValue( - withSpan( - tracer, - { name: "workspaces.getWorkspace", op: "controller" }, - withCapture( - logger, - { - feature: "workspaces", - layer: "controller", - name: "workspaces.getWorkspace", - }, - getWorkspaceController(wrappedGetWorkspace), - ), - ), - ); // bus + queue are passed through; generated handlers consume them at the anchors below. void bus; void queue; @@ -150,6 +163,8 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void { { connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase, getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase, + getWorkspaceStatus: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase, + listWorkspaces: WORKSPACES_SYMBOLS.IListWorkspacesUseCase, }, ctx, ); diff --git a/packages/workspaces/src/di/container.test.ts b/packages/workspaces/src/di/container.test.ts index 80f737f..1eba548 100644 --- a/packages/workspaces/src/di/container.test.ts +++ b/packages/workspaces/src/di/container.test.ts @@ -6,8 +6,12 @@ import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface"; import type { IConnectWorkspaceUseCase } from "@/application/use-cases/connect-workspace.use-case"; import type { IGetWorkspaceUseCase } from "@/application/use-cases/get-workspace.use-case"; +import type { IGetWorkspaceStatusUseCase } from "@/application/use-cases/get-workspace-status.use-case"; +import type { IListWorkspacesUseCase } from "@/application/use-cases/list-workspaces.use-case"; import type { IConnectWorkspaceController } from "@/interface-adapters/controllers/connect-workspace.controller"; import type { IGetWorkspaceController } from "@/interface-adapters/controllers/get-workspace.controller"; +import type { IGetWorkspaceStatusController } from "@/interface-adapters/controllers/get-workspace-status.controller"; +import type { IListWorkspacesController } from "@/interface-adapters/controllers/list-workspaces.controller"; describe("workspacesContainer", () => { beforeEach(() => { @@ -53,4 +57,26 @@ describe("workspacesContainer", () => { ); expect(typeof controller).toBe("function"); }); + + it("resolves IGetWorkspaceStatusUseCase and its controller as functions", () => { + const useCase = workspacesContainer.get( + WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase, + ); + const controller = workspacesContainer.get( + WORKSPACES_SYMBOLS.IGetWorkspaceStatusController, + ); + expect(typeof useCase).toBe("function"); + expect(typeof controller).toBe("function"); + }); + + it("resolves IListWorkspacesUseCase and its controller as functions", () => { + const useCase = workspacesContainer.get( + WORKSPACES_SYMBOLS.IListWorkspacesUseCase, + ); + const controller = workspacesContainer.get( + WORKSPACES_SYMBOLS.IListWorkspacesController, + ); + expect(typeof useCase).toBe("function"); + expect(typeof controller).toBe("function"); + }); }); diff --git a/packages/workspaces/src/di/module.ts b/packages/workspaces/src/di/module.ts index c00dd05..bcc0097 100644 --- a/packages/workspaces/src/di/module.ts +++ b/packages/workspaces/src/di/module.ts @@ -10,6 +10,14 @@ import { getWorkspaceUseCase, type IGetWorkspaceUseCase, } from "../application/use-cases/get-workspace.use-case"; +import { + getWorkspaceStatusUseCase, + type IGetWorkspaceStatusUseCase, +} from "../application/use-cases/get-workspace-status.use-case"; +import { + listWorkspacesUseCase, + type IListWorkspacesUseCase, +} from "../application/use-cases/list-workspaces.use-case"; import { connectWorkspaceController, type IConnectWorkspaceController, @@ -18,6 +26,14 @@ import { getWorkspaceController, type IGetWorkspaceController, } from "../interface-adapters/controllers/get-workspace.controller"; +import { + getWorkspaceStatusController, + type IGetWorkspaceStatusController, +} from "../interface-adapters/controllers/get-workspace-status.controller"; +import { + listWorkspacesController, + type IListWorkspacesController, +} from "../interface-adapters/controllers/list-workspaces.controller"; import { WORKSPACES_SYMBOLS } from "./symbols"; export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => { @@ -45,6 +61,26 @@ export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => { ), ); + bind( + WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase, + ).toDynamicValue((ctx) => + getWorkspaceStatusUseCase( + ctx.container.get( + WORKSPACES_SYMBOLS.IWorkspaceRepository, + ), + ), + ); + + bind( + WORKSPACES_SYMBOLS.IListWorkspacesUseCase, + ).toDynamicValue((ctx) => + listWorkspacesUseCase( + ctx.container.get( + WORKSPACES_SYMBOLS.IWorkspaceRepository, + ), + ), + ); + bind( WORKSPACES_SYMBOLS.IConnectWorkspaceController, ).toDynamicValue((ctx) => @@ -64,4 +100,24 @@ export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => { ), ), ); + + bind( + WORKSPACES_SYMBOLS.IGetWorkspaceStatusController, + ).toDynamicValue((ctx) => + getWorkspaceStatusController( + ctx.container.get( + WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase, + ), + ), + ); + + bind( + WORKSPACES_SYMBOLS.IListWorkspacesController, + ).toDynamicValue((ctx) => + listWorkspacesController( + ctx.container.get( + WORKSPACES_SYMBOLS.IListWorkspacesUseCase, + ), + ), + ); }); diff --git a/packages/workspaces/src/di/symbols.ts b/packages/workspaces/src/di/symbols.ts index c8e4de7..3a5afd8 100644 --- a/packages/workspaces/src/di/symbols.ts +++ b/packages/workspaces/src/di/symbols.ts @@ -3,11 +3,19 @@ export const WORKSPACES_SYMBOLS = { // Use cases IConnectWorkspaceUseCase: Symbol.for("workspaces:IConnectWorkspaceUseCase"), IGetWorkspaceUseCase: Symbol.for("workspaces:IGetWorkspaceUseCase"), + IGetWorkspaceStatusUseCase: Symbol.for( + "workspaces:IGetWorkspaceStatusUseCase", + ), + IListWorkspacesUseCase: Symbol.for("workspaces:IListWorkspacesUseCase"), // Controllers IConnectWorkspaceController: Symbol.for( "workspaces:IConnectWorkspaceController", ), IGetWorkspaceController: Symbol.for("workspaces:IGetWorkspaceController"), + IGetWorkspaceStatusController: Symbol.for( + "workspaces:IGetWorkspaceStatusController", + ), + IListWorkspacesController: Symbol.for("workspaces:IListWorkspacesController"), // // // diff --git a/packages/workspaces/src/feature.manifest.ts b/packages/workspaces/src/feature.manifest.ts index 061d054..8997d7c 100644 --- a/packages/workspaces/src/feature.manifest.ts +++ b/packages/workspaces/src/feature.manifest.ts @@ -29,6 +29,20 @@ export const workspacesManifest = defineFeature({ consumes: [], analyticsEvents: [], }, + getWorkspaceStatus: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + analyticsEvents: [], + }, + listWorkspaces: { + mutates: false, + audits: [], + publishes: [], + consumes: [], + analyticsEvents: [], + }, }, realtimeChannels: [], jobs: [], diff --git a/packages/workspaces/src/index.ts b/packages/workspaces/src/index.ts index 8eddce1..e5e3dd8 100644 --- a/packages/workspaces/src/index.ts +++ b/packages/workspaces/src/index.ts @@ -18,10 +18,26 @@ export { type GetWorkspaceOutput, type IGetWorkspaceUseCase, } from "./application/use-cases/get-workspace.use-case"; +export { + getWorkspaceStatusInputSchema, + getWorkspaceStatusOutputSchema, + type GetWorkspaceStatusInput, + type GetWorkspaceStatusOutput, + type IGetWorkspaceStatusUseCase, +} from "./application/use-cases/get-workspace-status.use-case"; +export { + listWorkspacesInputSchema, + listWorkspacesOutputSchema, + type ListWorkspacesInput, + type ListWorkspacesOutput, + type IListWorkspacesUseCase, +} from "./application/use-cases/list-workspaces.use-case"; // Controller type aliases export type { IConnectWorkspaceController } from "./interface-adapters/controllers/connect-workspace.controller"; export type { IGetWorkspaceController } from "./interface-adapters/controllers/get-workspace.controller"; +export type { IGetWorkspaceStatusController } from "./interface-adapters/controllers/get-workspace-status.controller"; +export type { IListWorkspacesController } from "./interface-adapters/controllers/list-workspaces.controller"; // // diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts index 021757e..9c66239 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.mock.ts @@ -69,6 +69,17 @@ export class MockWorkspaceRepository implements IWorkspaceRepository { ); } + async listWorkspaces(): Promise { + return this.tracer.startSpan( + { name: "workspace.listWorkspaces", op: "repository", attributes: {} }, + async (span) => { + const all = Array.from(this.data.values()); + span.setAttribute("count", all.length); + return all; + }, + ); + } + async createWorkspace(data: CreateWorkspaceData): Promise { return this.tracer.startSpan( { name: "workspace.createWorkspace", op: "repository", attributes: {} }, diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts index d306aa8..38d6d86 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.test.ts @@ -73,6 +73,10 @@ function buildPayloadStub( async ({ id }: { collection: string; id: string }) => store.get(String(id)) ?? null, ), + find: vi.fn(async ({ limit }: { collection: string; limit?: number }) => { + const docs = Array.from(store.values()).slice(0, limit ?? 10); + return { docs }; + }), __store: store, }; } @@ -168,6 +172,7 @@ describe("WorkspaceRepository", () => { it.each([ ["getWorkspace", (r: WorkspaceRepository) => r.getWorkspace("x")], + ["listWorkspaces", (r: WorkspaceRepository) => r.listWorkspaces()], [ "createWorkspace", (r: WorkspaceRepository) => @@ -186,6 +191,9 @@ describe("WorkspaceRepository", () => { findByID: vi.fn(async () => { throw new Error("infra boom"); }), + find: vi.fn(async () => { + throw new Error("infra boom"); + }), create: vi.fn(async () => { throw new Error("infra boom"); }), diff --git a/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts b/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts index 523c67a..df14346 100644 --- a/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts +++ b/packages/workspaces/src/infrastructure/repositories/workspace.repository.ts @@ -77,6 +77,35 @@ export class WorkspaceRepository implements IWorkspaceRepository { ); } + async listWorkspaces(): Promise { + return this.tracer.startSpan( + { name: "workspace.listWorkspaces", op: "repository", attributes: {} }, + async (span) => { + try { + const payload = await getPayload({ config: this.config }); + const { docs } = await payload.find({ + collection: "workspaces", + limit: 100, + overrideAccess: true, + }); + span.setAttribute("count", docs.length); + return docs.map((doc) => + this.toDomain(doc as Record), + ); + } catch (err) { + this.logger.captureException(err, { + tags: { feature: FEATURE, repo: REPO, method: "listWorkspaces" }, + }); + span.setStatus( + "error", + err instanceof Error ? err.message : String(err), + ); + throw err; + } + }, + ); + } + async createWorkspace(data: CreateWorkspaceData): Promise { return this.tracer.startSpan( { name: "workspace.createWorkspace", op: "repository", attributes: {} }, diff --git a/packages/workspaces/src/integrations/api/router.test.ts b/packages/workspaces/src/integrations/api/router.test.ts index 7fb8bd4..c7c39af 100644 --- a/packages/workspaces/src/integrations/api/router.test.ts +++ b/packages/workspaces/src/integrations/api/router.test.ts @@ -18,10 +18,16 @@ describe("workspacesRouter", () => { workspacesContainer.unbindAll(); }); - it("exposes the getWorkspace and connectWorkspace procedures", () => { + it("exposes the connectWorkspace, getWorkspace, getWorkspaceStatus, and listWorkspaces procedures", () => { const names = Object.keys(workspacesRouter._def.procedures); - expect(names).toContain("getWorkspace"); - expect(names).toContain("connectWorkspace"); + expect(names).toEqual( + expect.arrayContaining([ + "connectWorkspace", + "getWorkspace", + "getWorkspaceStatus", + "listWorkspaces", + ]), + ); }); it("getWorkspace returns the seeded workspace", async () => { @@ -40,6 +46,18 @@ describe("workspacesRouter", () => { expect(result.status).toBe("created"); expect(JSON.stringify(result)).not.toContain("ghp_super-secret-token"); }); + + it("getWorkspaceStatus returns the persisted status only", async () => { + const caller = workspacesRouter.createCaller({}); + const result = await caller.getWorkspaceStatus({ id: "seed-1" }); + expect(result).toEqual({ id: "seed-1", status: "ready" }); + }); + + it("listWorkspaces returns the seeded workspaces without an input", async () => { + const caller = workspacesRouter.createCaller({}); + const result = await caller.listWorkspaces(); + expect(result.map((w) => w.id).sort()).toEqual(["seed-1", "seed-2"]); + }); }); describe("workspacesRouter (error mapping)", () => { diff --git a/packages/workspaces/src/integrations/api/router.ts b/packages/workspaces/src/integrations/api/router.ts index b0b6e57..8687e37 100644 --- a/packages/workspaces/src/integrations/api/router.ts +++ b/packages/workspaces/src/integrations/api/router.ts @@ -5,8 +5,12 @@ import { WORKSPACES_SYMBOLS } from "../../di/symbols"; import { connectWorkspaceInputSchema } from "../../application/use-cases/connect-workspace.use-case"; import { getWorkspaceInputSchema } from "../../application/use-cases/get-workspace.use-case"; +import { getWorkspaceStatusInputSchema } from "../../application/use-cases/get-workspace-status.use-case"; +import { listWorkspacesInputSchema } from "../../application/use-cases/list-workspaces.use-case"; import type { IConnectWorkspaceController } from "../../interface-adapters/controllers/connect-workspace.controller"; import type { IGetWorkspaceController } from "../../interface-adapters/controllers/get-workspace.controller"; +import type { IGetWorkspaceStatusController } from "../../interface-adapters/controllers/get-workspace-status.controller"; +import type { IListWorkspacesController } from "../../interface-adapters/controllers/list-workspaces.controller"; import { workspacesProcedure } from "./procedures"; @@ -27,6 +31,22 @@ export const workspacesRouter = router({ ); return ctrl(input); }), + getWorkspaceStatus: workspacesProcedure + .input(getWorkspaceStatusInputSchema) + .query(({ input }) => { + const ctrl = workspacesContainer.get( + WORKSPACES_SYMBOLS.IGetWorkspaceStatusController, + ); + return ctrl(input); + }), + listWorkspaces: workspacesProcedure + .input(listWorkspacesInputSchema.optional()) + .query(({ input }) => { + const ctrl = workspacesContainer.get( + WORKSPACES_SYMBOLS.IListWorkspacesController, + ); + return ctrl(input); + }), }); export type WorkspacesRouter = typeof workspacesRouter; diff --git a/packages/workspaces/src/interface-adapters/controllers/get-workspace-status.controller.test.ts b/packages/workspaces/src/interface-adapters/controllers/get-workspace-status.controller.test.ts new file mode 100644 index 0000000..1c37ac0 --- /dev/null +++ b/packages/workspaces/src/interface-adapters/controllers/get-workspace-status.controller.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { getWorkspaceStatusController } from "@/interface-adapters/controllers/get-workspace-status.controller"; +import { getWorkspaceStatusUseCase } from "@/application/use-cases/get-workspace-status.use-case"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import { InputParseError } from "@/entities/errors/common"; +import { WorkspaceNotFoundError } from "@/entities/errors/workspace"; +import type { Workspace } from "@/entities/models/workspace"; + +const SEED = new Map([ + [ + "ws-1", + { + id: "ws-1", + name: "One", + gitUrl: "https://github.com/acme/one.git", + status: "ready", + }, + ], +]); + +function buildController(seed = SEED) { + const repo = new MockWorkspaceRepository(new Map(seed)); + return getWorkspaceStatusController(getWorkspaceStatusUseCase(repo)); +} + +describe("getWorkspaceStatusController", () => { + it("returns id + status for a valid input", async () => { + const controller = buildController(); + + await expect(controller({ id: "ws-1" })).resolves.toEqual({ + id: "ws-1", + status: "ready", + }); + }); + + it("throws InputParseError when id is missing", async () => { + const controller = buildController(); + + await expect(controller({})).rejects.toBeInstanceOf(InputParseError); + }); + + it("throws InputParseError on unknown extra fields (strict schema)", async () => { + const controller = buildController(); + + await expect( + controller({ id: "ws-1", verbose: true }), + ).rejects.toBeInstanceOf(InputParseError); + }); + + it("propagates WorkspaceNotFoundError from the use case", async () => { + const controller = buildController(new Map()); + + await expect(controller({ id: "missing" })).rejects.toBeInstanceOf( + WorkspaceNotFoundError, + ); + }); +}); diff --git a/packages/workspaces/src/interface-adapters/controllers/get-workspace-status.controller.ts b/packages/workspaces/src/interface-adapters/controllers/get-workspace-status.controller.ts new file mode 100644 index 0000000..f6ba0df --- /dev/null +++ b/packages/workspaces/src/interface-adapters/controllers/get-workspace-status.controller.ts @@ -0,0 +1,27 @@ +import { InputParseError } from "../../entities/errors/common"; +import { + getWorkspaceStatusInputSchema, + type GetWorkspaceStatusOutput, + type IGetWorkspaceStatusUseCase, +} from "../../application/use-cases/get-workspace-status.use-case"; + +function presenter(value: GetWorkspaceStatusOutput) { + return value; +} + +export type IGetWorkspaceStatusController = ReturnType< + typeof getWorkspaceStatusController +>; + +export const getWorkspaceStatusController = + (getWorkspaceStatusUseCase: IGetWorkspaceStatusUseCase) => + async (input: unknown): Promise> => { + const parsed = getWorkspaceStatusInputSchema.safeParse(input); + if (!parsed.success) { + throw new InputParseError("Invalid get-workspace-status input", { + cause: parsed.error, + }); + } + const result = await getWorkspaceStatusUseCase(parsed.data); + return presenter(result); + }; diff --git a/packages/workspaces/src/interface-adapters/controllers/list-workspaces.controller.test.ts b/packages/workspaces/src/interface-adapters/controllers/list-workspaces.controller.test.ts new file mode 100644 index 0000000..05a2eaa --- /dev/null +++ b/packages/workspaces/src/interface-adapters/controllers/list-workspaces.controller.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { listWorkspacesController } from "@/interface-adapters/controllers/list-workspaces.controller"; +import { listWorkspacesUseCase } from "@/application/use-cases/list-workspaces.use-case"; +import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace.repository.mock"; +import { InputParseError } from "@/entities/errors/common"; +import { CONTRACT_WORKSPACE_SEED } from "@/__contracts__/workspace-repository.contract"; + +function buildController() { + const repo = new MockWorkspaceRepository(new Map(CONTRACT_WORKSPACE_SEED)); + return listWorkspacesController(listWorkspacesUseCase(repo)); +} + +describe("listWorkspacesController", () => { + it("returns all workspaces for an empty input object", async () => { + const controller = buildController(); + + const result = await controller({}); + + expect(result.map((w) => w.id).sort()).toEqual(["seed-1", "seed-2"]); + }); + + it("accepts a missing input (void query)", async () => { + const controller = buildController(); + + const result = await controller(undefined); + + expect(result).toHaveLength(2); + }); + + it("throws InputParseError on unexpected fields (strict void schema)", async () => { + const controller = buildController(); + + await expect(controller({ filter: "x" })).rejects.toBeInstanceOf( + InputParseError, + ); + }); +}); diff --git a/packages/workspaces/src/interface-adapters/controllers/list-workspaces.controller.ts b/packages/workspaces/src/interface-adapters/controllers/list-workspaces.controller.ts new file mode 100644 index 0000000..64c996c --- /dev/null +++ b/packages/workspaces/src/interface-adapters/controllers/list-workspaces.controller.ts @@ -0,0 +1,28 @@ +import { InputParseError } from "../../entities/errors/common"; +import { + listWorkspacesInputSchema, + type ListWorkspacesOutput, + type IListWorkspacesUseCase, +} from "../../application/use-cases/list-workspaces.use-case"; + +function presenter(value: ListWorkspacesOutput) { + return value; +} + +export type IListWorkspacesController = ReturnType< + typeof listWorkspacesController +>; + +export const listWorkspacesController = + (listWorkspacesUseCase: IListWorkspacesUseCase) => + async (input: unknown): Promise> => { + // Void input: callers may omit it entirely (tRPC query without args). + const parsed = listWorkspacesInputSchema.safeParse(input ?? {}); + if (!parsed.success) { + throw new InputParseError("Invalid list-workspaces input", { + cause: parsed.error, + }); + } + const result = await listWorkspacesUseCase(parsed.data); + return presenter(result); + };