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();
|
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", () => {
|
describe("createWorkspace", () => {
|
||||||
it("creates a workspace with initial status 'created'", async () => {
|
it("creates a workspace with initial status 'created'", async () => {
|
||||||
const created = await repo.createWorkspace(CREATE_DATA);
|
const created = await repo.createWorkspace(CREATE_DATA);
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type CreateWorkspaceData = {
|
|||||||
|
|
||||||
export interface IWorkspaceRepository {
|
export interface IWorkspaceRepository {
|
||||||
getWorkspace(id: string): Promise<Workspace | null>;
|
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". */
|
/** Persists a new workspace with initial status "created". */
|
||||||
createWorkspace(data: CreateWorkspaceData): Promise<Workspace>;
|
createWorkspace(data: CreateWorkspaceData): Promise<Workspace>;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe("connectWorkspaceUseCase", () => {
|
|||||||
const leakyRepo = {
|
const leakyRepo = {
|
||||||
getWorkspace: async () => null,
|
getWorkspace: async () => null,
|
||||||
getDecryptedCredential: async () => null,
|
getDecryptedCredential: async () => null,
|
||||||
|
listWorkspaces: async () => [],
|
||||||
createWorkspace: async () => ({
|
createWorkspace: async () => ({
|
||||||
id: "ws-1",
|
id: "ws-1",
|
||||||
name: "Acme Web",
|
name: "Acme Web",
|
||||||
@@ -101,6 +102,7 @@ describe("connectWorkspaceUseCase", () => {
|
|||||||
const failingRepo = {
|
const failingRepo = {
|
||||||
getWorkspace: async () => null,
|
getWorkspace: async () => null,
|
||||||
getDecryptedCredential: async () => null,
|
getDecryptedCredential: async () => null,
|
||||||
|
listWorkspaces: async () => [],
|
||||||
createWorkspace: async () => {
|
createWorkspace: async () => {
|
||||||
throw new Error("boom");
|
throw new Error("boom");
|
||||||
},
|
},
|
||||||
@@ -123,6 +125,7 @@ describe("connectWorkspaceUseCase", () => {
|
|||||||
const malformedRepo = {
|
const malformedRepo = {
|
||||||
getWorkspace: async () => null,
|
getWorkspace: async () => null,
|
||||||
getDecryptedCredential: async () => null,
|
getDecryptedCredential: async () => null,
|
||||||
|
listWorkspaces: async () => [],
|
||||||
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
||||||
};
|
};
|
||||||
const useCase = connectWorkspaceUseCase(malformedRepo);
|
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,
|
getWorkspace: async () => ({ id: "", name: "x" }) as never,
|
||||||
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
createWorkspace: async () => ({ id: "", name: "x" }) as never,
|
||||||
getDecryptedCredential: async () => null,
|
getDecryptedCredential: async () => null,
|
||||||
|
listWorkspaces: async () => [],
|
||||||
};
|
};
|
||||||
const useCase = getWorkspaceUseCase(malformedRepo);
|
const useCase = getWorkspaceUseCase(malformedRepo);
|
||||||
await expect(useCase({ id: "anything" })).rejects.toBeInstanceOf(ZodError);
|
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 {
|
import {
|
||||||
withSpan,
|
|
||||||
withCapture,
|
|
||||||
INSTRUMENTATION_SYMBOLS,
|
INSTRUMENTATION_SYMBOLS,
|
||||||
type ITracer,
|
type ITracer,
|
||||||
type ILogger,
|
type ILogger,
|
||||||
@@ -12,13 +10,18 @@ import {
|
|||||||
} from "@repo/core-shared/conformance";
|
} from "@repo/core-shared/conformance";
|
||||||
import { workspacesManifest } from "../feature.manifest";
|
import { workspacesManifest } from "../feature.manifest";
|
||||||
import { workspacesContainer } from "./container";
|
import { workspacesContainer } from "./container";
|
||||||
|
import { bindWorkspacesController } from "./bind-controller";
|
||||||
import { WORKSPACES_SYMBOLS } from "./symbols";
|
import { WORKSPACES_SYMBOLS } from "./symbols";
|
||||||
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
|
import { MockWorkspaceRepository } from "../infrastructure/repositories/workspace.repository.mock";
|
||||||
import { buildDevWorkspaceMap } from "../__seeds__/dev";
|
import { buildDevWorkspaceMap } from "../__seeds__/dev";
|
||||||
import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case";
|
import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case";
|
||||||
import { getWorkspaceUseCase } from "../application/use-cases/get-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 { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller";
|
||||||
import { getWorkspaceController } from "../interface-adapters/controllers/get-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";
|
import type { IWorkspaceRepository } from "../application/repositories/workspace.repository.interface";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,49 +90,60 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
const wrappedGetWorkspaceStatus = wireUseCase({
|
||||||
workspacesContainer.isBound(WORKSPACES_SYMBOLS.IConnectWorkspaceController)
|
container: workspacesContainer,
|
||||||
) {
|
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase,
|
||||||
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IConnectWorkspaceController);
|
factory: getWorkspaceStatusUseCase,
|
||||||
}
|
deps: [repo],
|
||||||
workspacesContainer
|
|
||||||
.bind(WORKSPACES_SYMBOLS.IConnectWorkspaceController)
|
|
||||||
.toConstantValue(
|
|
||||||
withSpan(
|
|
||||||
tracer,
|
|
||||||
{ name: "workspaces.connectWorkspace", op: "controller" },
|
|
||||||
withCapture(
|
|
||||||
logger,
|
|
||||||
{
|
|
||||||
feature: "workspaces",
|
feature: "workspaces",
|
||||||
layer: "controller",
|
layer: "use-case",
|
||||||
name: "workspaces.connectWorkspace",
|
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),
|
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.
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
@@ -146,6 +160,8 @@ export async function bindDevSeedWorkspaces(ctx: BindContext): Promise<void> {
|
|||||||
{
|
{
|
||||||
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
|
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
|
||||||
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
||||||
|
getWorkspaceStatus: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase,
|
||||||
|
listWorkspaces: WORKSPACES_SYMBOLS.IListWorkspacesUseCase,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
withSpan,
|
|
||||||
withCapture,
|
|
||||||
INSTRUMENTATION_SYMBOLS,
|
INSTRUMENTATION_SYMBOLS,
|
||||||
type ITracer,
|
type ITracer,
|
||||||
type ILogger,
|
type ILogger,
|
||||||
@@ -11,14 +9,19 @@ import {
|
|||||||
wireUseCase,
|
wireUseCase,
|
||||||
} from "@repo/core-shared/conformance";
|
} from "@repo/core-shared/conformance";
|
||||||
import { workspacesContainer } from "./container";
|
import { workspacesContainer } from "./container";
|
||||||
|
import { bindWorkspacesController } from "./bind-controller";
|
||||||
import { WORKSPACES_SYMBOLS } from "./symbols";
|
import { WORKSPACES_SYMBOLS } from "./symbols";
|
||||||
import { workspacesManifest } from "../feature.manifest";
|
import { workspacesManifest } from "../feature.manifest";
|
||||||
import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository";
|
import { WorkspaceRepository } from "../infrastructure/repositories/workspace.repository";
|
||||||
import { requireVeectSecret } from "../infrastructure/crypto/credential-cipher";
|
import { requireVeectSecret } from "../infrastructure/crypto/credential-cipher";
|
||||||
import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case";
|
import { connectWorkspaceUseCase } from "../application/use-cases/connect-workspace.use-case";
|
||||||
import { getWorkspaceUseCase } from "../application/use-cases/get-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 { connectWorkspaceController } from "../interface-adapters/controllers/connect-workspace.controller";
|
||||||
import { getWorkspaceController } from "../interface-adapters/controllers/get-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 {
|
export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
||||||
const {
|
const {
|
||||||
@@ -89,50 +92,60 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Controllers — wrapped with span at bind time
|
const wrappedGetWorkspaceStatus = wireUseCase({
|
||||||
if (
|
container: workspacesContainer,
|
||||||
workspacesContainer.isBound(WORKSPACES_SYMBOLS.IConnectWorkspaceController)
|
symbol: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase,
|
||||||
) {
|
factory: getWorkspaceStatusUseCase,
|
||||||
workspacesContainer.unbind(WORKSPACES_SYMBOLS.IConnectWorkspaceController);
|
deps: [repo],
|
||||||
}
|
|
||||||
workspacesContainer
|
|
||||||
.bind(WORKSPACES_SYMBOLS.IConnectWorkspaceController)
|
|
||||||
.toConstantValue(
|
|
||||||
withSpan(
|
|
||||||
tracer,
|
|
||||||
{ name: "workspaces.connectWorkspace", op: "controller" },
|
|
||||||
withCapture(
|
|
||||||
logger,
|
|
||||||
{
|
|
||||||
feature: "workspaces",
|
feature: "workspaces",
|
||||||
layer: "controller",
|
layer: "use-case",
|
||||||
name: "workspaces.connectWorkspace",
|
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),
|
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.
|
// bus + queue are passed through; generated handlers consume them at the anchors below.
|
||||||
void bus;
|
void bus;
|
||||||
void queue;
|
void queue;
|
||||||
@@ -150,6 +163,8 @@ export function bindProductionWorkspaces(ctx: BindProductionContext): void {
|
|||||||
{
|
{
|
||||||
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
|
connectWorkspace: WORKSPACES_SYMBOLS.IConnectWorkspaceUseCase,
|
||||||
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
getWorkspace: WORKSPACES_SYMBOLS.IGetWorkspaceUseCase,
|
||||||
|
getWorkspaceStatus: WORKSPACES_SYMBOLS.IGetWorkspaceStatusUseCase,
|
||||||
|
listWorkspaces: WORKSPACES_SYMBOLS.IListWorkspacesUseCase,
|
||||||
},
|
},
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,8 +6,12 @@ import { MockWorkspaceRepository } from "@/infrastructure/repositories/workspace
|
|||||||
import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface";
|
import type { IWorkspaceRepository } from "@/application/repositories/workspace.repository.interface";
|
||||||
import type { IConnectWorkspaceUseCase } from "@/application/use-cases/connect-workspace.use-case";
|
import type { IConnectWorkspaceUseCase } from "@/application/use-cases/connect-workspace.use-case";
|
||||||
import type { IGetWorkspaceUseCase } from "@/application/use-cases/get-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 { IConnectWorkspaceController } from "@/interface-adapters/controllers/connect-workspace.controller";
|
||||||
import type { IGetWorkspaceController } from "@/interface-adapters/controllers/get-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", () => {
|
describe("workspacesContainer", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -53,4 +57,26 @@ describe("workspacesContainer", () => {
|
|||||||
);
|
);
|
||||||
expect(typeof controller).toBe("function");
|
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,
|
getWorkspaceUseCase,
|
||||||
type IGetWorkspaceUseCase,
|
type IGetWorkspaceUseCase,
|
||||||
} from "../application/use-cases/get-workspace.use-case";
|
} 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 {
|
import {
|
||||||
connectWorkspaceController,
|
connectWorkspaceController,
|
||||||
type IConnectWorkspaceController,
|
type IConnectWorkspaceController,
|
||||||
@@ -18,6 +26,14 @@ import {
|
|||||||
getWorkspaceController,
|
getWorkspaceController,
|
||||||
type IGetWorkspaceController,
|
type IGetWorkspaceController,
|
||||||
} from "../interface-adapters/controllers/get-workspace.controller";
|
} 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";
|
import { WORKSPACES_SYMBOLS } from "./symbols";
|
||||||
|
|
||||||
export const WorkspacesModule = new ContainerModule((bind: interfaces.Bind) => {
|
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>(
|
bind<IConnectWorkspaceController>(
|
||||||
WORKSPACES_SYMBOLS.IConnectWorkspaceController,
|
WORKSPACES_SYMBOLS.IConnectWorkspaceController,
|
||||||
).toDynamicValue((ctx) =>
|
).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
|
// Use cases
|
||||||
IConnectWorkspaceUseCase: Symbol.for("workspaces:IConnectWorkspaceUseCase"),
|
IConnectWorkspaceUseCase: Symbol.for("workspaces:IConnectWorkspaceUseCase"),
|
||||||
IGetWorkspaceUseCase: Symbol.for("workspaces:IGetWorkspaceUseCase"),
|
IGetWorkspaceUseCase: Symbol.for("workspaces:IGetWorkspaceUseCase"),
|
||||||
|
IGetWorkspaceStatusUseCase: Symbol.for(
|
||||||
|
"workspaces:IGetWorkspaceStatusUseCase",
|
||||||
|
),
|
||||||
|
IListWorkspacesUseCase: Symbol.for("workspaces:IListWorkspacesUseCase"),
|
||||||
// Controllers
|
// Controllers
|
||||||
IConnectWorkspaceController: Symbol.for(
|
IConnectWorkspaceController: Symbol.for(
|
||||||
"workspaces:IConnectWorkspaceController",
|
"workspaces:IConnectWorkspaceController",
|
||||||
),
|
),
|
||||||
IGetWorkspaceController: Symbol.for("workspaces:IGetWorkspaceController"),
|
IGetWorkspaceController: Symbol.for("workspaces:IGetWorkspaceController"),
|
||||||
|
IGetWorkspaceStatusController: Symbol.for(
|
||||||
|
"workspaces:IGetWorkspaceStatusController",
|
||||||
|
),
|
||||||
|
IListWorkspacesController: Symbol.for("workspaces:IListWorkspacesController"),
|
||||||
// <gen:event-handler-symbols>
|
// <gen:event-handler-symbols>
|
||||||
// <gen:job-symbols>
|
// <gen:job-symbols>
|
||||||
// <gen:realtime-handler-symbols>
|
// <gen:realtime-handler-symbols>
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ export const workspacesManifest = defineFeature({
|
|||||||
consumes: [],
|
consumes: [],
|
||||||
analyticsEvents: [],
|
analyticsEvents: [],
|
||||||
},
|
},
|
||||||
|
getWorkspaceStatus: {
|
||||||
|
mutates: false,
|
||||||
|
audits: [],
|
||||||
|
publishes: [],
|
||||||
|
consumes: [],
|
||||||
|
analyticsEvents: [],
|
||||||
|
},
|
||||||
|
listWorkspaces: {
|
||||||
|
mutates: false,
|
||||||
|
audits: [],
|
||||||
|
publishes: [],
|
||||||
|
consumes: [],
|
||||||
|
analyticsEvents: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
realtimeChannels: [],
|
realtimeChannels: [],
|
||||||
jobs: [],
|
jobs: [],
|
||||||
|
|||||||
@@ -18,10 +18,26 @@ export {
|
|||||||
type GetWorkspaceOutput,
|
type GetWorkspaceOutput,
|
||||||
type IGetWorkspaceUseCase,
|
type IGetWorkspaceUseCase,
|
||||||
} from "./application/use-cases/get-workspace.use-case";
|
} 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
|
// Controller type aliases
|
||||||
export type { IConnectWorkspaceController } from "./interface-adapters/controllers/connect-workspace.controller";
|
export type { IConnectWorkspaceController } from "./interface-adapters/controllers/connect-workspace.controller";
|
||||||
export type { IGetWorkspaceController } from "./interface-adapters/controllers/get-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:events>
|
||||||
// <gen:realtime-channels>
|
// <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> {
|
async createWorkspace(data: CreateWorkspaceData): Promise<Workspace> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },
|
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ function buildPayloadStub(
|
|||||||
async ({ id }: { collection: string; id: string }) =>
|
async ({ id }: { collection: string; id: string }) =>
|
||||||
store.get(String(id)) ?? null,
|
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,
|
__store: store,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -168,6 +172,7 @@ describe("WorkspaceRepository", () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["getWorkspace", (r: WorkspaceRepository) => r.getWorkspace("x")],
|
["getWorkspace", (r: WorkspaceRepository) => r.getWorkspace("x")],
|
||||||
|
["listWorkspaces", (r: WorkspaceRepository) => r.listWorkspaces()],
|
||||||
[
|
[
|
||||||
"createWorkspace",
|
"createWorkspace",
|
||||||
(r: WorkspaceRepository) =>
|
(r: WorkspaceRepository) =>
|
||||||
@@ -186,6 +191,9 @@ describe("WorkspaceRepository", () => {
|
|||||||
findByID: vi.fn(async () => {
|
findByID: vi.fn(async () => {
|
||||||
throw new Error("infra boom");
|
throw new Error("infra boom");
|
||||||
}),
|
}),
|
||||||
|
find: vi.fn(async () => {
|
||||||
|
throw new Error("infra boom");
|
||||||
|
}),
|
||||||
create: vi.fn(async () => {
|
create: vi.fn(async () => {
|
||||||
throw new Error("infra boom");
|
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> {
|
async createWorkspace(data: CreateWorkspaceData): Promise<Workspace> {
|
||||||
return this.tracer.startSpan(
|
return this.tracer.startSpan(
|
||||||
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },
|
{ name: "workspace.createWorkspace", op: "repository", attributes: {} },
|
||||||
|
|||||||
@@ -18,10 +18,16 @@ describe("workspacesRouter", () => {
|
|||||||
workspacesContainer.unbindAll();
|
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);
|
const names = Object.keys(workspacesRouter._def.procedures);
|
||||||
expect(names).toContain("getWorkspace");
|
expect(names).toEqual(
|
||||||
expect(names).toContain("connectWorkspace");
|
expect.arrayContaining([
|
||||||
|
"connectWorkspace",
|
||||||
|
"getWorkspace",
|
||||||
|
"getWorkspaceStatus",
|
||||||
|
"listWorkspaces",
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("getWorkspace returns the seeded workspace", async () => {
|
it("getWorkspace returns the seeded workspace", async () => {
|
||||||
@@ -40,6 +46,18 @@ describe("workspacesRouter", () => {
|
|||||||
expect(result.status).toBe("created");
|
expect(result.status).toBe("created");
|
||||||
expect(JSON.stringify(result)).not.toContain("ghp_super-secret-token");
|
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)", () => {
|
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 { connectWorkspaceInputSchema } from "../../application/use-cases/connect-workspace.use-case";
|
||||||
import { getWorkspaceInputSchema } from "../../application/use-cases/get-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 { IConnectWorkspaceController } from "../../interface-adapters/controllers/connect-workspace.controller";
|
||||||
import type { IGetWorkspaceController } from "../../interface-adapters/controllers/get-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";
|
import { workspacesProcedure } from "./procedures";
|
||||||
|
|
||||||
@@ -27,6 +31,22 @@ export const workspacesRouter = router({
|
|||||||
);
|
);
|
||||||
return ctrl(input);
|
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;
|
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