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:
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user