feat(workspaces): status and list use cases

getWorkspaceStatus returns persisted { id, status } only (runner-driven
transitions arrive in story 07); listWorkspaces returns the
credential-free workspace array with a strict void input (optional on
the tRPC procedure so clients can call without args). Repository gains
listWorkspaces on interface, mock, and Payload impl; the contract suite
covers listing incl. the never-returns-credentials guarantee.
Controller span+capture wrapping is extracted into a shared
bindWorkspacesController helper used by both binders, trimming the
bind-production/bind-dev-seed clone family fallow was flagging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 22:27:44 +02:00
parent c990e1b871
commit eec402d49d
25 changed files with 743 additions and 92 deletions

View File

@@ -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);

View File

@@ -14,6 +14,8 @@ export type CreateWorkspaceData = {
export interface IWorkspaceRepository {
getWorkspace(id: string): Promise<Workspace | null>;
/** Returns every persisted workspace (credential-free by construction). */
listWorkspaces(): Promise<Workspace[]>;
/** Persists a new workspace with initial status "created". */
createWorkspace(data: CreateWorkspaceData): Promise<Workspace>;
/**

View File

@@ -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);

View File

@@ -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<string, Workspace>([
[
"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);
});
});

View File

@@ -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<GetWorkspaceStatusOutput> => {
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,
});
};

View File

@@ -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);

View File

@@ -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);
});
});

View File

@@ -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<typeof listWorkspacesInputSchema>;
// ── Output ───────────────────────────────────────────────────────────────
// Array of credential-free workspace entities (persisted state only).
export const listWorkspacesOutputSchema = z.array(workspaceSchema);
export type ListWorkspacesOutput = z.infer<typeof listWorkspacesOutputSchema>;
// ── Use case ─────────────────────────────────────────────────────────────
export type IListWorkspacesUseCase = ReturnType<typeof listWorkspacesUseCase>;
export const listWorkspacesUseCase =
(workspaceRepository: IWorkspaceRepository) =>
async (_input: ListWorkspacesInput): Promise<ListWorkspacesOutput> => {
const workspaces = await workspaceRepository.listWorkspaces();
return listWorkspacesOutputSchema.parse(workspaces);
};

View File

@@ -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<Args extends unknown[], R>(
tracer: ITracer,
logger: ILogger,
symbol: symbol,
name: string,
controller: (...args: Args) => Promise<R>,
): 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,
),
),
);
}

View File

@@ -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<void> {
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<void> {
{
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
getWorkspaceStatus: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase,
listWorkspaces: WORKSPACES_SYMBOLS.IListWorkspacesUseCase,
},
ctx,
);

View File

@@ -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,
);

View File

@@ -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<IGetWorkspaceStatusUseCase>(
WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase,
);
const controller = workspacesContainer.get<IGetWorkspaceStatusController>(
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<IListWorkspacesUseCase>(
WORKSPACES_SYMBOLS.IListWorkspacesUseCase,
);
const controller = workspacesContainer.get<IListWorkspacesController>(
WORKSPACES_SYMBOLS.IListWorkspacesController,
);
expect(typeof useCase).toBe("function");
expect(typeof controller).toBe("function");
});
});

View File

@@ -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<IGetWorkspaceStatusUseCase>(
WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase,
).toDynamicValue((ctx) =>
getWorkspaceStatusUseCase(
ctx.container.get<IWorkspaceRepository>(
WORKSPACES_SYMBOLS.IWorkspaceRepository,
),
),
);
bind<IListWorkspacesUseCase>(
WORKSPACES_SYMBOLS.IListWorkspacesUseCase,
).toDynamicValue((ctx) =>
listWorkspacesUseCase(
ctx.container.get<IWorkspaceRepository>(
WORKSPACES_SYMBOLS.IWorkspaceRepository,
),
),
);
bind<IConnectWorkspaceController>(
WORKSPACES_SYMBOLS.IConnectWorkspaceController,
).toDynamicValue((ctx) =>
@@ -64,4 +100,24 @@ export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => {
),
),
);
bind<IGetWorkspaceStatusController>(
WORKSPACES_SYMBOLS.IGetWorkspaceStatusController,
).toDynamicValue((ctx) =>
getWorkspaceStatusController(
ctx.container.get<IGetWorkspaceStatusUseCase>(
WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase,
),
),
);
bind<IListWorkspacesController>(
WORKSPACES_SYMBOLS.IListWorkspacesController,
).toDynamicValue((ctx) =>
listWorkspacesController(
ctx.container.get<IListWorkspacesUseCase>(
WORKSPACES_SYMBOLS.IListWorkspacesUseCase,
),
),
);
});

View File

@@ -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"),
// <gen:event-handler-symbols>
// <gen:job-symbols>
// <gen:realtime-handler-symbols>

View File

@@ -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: [],

View File

@@ -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";
// <gen:events>
// <gen:realtime-channels>

View File

@@ -69,6 +69,17 @@ export class MockWorkspaceRepository implements IWorkspaceRepository {
);
}
async listWorkspaces(): Promise<Workspace[]> {
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<Workspace> {
return this.tracer.startSpan(
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },

View File

@@ -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");
}),

View File

@@ -77,6 +77,35 @@ export class WorkspaceRepository implements IWorkspaceRepository {
);
}
async listWorkspaces(): Promise<Workspace[]> {
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<string, unknown>),
);
} 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<Workspace> {
return this.tracer.startSpan(
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },

View File

@@ -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)", () => {

View File

@@ -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<IGetWorkspaceStatusController>(
WORKSPACES_SYMBOLS.IGetWorkspaceStatusController,
);
return ctrl(input);
}),
listWorkspaces: workspacesProcedure
.input(listWorkspacesInputSchema.optional())
.query(({ input }) => {
const ctrl = workspacesContainer.get<IListWorkspacesController>(
WORKSPACES_SYMBOLS.IListWorkspacesController,
);
return ctrl(input);
}),
});
export type WorkspacesRouter = typeof workspacesRouter;

View File

@@ -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<string, Workspace>([
[
"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,
);
});
});

View File

@@ -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<ReturnType<typeof presenter>> => {
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);
};

View File

@@ -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,
);
});
});

View File

@@ -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<ReturnType<typeof presenter>> => {
// 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);
};