import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ConfigService } from "@nestjs/config"; import type { PrismaService } from "../prisma/prisma.service"; import type { CryptoService } from "../crypto/crypto.service"; interface MockUserContainerRecord { id: string; userId: string; containerId: string | null; containerName: string; gatewayPort: number | null; gatewayToken: string; status: string; lastActiveAt: Date | null; idleTimeoutMin: number; config: Record; createdAt: Date; updatedAt: Date; } const dockerMock = vi.hoisted(() => { interface MockDockerContainerState { id: string; name: string; running: boolean; port: number; } const containers = new Map(); const handles = new Map< string, { inspect: ReturnType; start: ReturnType; stop: ReturnType; } >(); const ensureHandle = (id: string) => { const existing = handles.get(id); if (existing) { return existing; } const handle = { inspect: vi.fn(async () => { const container = containers.get(id); if (!container) { throw { statusCode: 404 }; } return { Id: container.id, State: { Running: container.running, }, NetworkSettings: { Ports: { "18789/tcp": [{ HostPort: String(container.port) }], }, }, }; }), start: vi.fn(async () => { const container = containers.get(id); if (!container) { throw { statusCode: 404 }; } container.running = true; }), stop: vi.fn(async () => { const container = containers.get(id); if (!container) { throw { statusCode: 404 }; } container.running = false; }), }; handles.set(id, handle); return handle; }; const listContainers = vi.fn( async (options?: { all?: boolean; filters?: { name?: string[] } }) => { const nameFilter = options?.filters?.name?.[0]; return [...containers.values()] .filter((container) => (nameFilter ? container.name.includes(nameFilter) : true)) .map((container) => ({ Id: container.id, Names: [`/${container.name}`], })); } ); const getContainer = vi.fn((id: string) => ensureHandle(id)); const createContainer = vi.fn( async (options: { name?: string; HostConfig?: { PortBindings?: Record> }; }) => { const id = `ctr-${containers.size + 1}`; const name = options.name ?? id; const hostPort = options.HostConfig?.PortBindings?.["18789/tcp"]?.[0]?.HostPort; const port = hostPort ? Number.parseInt(hostPort, 10) : 0; containers.set(id, { id, name, running: false, port, }); return ensureHandle(id); } ); const dockerInstance = { listContainers, getContainer, createContainer, }; const constructorSpy = vi.fn(); class DockerConstructorMock { constructor(options?: unknown) { constructorSpy(options); return dockerInstance; } } const registerContainer = (container: MockDockerContainerState) => { containers.set(container.id, { ...container }); ensureHandle(container.id); }; const reset = () => { containers.clear(); handles.clear(); constructorSpy.mockClear(); listContainers.mockClear(); getContainer.mockClear(); createContainer.mockClear(); }; return { DockerConstructorMock, constructorSpy, createContainer, handles, registerContainer, reset, }; }); vi.mock("dockerode", () => ({ default: dockerMock.DockerConstructorMock, })); import { ContainerLifecycleService } from "./container-lifecycle.service"; function createConfigMock(values: Record = {}) { return { get: vi.fn((key: string) => values[key]), }; } function createCryptoMock() { return { generateToken: vi.fn(() => "generated-token"), encrypt: vi.fn((value: string) => `enc:${value}`), decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")), isEncrypted: vi.fn((value: string) => value.startsWith("enc:")), }; } function projectRecord( record: MockUserContainerRecord, select?: Record ): Partial { if (!select) { return { ...record }; } const projection: Partial = {}; for (const [field, enabled] of Object.entries(select)) { if (enabled) { const key = field as keyof MockUserContainerRecord; projection[key] = record[key]; } } return projection; } function createPrismaMock(initialRecords: MockUserContainerRecord[] = []) { const records = new Map(); for (const record of initialRecords) { records.set(record.userId, { ...record }); } const userContainer = { findUnique: vi.fn( async (args: { where: { userId?: string; id?: string }; select?: Record; }) => { let record: MockUserContainerRecord | undefined; if (args.where.userId) { record = records.get(args.where.userId); } else if (args.where.id) { record = [...records.values()].find((entry) => entry.id === args.where.id); } if (!record) { return null; } return projectRecord(record, args.select); } ), create: vi.fn( async (args: { data: Partial & { userId: string; containerName: string; gatewayToken: string; }; }) => { const now = new Date(); const next: MockUserContainerRecord = { id: args.data.id ?? `uc-${records.size + 1}`, userId: args.data.userId, containerId: args.data.containerId ?? null, containerName: args.data.containerName, gatewayPort: args.data.gatewayPort ?? null, gatewayToken: args.data.gatewayToken, status: args.data.status ?? "stopped", lastActiveAt: args.data.lastActiveAt ?? null, idleTimeoutMin: args.data.idleTimeoutMin ?? 30, config: args.data.config ?? {}, createdAt: now, updatedAt: now, }; records.set(next.userId, next); return { ...next }; } ), update: vi.fn( async (args: { where: { userId: string }; data: Partial }) => { const record = records.get(args.where.userId); if (!record) { throw new Error(`Record ${args.where.userId} not found`); } const updated: MockUserContainerRecord = { ...record, ...args.data, updatedAt: new Date(), }; records.set(updated.userId, updated); return { ...updated }; } ), updateMany: vi.fn( async (args: { where: { userId: string }; data: Partial }) => { const record = records.get(args.where.userId); if (!record) { return { count: 0 }; } const updated: MockUserContainerRecord = { ...record, ...args.data, updatedAt: new Date(), }; records.set(updated.userId, updated); return { count: 1 }; } ), findMany: vi.fn( async (args?: { where?: { status?: string; lastActiveAt?: { not: null }; gatewayPort?: { not: null }; }; select?: Record; }) => { let rows = [...records.values()]; if (args?.where?.status) { rows = rows.filter((record) => record.status === args.where?.status); } if (args?.where?.lastActiveAt?.not === null) { rows = rows.filter((record) => record.lastActiveAt !== null); } if (args?.where?.gatewayPort?.not === null) { rows = rows.filter((record) => record.gatewayPort !== null); } return rows.map((record) => projectRecord(record, args?.select)); } ), }; return { prisma: { userContainer, }, records, }; } function createRecord(overrides: Partial): MockUserContainerRecord { const now = new Date(); return { id: overrides.id ?? "uc-default", userId: overrides.userId ?? "user-default", containerId: overrides.containerId ?? null, containerName: overrides.containerName ?? "mosaic-user-user-default", gatewayPort: overrides.gatewayPort ?? null, gatewayToken: overrides.gatewayToken ?? "enc:token-default", status: overrides.status ?? "stopped", lastActiveAt: overrides.lastActiveAt ?? null, idleTimeoutMin: overrides.idleTimeoutMin ?? 30, config: overrides.config ?? {}, createdAt: overrides.createdAt ?? now, updatedAt: overrides.updatedAt ?? now, }; } describe("ContainerLifecycleService", () => { beforeEach(() => { dockerMock.reset(); }); it("ensureRunning creates container when none exists", async () => { const { prisma, records } = createPrismaMock(); const crypto = createCryptoMock(); const config = createConfigMock(); const service = new ContainerLifecycleService( prisma as unknown as PrismaService, crypto as unknown as CryptoService, config as unknown as ConfigService ); const result = await service.ensureRunning("user-1"); expect(result).toEqual({ url: "http://mosaic-user-user-1:19000", token: "generated-token", }); const updatedRecord = records.get("user-1"); expect(updatedRecord?.status).toBe("running"); expect(updatedRecord?.containerId).toBe("ctr-1"); expect(updatedRecord?.gatewayPort).toBe(19000); expect(updatedRecord?.gatewayToken).toBe("enc:generated-token"); expect(dockerMock.createContainer).toHaveBeenCalledTimes(1); const [createCall] = dockerMock.createContainer.mock.calls[0] as [ { name: string; Image: string; Env: string[]; HostConfig: { Binds: string[]; NetworkMode: string }; }, ]; expect(createCall.name).toBe("mosaic-user-user-1"); expect(createCall.Image).toBe("alpine/openclaw:latest"); expect(createCall.HostConfig.Binds).toEqual(["mosaic-user-user-1-state:/home/node/.openclaw"]); expect(createCall.HostConfig.NetworkMode).toBe("mosaic-internal"); expect(createCall.Env).toContain("AGENT_TOKEN=generated-token"); }); it("ensureRunning starts existing stopped container", async () => { const { prisma, records } = createPrismaMock([ createRecord({ id: "uc-1", userId: "user-2", containerId: "ctr-stopped", containerName: "mosaic-user-user-2", gatewayToken: "enc:existing-token", status: "stopped", }), ]); const crypto = createCryptoMock(); const config = createConfigMock(); const service = new ContainerLifecycleService( prisma as unknown as PrismaService, crypto as unknown as CryptoService, config as unknown as ConfigService ); dockerMock.registerContainer({ id: "ctr-stopped", name: "mosaic-user-user-2", running: false, port: 19042, }); const result = await service.ensureRunning("user-2"); expect(result).toEqual({ url: "http://mosaic-user-user-2:19042", token: "existing-token", }); const handle = dockerMock.handles.get("ctr-stopped"); expect(handle?.start).toHaveBeenCalledTimes(1); expect(records.get("user-2")?.status).toBe("running"); expect(records.get("user-2")?.gatewayPort).toBe(19042); }); it("ensureRunning returns existing running container", async () => { const { prisma } = createPrismaMock([ createRecord({ id: "uc-2", userId: "user-3", containerId: "ctr-running", containerName: "mosaic-user-user-3", gatewayPort: 19043, gatewayToken: "enc:running-token", status: "running", }), ]); const crypto = createCryptoMock(); const config = createConfigMock(); const service = new ContainerLifecycleService( prisma as unknown as PrismaService, crypto as unknown as CryptoService, config as unknown as ConfigService ); dockerMock.registerContainer({ id: "ctr-running", name: "mosaic-user-user-3", running: true, port: 19043, }); const result = await service.ensureRunning("user-3"); expect(result).toEqual({ url: "http://mosaic-user-user-3:19043", token: "running-token", }); expect(dockerMock.createContainer).not.toHaveBeenCalled(); const handle = dockerMock.handles.get("ctr-running"); expect(handle?.start).not.toHaveBeenCalled(); }); it("stop gracefully stops container and updates DB", async () => { const { prisma, records } = createPrismaMock([ createRecord({ id: "uc-stop", userId: "user-stop", containerId: "ctr-stop", containerName: "mosaic-user-user-stop", gatewayPort: 19044, status: "running", }), ]); const crypto = createCryptoMock(); const config = createConfigMock(); const service = new ContainerLifecycleService( prisma as unknown as PrismaService, crypto as unknown as CryptoService, config as unknown as ConfigService ); dockerMock.registerContainer({ id: "ctr-stop", name: "mosaic-user-user-stop", running: true, port: 19044, }); await service.stop("user-stop"); const handle = dockerMock.handles.get("ctr-stop"); expect(handle?.stop).toHaveBeenCalledWith({ t: 10 }); const updatedRecord = records.get("user-stop"); expect(updatedRecord?.status).toBe("stopped"); expect(updatedRecord?.containerId).toBeNull(); expect(updatedRecord?.gatewayPort).toBeNull(); }); it("reapIdle stops only containers past their idle timeout", async () => { const now = Date.now(); const { prisma, records } = createPrismaMock([ createRecord({ id: "uc-old", userId: "user-old", containerId: "ctr-old", containerName: "mosaic-user-user-old", gatewayPort: 19045, status: "running", lastActiveAt: new Date(now - 60 * 60 * 1000), idleTimeoutMin: 30, }), createRecord({ id: "uc-fresh", userId: "user-fresh", containerId: "ctr-fresh", containerName: "mosaic-user-user-fresh", gatewayPort: 19046, status: "running", lastActiveAt: new Date(now - 5 * 60 * 1000), idleTimeoutMin: 30, }), ]); const crypto = createCryptoMock(); const config = createConfigMock(); const service = new ContainerLifecycleService( prisma as unknown as PrismaService, crypto as unknown as CryptoService, config as unknown as ConfigService ); dockerMock.registerContainer({ id: "ctr-old", name: "mosaic-user-user-old", running: true, port: 19045, }); dockerMock.registerContainer({ id: "ctr-fresh", name: "mosaic-user-user-fresh", running: true, port: 19046, }); const result = await service.reapIdle(); expect(result).toEqual({ stopped: ["user-old"], }); expect(records.get("user-old")?.status).toBe("stopped"); expect(records.get("user-fresh")?.status).toBe("running"); const oldHandle = dockerMock.handles.get("ctr-old"); const freshHandle = dockerMock.handles.get("ctr-fresh"); expect(oldHandle?.stop).toHaveBeenCalledTimes(1); expect(freshHandle?.stop).not.toHaveBeenCalled(); }); it("touch updates lastActiveAt", async () => { const { prisma, records } = createPrismaMock([ createRecord({ id: "uc-touch", userId: "user-touch", containerName: "mosaic-user-user-touch", lastActiveAt: null, }), ]); const crypto = createCryptoMock(); const config = createConfigMock(); const service = new ContainerLifecycleService( prisma as unknown as PrismaService, crypto as unknown as CryptoService, config as unknown as ConfigService ); await service.touch("user-touch"); const updatedRecord = records.get("user-touch"); expect(updatedRecord?.lastActiveAt).toBeInstanceOf(Date); }); it("getStatus returns null for unknown user", async () => { const { prisma } = createPrismaMock(); const crypto = createCryptoMock(); const config = createConfigMock(); const service = new ContainerLifecycleService( prisma as unknown as PrismaService, crypto as unknown as CryptoService, config as unknown as ConfigService ); const status = await service.getStatus("missing-user"); expect(status).toBeNull(); }); });