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

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