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:
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
43
packages/workspaces/src/di/bind-controller.ts
Normal file
43
packages/workspaces/src/di/bind-controller.ts
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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");
|
||||
}),
|
||||
|
||||
@@ -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: {} },
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user