IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

operations.test.ts

Open raw
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import yaml from "js-yaml";

vi.mock("@/lib/server/modules/store/catalog", () => ({
  findStoreCatalogTemplateByAppId: vi.fn(),
}));

vi.mock("@/lib/server/modules/docker/compose-parser", () => ({
  parseComposeFile: vi.fn(),
  extractPrimaryServiceWithName: vi.fn(),
}));

vi.mock("@/lib/server/modules/docker/compose-runner", () => ({
  cleanupComposeDataOnUninstall: vi.fn(),
  getComposeRuntimeInfo: vi.fn(),
  materializeInlineStackFiles: vi.fn(),
  runComposeDown: vi.fn(),
  runComposePull: vi.fn(),
  runComposeRestart: vi.fn(),
  runComposeStart: vi.fn(),
  runComposeStop: vi.fn(),
  runComposeUp: vi.fn(),
  sanitizeStackName: vi.fn(() => "adguard-home"),
}));

vi.mock("@/lib/server/modules/store/custom-apps", () => ({
  findCustomStoreTemplateByAppId: vi.fn(),
  isCustomStoreTemplate: vi.fn(() => false),
}));

vi.mock("@/lib/server/modules/store/docker-client", () => ({}));

vi.mock("@/lib/server/modules/apps/service", () => ({
  invalidateInstalledAppsCache: vi.fn(),
}));

vi.mock("@/lib/server/modules/apps/stacks-repository", () => ({
  createStoreOperation: vi.fn(),
  deleteInstalledStackByAppId: vi.fn(),
  findInstalledStackByAppId: vi.fn(),
  findStackByWebUiPort: vi.fn(),
  findStoreOperationById: vi.fn(),
  markStaleOperationsAsError: vi.fn().mockResolvedValue(undefined),
  pruneFinishedOperations: vi.fn().mockResolvedValue(undefined),
  updateStoreOperation: vi.fn(),
  updateStackUpdateStatus: vi.fn(),
  upsertInstalledStack: vi.fn(),
}));

import { findStoreCatalogTemplateByAppId } from "@/lib/server/modules/store/catalog";
import {
  extractPrimaryServiceWithName,
  parseComposeFile,
} from "@/lib/server/modules/docker/compose-parser";
import {
  cleanupComposeDataOnUninstall,
  getComposeRuntimeInfo,
  materializeInlineStackFiles,
  runComposeDown,
  runComposePull,
  runComposeRestart,
  runComposeUp,
} from "@/lib/server/modules/docker/compose-runner";
import { findCustomStoreTemplateByAppId } from "@/lib/server/modules/store/custom-apps";
import { invalidateInstalledAppsCache } from "@/lib/server/modules/apps/service";
import {
  createStoreOperation,
  deleteInstalledStackByAppId,
  findInstalledStackByAppId,
  findStackByWebUiPort,
  updateStoreOperation,
  updateStackUpdateStatus,
  upsertInstalledStack,
} from "@/lib/server/modules/apps/stacks-repository";
import {
  _resetActiveOperationsForTesting,
  getLatestStoreOperationEvent,
  startStoreOperation,
} from "@/lib/server/modules/apps/operations";
import type { StoreOperationError } from "@/lib/server/modules/apps/operations";

async function waitForLatestEventType(operationId: string, expectedType: string) {
  for (let index = 0; index < 50; index += 1) {
    const latest = getLatestStoreOperationEvent(operationId);
    if (latest?.type === expectedType) {
      return latest;
    }

    await new Promise((resolve) => {
      setTimeout(resolve, 0);
    });
  }

  throw new Error(`Timed out waiting for event type "${expectedType}"`);
}

async function writeCatalogComposeFile(prefix: string, composeContent: string) {
  const tempDir = await mkdtemp(path.join(os.tmpdir(), prefix));
  const composePath = path.join(tempDir, "docker-compose.yml");
  await writeFile(composePath, composeContent, "utf8");
  return { tempDir, composePath };
}

describe("store operations", () => {
  beforeEach(() => {
    _resetActiveOperationsForTesting();
    vi.mocked(findStoreCatalogTemplateByAppId).mockReset();
    vi.mocked(findCustomStoreTemplateByAppId).mockReset();
    vi.mocked(cleanupComposeDataOnUninstall).mockReset();
    vi.mocked(parseComposeFile).mockReset();
    vi.mocked(extractPrimaryServiceWithName).mockReset();
    vi.mocked(getComposeRuntimeInfo).mockReset();
    vi.mocked(materializeInlineStackFiles).mockReset();
    vi.mocked(runComposePull).mockReset();
    vi.mocked(runComposeUp).mockReset();
    vi.mocked(invalidateInstalledAppsCache).mockReset();
    vi.mocked(createStoreOperation).mockReset();
    vi.mocked(deleteInstalledStackByAppId).mockReset();
    vi.mocked(findInstalledStackByAppId).mockReset();
    vi.mocked(findStackByWebUiPort).mockReset();
    vi.mocked(updateStoreOperation).mockReset();
    vi.mocked(updateStackUpdateStatus).mockReset();
    vi.mocked(upsertInstalledStack).mockReset();
    vi.stubGlobal(
      "fetch",
      vi.fn().mockResolvedValue({
        status: 200,
      }),
    );
    vi.mocked(getComposeRuntimeInfo).mockResolvedValue({
      status: "running",
      lifecycleStatus: "running",
      containerNames: ["app"],
      primaryContainerName: "app",
      condition: { condition: "running", exitCode: null },
    });
  });

  it("rejects a second active operation for the same app", async () => {
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);

    await startStoreOperation({
      appId: "example",
      action: "install",
    });

    await expect(
      startStoreOperation({
        appId: "example",
        action: "update",
      }),
    ).rejects.toMatchObject({
      code: "operation_conflict",
      statusCode: 409,
    } satisfies Partial<StoreOperationError>);
  });

  it("rejects new operations when the global concurrency cap is reached", async () => {
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);

    await startStoreOperation({ appId: "one", action: "install" });
    await startStoreOperation({ appId: "two", action: "install" });
    await startStoreOperation({ appId: "three", action: "install" });

    await expect(
      startStoreOperation({ appId: "four", action: "install" }),
    ).rejects.toMatchObject({
      code: "operation_limit_reached",
      statusCode: 429,
    } satisfies Partial<StoreOperationError>);
  });

  it("runs install flow and completes with progress updates", async () => {
    const { tempDir, composePath } = await writeCatalogComposeFile(
      "store-install-",
      `services:
  adguard-home:
    image: ghcr.io/example/app:latest
`,
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "adguard-home",
      templateName: "adguard-home",
      name: "AdGuard Home",
      description: "dns",
      platform: "Docker",
      note: "note",
      categories: ["Network"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/bigbeartechworld/big-bear-portainer",
      stackFile: "Apps/adguard-home/docker-compose.yml",
      composePath,
      env: [{ name: "TZ", default: "UTC" }],
      screenshots: [],
      image: "ghcr.io/example/app:latest",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "adguard-home",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);

    vi.mocked(findInstalledStackByAppId).mockResolvedValue(null);
    vi.mocked(findStackByWebUiPort).mockResolvedValue(null);
    vi.mocked(materializeInlineStackFiles).mockResolvedValue({
      stackDir: "/tmp/store/stacks/adguard-home",
      composePath: "/tmp/store/stacks/adguard-home/docker-compose.yml",
      envPath: "/tmp/store/stacks/adguard-home/.env",
      stackName: "adguard-home",
      webUiPort: 3001,
    });
    vi.mocked(runComposePull).mockResolvedValue(undefined);

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(upsertInstalledStack).mockResolvedValue(undefined);

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    const { operationId } = await startStoreOperation({
      appId: "adguard-home",
      action: "install",
      webUiPort: 3001,
      env: {
        TZ: "Africa/Tunis",
      },
    });

    await done;

    expect(operationId).toBeTruthy();
    expect(upsertInstalledStack).toHaveBeenCalledWith(
      expect.objectContaining({
        appId: "adguard-home",
        status: "installed",
        webUiPort: 3001,
      }),
    );
    expect(materializeInlineStackFiles).toHaveBeenCalledWith(
      expect.objectContaining({
        appId: "adguard-home",
        storageMappingStrategy: "app_target_path",
      }),
    );
    expect(invalidateInstalledAppsCache).toHaveBeenCalledTimes(1);
    expect(runComposeUp).toHaveBeenCalled();

    const latest = await waitForLatestEventType(operationId, "operation.completed");
    expect(latest?.status).toBe("success");
    await rm(tempDir, { recursive: true, force: true });
  });

  it("marks operation as failed when docker pull fails", async () => {
    const { tempDir, composePath } = await writeCatalogComposeFile(
      "store-install-fail-",
      `services:
  homepage:
    image: ghcr.io/example/homepage:latest
`,
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "homepage",
      templateName: "homepage",
      name: "Homepage",
      description: "dashboard",
      platform: "Docker",
      note: "note",
      categories: ["Productivity"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/bigbeartechworld/big-bear-portainer",
      stackFile: "Apps/Homepage/docker-compose.yml",
      composePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/homepage:latest",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "homepage",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);

    vi.mocked(findInstalledStackByAppId).mockResolvedValue(null);
    vi.mocked(findStackByWebUiPort).mockResolvedValue(null);
    vi.mocked(materializeInlineStackFiles).mockResolvedValue({
      stackDir: "/tmp/store/stacks/homepage",
      composePath: "/tmp/store/stacks/homepage/docker-compose.yml",
      envPath: "/tmp/store/stacks/homepage/.env",
      stackName: "homepage",
      webUiPort: null,
    });
    vi.mocked(runComposePull).mockRejectedValue(new Error("unauthorized"));
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);

    let failed = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!failed && patch.status === "error") {
          failed = true;
          resolve();
        }
      });
    });

    const { operationId } = await startStoreOperation({
      appId: "homepage",
      action: "install",
    });

    await done;

    const latest = await waitForLatestEventType(operationId, "operation.failed");
    expect(latest?.status).toBe("error");
    await rm(tempDir, { recursive: true, force: true });
  });

  it("fails install when compose runtime never reaches running", async () => {
    const { tempDir, composePath } = await writeCatalogComposeFile(
      "store-install-health-stopped-",
      `services:
  grafana:
    image: grafana/grafana:latest
`,
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "grafana",
      templateName: "grafana",
      name: "Grafana",
      description: "observability",
      platform: "Docker",
      note: "note",
      categories: ["Developer"],
      logoUrl: null,
      port: 3000,
      repositoryUrl: "https://github.com/grafana/grafana",
      stackFile: "Apps/Grafana/docker-compose.yml",
      composePath,
      env: [],
      screenshots: [],
      image: "grafana/grafana:latest",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "grafana",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue(null);
    vi.mocked(findStackByWebUiPort).mockResolvedValue(null);
    vi.mocked(materializeInlineStackFiles).mockResolvedValue({
      stackDir: "/tmp/store/stacks/grafana",
      composePath: "/tmp/store/stacks/grafana/docker-compose.yml",
      envPath: "/tmp/store/stacks/grafana/.env",
      stackName: "grafana",
      webUiPort: 3000,
    });

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(getComposeRuntimeInfo).mockResolvedValue({
      status: "stopped",
      lifecycleStatus: "stopped",
      containerNames: ["grafana"],
      primaryContainerName: "grafana",
      condition: { condition: "stopped", exitCode: null },
    });

    let failed = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!failed && patch.status === "error") {
          failed = true;
          resolve();
        }
      });
    });

    const { operationId } = await startStoreOperation({
      appId: "grafana",
      action: "install",
    });

    await done;

    expect(upsertInstalledStack).not.toHaveBeenCalled();
    const latest = await waitForLatestEventType(operationId, "operation.failed");
    expect(latest?.message).toBe(
      "Deployment failed because containers never reached a running state",
    );
    await rm(tempDir, { recursive: true, force: true });
  });

  it("fails install when compose runtime remains unknown", async () => {
    const { tempDir, composePath } = await writeCatalogComposeFile(
      "store-install-health-unknown-",
      `services:
  kuma:
    image: louislam/uptime-kuma:latest
`,
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "uptime-kuma",
      templateName: "uptime-kuma",
      name: "Uptime Kuma",
      description: "monitoring",
      platform: "Docker",
      note: "note",
      categories: ["Monitoring"],
      logoUrl: null,
      port: 3001,
      repositoryUrl: "https://github.com/louislam/uptime-kuma",
      stackFile: "Apps/Uptime-Kuma/docker-compose.yml",
      composePath,
      env: [],
      screenshots: [],
      image: "louislam/uptime-kuma:latest",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "kuma",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue(null);
    vi.mocked(findStackByWebUiPort).mockResolvedValue(null);
    vi.mocked(materializeInlineStackFiles).mockResolvedValue({
      stackDir: "/tmp/store/stacks/uptime-kuma",
      composePath: "/tmp/store/stacks/uptime-kuma/docker-compose.yml",
      envPath: "/tmp/store/stacks/uptime-kuma/.env",
      stackName: "uptime-kuma",
      webUiPort: 3001,
    });

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(getComposeRuntimeInfo).mockResolvedValue({
      status: "unknown",
      lifecycleStatus: "unknown",
      containerNames: [],
      primaryContainerName: null,
      condition: { condition: "unknown", exitCode: null },
    });

    let failed = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!failed && patch.status === "error") {
          failed = true;
          resolve();
        }
      });
    });

    const { operationId } = await startStoreOperation({
      appId: "uptime-kuma",
      action: "install",
    });

    await done;

    expect(upsertInstalledStack).not.toHaveBeenCalled();
    const latest = await waitForLatestEventType(operationId, "operation.failed");
    expect(latest?.message).toBe(
      "Deployment failed because runtime status could not be confirmed",
    );
    await rm(tempDir, { recursive: true, force: true });
  });

  it("keeps install successful when containers are stable but the Web UI probe fails", async () => {
    const { tempDir, composePath } = await writeCatalogComposeFile(
      "store-install-health-webui-",
      `services:
  home-assistant:
    image: ghcr.io/home-assistant/home-assistant:latest
`,
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "home-assistant",
      templateName: "home-assistant",
      name: "Home Assistant",
      description: "automation",
      platform: "Docker",
      note: "note",
      categories: ["Automation"],
      logoUrl: null,
      port: 8123,
      repositoryUrl: "https://github.com/home-assistant/core",
      stackFile: "Apps/Home-Assistant/docker-compose.yml",
      composePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/home-assistant/home-assistant:latest",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "home-assistant",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue(null);
    vi.mocked(findStackByWebUiPort).mockResolvedValue(null);
    vi.mocked(materializeInlineStackFiles).mockResolvedValue({
      stackDir: "/tmp/store/stacks/home-assistant",
      composePath: "/tmp/store/stacks/home-assistant/docker-compose.yml",
      envPath: "/tmp/store/stacks/home-assistant/.env",
      stackName: "home-assistant",
      webUiPort: 8123,
    });

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(upsertInstalledStack).mockResolvedValue(undefined);
    vi.stubGlobal(
      "fetch",
      vi.fn().mockRejectedValue(new TypeError("Failed to fetch")),
    );

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    const { operationId } = await startStoreOperation({
      appId: "home-assistant",
      action: "install",
    });

    await done;

    expect(upsertInstalledStack).toHaveBeenCalledWith(
      expect.objectContaining({
        appId: "home-assistant",
      }),
    );
    const latest = await waitForLatestEventType(operationId, "operation.completed");
    expect(latest?.status).toBe("success");
    expect(updateStoreOperation).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        currentStep: "health-check",
      }),
    );
    await rm(tempDir, { recursive: true, force: true });
  });

  it("hard-deletes stack record on uninstall and removes data when requested", async () => {
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "2fauth",
      templateName: "2fauth",
      stackName: "big-bear-2fauth",
      composePath: "/tmp/store/stacks/2fauth/docker-compose.yml",
      status: "installed",
      webUiPort: 8000,
      env: {},
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(runComposeDown).mockResolvedValue(undefined);
    vi.mocked(cleanupComposeDataOnUninstall).mockResolvedValue(undefined);
    vi.mocked(deleteInstalledStackByAppId).mockResolvedValue(undefined);

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    const { operationId } = await startStoreOperation({
      appId: "2fauth",
      action: "uninstall",
      removeVolumes: true,
    });

    await done;

    expect(runComposeDown).toHaveBeenCalledWith({
      composePath: "/tmp/store/stacks/2fauth/docker-compose.yml",
      envPath: "/tmp/store/stacks/2fauth/.env",
      stackName: "big-bear-2fauth",
      removeVolumes: true,
    });
    expect(cleanupComposeDataOnUninstall).toHaveBeenCalledWith({
      composePath: "/tmp/store/stacks/2fauth/docker-compose.yml",
      removeVolumes: true,
    });
    expect(deleteInstalledStackByAppId).toHaveBeenCalledWith("2fauth");
    expect(invalidateInstalledAppsCache).toHaveBeenCalledTimes(1);

    const latest = await waitForLatestEventType(operationId, "operation.completed");
    expect(latest?.status).toBe("success");
  });

  it("always cleans up installed stack files during uninstall even when data deletion is disabled", async () => {
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "pingvin-share",
      templateName: "pingvin-share",
      stackName: "pingvin-share",
      composePath: "/var/lib/home-server/Apps/pingvin-share/docker-compose.yml",
      status: "installed",
      webUiPort: 3410,
      env: {},
      installedAt: "2026-03-07T00:00:00.000Z",
      updatedAt: "2026-03-07T00:00:00.000Z",
    });

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(runComposeDown).mockResolvedValue(undefined);
    vi.mocked(cleanupComposeDataOnUninstall).mockResolvedValue(undefined);
    vi.mocked(deleteInstalledStackByAppId).mockResolvedValue(undefined);

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    await startStoreOperation({
      appId: "pingvin-share",
      action: "uninstall",
      removeVolumes: false,
    });

    await done;

    expect(runComposeDown).toHaveBeenCalledWith({
      composePath: "/var/lib/home-server/Apps/pingvin-share/docker-compose.yml",
      envPath: "/var/lib/home-server/Apps/pingvin-share/.env",
      stackName: "pingvin-share",
      removeVolumes: false,
    });
    expect(cleanupComposeDataOnUninstall).toHaveBeenCalledWith({
      composePath: "/var/lib/home-server/Apps/pingvin-share/docker-compose.yml",
      removeVolumes: false,
    });
    expect(deleteInstalledStackByAppId).toHaveBeenCalledWith("pingvin-share");
  });

  it("uses legacy storage mapping on redeploy for existing apps", async () => {
    const { tempDir, composePath } = await writeCatalogComposeFile(
      "store-redeploy-",
      `services:
  home-assistant:
    image: ghcr.io/example/home-assistant:latest
`,
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "home-assistant",
      templateName: "home-assistant",
      name: "Home Assistant",
      description: "automation",
      platform: "Docker",
      note: "note",
      categories: ["Automation"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/bigbeartechworld/big-bear-portainer",
      stackFile: "Apps/home-assistant/docker-compose.yml",
      composePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/home-assistant:latest",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "home-assistant",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "home-assistant",
      templateName: "home-assistant",
      stackName: "home-assistant",
      composePath: "/tmp/store/stacks/home-assistant/docker-compose.yml",
      status: "installed",
      webUiPort: null,
      env: {},
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });
    vi.mocked(findStackByWebUiPort).mockResolvedValue(null);
    vi.mocked(materializeInlineStackFiles).mockResolvedValue({
      stackDir: "/tmp/store/stacks/home-assistant",
      composePath: "/tmp/store/stacks/home-assistant/docker-compose.yml",
      envPath: "/tmp/store/stacks/home-assistant/.env",
      stackName: "home-assistant",
      webUiPort: null,
    });

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(upsertInstalledStack).mockResolvedValue(undefined);

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    await startStoreOperation({
      appId: "home-assistant",
      action: "redeploy",
    });

    await done;

    expect(materializeInlineStackFiles).toHaveBeenCalledWith(
      expect.objectContaining({
        appId: "home-assistant",
        storageMappingStrategy: "legacy_named_source",
      }),
    );
    expect(invalidateInstalledAppsCache).toHaveBeenCalledTimes(1);
    await rm(tempDir, { recursive: true, force: true });
  });

  it("uses composeSource for install materialization and bypasses template env whitelist", async () => {
    const composeSource = `services:\n  immich-server:\n    image: ghcr.io/immich-app/immich-server:v2.5.6\n    ports:\n      - \"2283:2283\"\n    environment:\n      DB_HOSTNAME: immich-postgres\n      CUSTOM_ENV: yes\n`;

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "immich",
      templateName: "immich",
      name: "Immich",
      description: "photos",
      platform: "Docker",
      note: "note",
      categories: ["Photos"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/bigbeartechworld/big-bear-portainer",
      stackFile: "Apps/immich/docker-compose.yml",
      composePath: "/tmp/store/immich/docker-compose.yml",
      env: [{ name: "TZ", default: "UTC" }],
      screenshots: [],
      image: "ghcr.io/immich-app/immich-server:v2.5.6",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "immich-server",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue(null);
    vi.mocked(findStackByWebUiPort).mockResolvedValue(null);
    vi.mocked(parseComposeFile).mockReturnValue({
      services: {
        "immich-server": {
          image: "ghcr.io/immich-app/immich-server:v2.5.6",
          ports: ["2283:2283"],
          environment: {
            DB_HOSTNAME: "immich-postgres",
            CUSTOM_ENV: "yes",
          },
        },
      },
    });
    vi.mocked(extractPrimaryServiceWithName).mockReturnValue({
      name: "immich-server",
      service: {
        image: "ghcr.io/immich-app/immich-server:v2.5.6",
        ports: ["2283:2283"],
        environment: {
          DB_HOSTNAME: "immich-postgres",
          CUSTOM_ENV: "yes",
        },
      },
    });
    vi.mocked(materializeInlineStackFiles).mockResolvedValue({
      stackDir: "/tmp/store/stacks/immich",
      composePath: "/tmp/store/stacks/immich/docker-compose.yml",
      envPath: "/tmp/store/stacks/immich/.env",
      stackName: "immich",
      webUiPort: 2283,
    });

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(upsertInstalledStack).mockResolvedValue(undefined);

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    await startStoreOperation({
      appId: "immich",
      action: "install",
      composeSource,
    });

    await done;

    expect(materializeInlineStackFiles).toHaveBeenCalledWith(
      expect.objectContaining({
        appId: "immich",
        composeContent: composeSource,
      }),
    );
    expect(upsertInstalledStack).toHaveBeenCalledWith(
      expect.objectContaining({
        appId: "immich",
        env: expect.objectContaining({
          AppID: "immich",
          DB_HOSTNAME: "immich-postgres",
          CUSTOM_ENV: "yes",
        }),
      }),
    );
  });

  it("injects AppID into compose interpolation env during install", async () => {
    const composeSource = `services:\n  home-assistant:\n    image: ghcr.io/home-assistant/home-assistant:latest\n    volumes:\n      - /DATA/AppData/$AppID/config:/config\n`;

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "home-assistant",
      templateName: "home-assistant",
      name: "Home Assistant",
      description: "automation",
      platform: "Docker",
      note: "note",
      categories: ["Automation"],
      logoUrl: null,
      port: 8123,
      repositoryUrl: "https://github.com/home-assistant/core",
      stackFile: "Apps/home-assistant/docker-compose.yml",
      composePath: "/tmp/store/home-assistant/docker-compose.yml",
      env: [],
      screenshots: [],
      image: "ghcr.io/home-assistant/home-assistant:latest",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "home-assistant",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue(null);
    vi.mocked(findStackByWebUiPort).mockResolvedValue(null);
    vi.mocked(parseComposeFile).mockReturnValue({
      services: {
        "home-assistant": {
          image: "ghcr.io/home-assistant/home-assistant:latest",
          volumes: ["/DATA/AppData/$AppID/config:/config"],
        },
      },
    });
    vi.mocked(extractPrimaryServiceWithName).mockReturnValue({
      name: "home-assistant",
      service: {
        image: "ghcr.io/home-assistant/home-assistant:latest",
        volumes: ["/DATA/AppData/$AppID/config:/config"],
      },
    });
    vi.mocked(materializeInlineStackFiles).mockResolvedValue({
      stackDir: "/tmp/store/stacks/home-assistant",
      composePath: "/tmp/store/stacks/home-assistant/docker-compose.yml",
      envPath: "/tmp/store/stacks/home-assistant/.env",
      stackName: "home-assistant",
      webUiPort: 8123,
    });

    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(upsertInstalledStack).mockResolvedValue(undefined);

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    await startStoreOperation({
      appId: "home-assistant",
      action: "install",
      composeSource,
    });

    await done;

    expect(materializeInlineStackFiles).toHaveBeenCalledWith(
      expect.objectContaining({
        appId: "home-assistant",
        env: expect.objectContaining({
          AppID: "home-assistant",
        }),
      }),
    );
  });

  it("updates only service images and preserves other compose fields", async () => {
    const tempDir = await mkdtemp(path.join(os.tmpdir(), "store-update-"));
    const composePath = path.join(tempDir, "docker-compose.yml");
    const storeComposePath = path.join(tempDir, "store-compose.yml");
    const initialCompose = `services:
  app:
    image: ghcr.io/example/app:v1
    environment:
      TZ: UTC
      APP_URL: http://localhost:3100
    ports:
      - "3100:3100"
  redis:
    image: redis:6
`;
    await writeFile(composePath, initialCompose, "utf8");
    await writeFile(
      storeComposePath,
      `services:
  app:
    image: ghcr.io/example/app:v2
  redis:
    image: redis:7
`,
      "utf8",
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      name: "Example",
      description: "example",
      platform: "Docker",
      note: "note",
      categories: ["Tools"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/example/store",
      stackFile: "Apps/example/docker-compose.yml",
      composePath: storeComposePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/app:v2",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "app",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      stackName: "example",
      composePath,
      status: "installed",
      webUiPort: 3100,
      env: {
        TZ: "UTC",
      },
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });

    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(upsertInstalledStack).mockResolvedValue(undefined);

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    await startStoreOperation({
      appId: "example",
      action: "update",
    });

    await done;

    const updatedCompose = await readFile(composePath, "utf8");
    const parsed = yaml.load(updatedCompose) as {
      services: Record<string, { image?: string; environment?: Record<string, string>; ports?: string[] }>;
    };

    expect(parsed.services.app.image).toBe("ghcr.io/example/app:v2");
    expect(parsed.services.redis.image).toBe("redis:7");
    expect(parsed.services.app.environment).toEqual({
      TZ: "UTC",
      APP_URL: "http://localhost:3100",
    });
    expect(parsed.services.app.ports).toEqual(["3100:3100"]);
    expect(runComposeUp).toHaveBeenCalledTimes(1);

    await rm(tempDir, { recursive: true, force: true });
  });

  it("treats update as no-op when installed images are already current", async () => {
    const tempDir = await mkdtemp(path.join(os.tmpdir(), "store-update-noop-"));
    const composePath = path.join(tempDir, "docker-compose.yml");
    const storeComposePath = path.join(tempDir, "store-compose.yml");
    const initialCompose = `services:\n  app:\n    image: ghcr.io/example/app:v2\n    environment:\n      TZ: UTC\n`;
    await writeFile(composePath, initialCompose, "utf8");
    await writeFile(storeComposePath, `services:\n  app:\n    image: ghcr.io/example/app:v2\n`, "utf8");

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      name: "Example",
      description: "example",
      platform: "Docker",
      note: "note",
      categories: ["Tools"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/example/store",
      stackFile: "Apps/example/docker-compose.yml",
      composePath: storeComposePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/app:v2",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "app",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      stackName: "example",
      composePath,
      status: "installed",
      webUiPort: 3100,
      env: {
        TZ: "UTC",
      },
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);

    let finished = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!finished && patch.status === "success") {
          finished = true;
          resolve();
        }
      });
    });

    const { operationId } = await startStoreOperation({
      appId: "example",
      action: "update",
    });

    await done;

    const latest = await waitForLatestEventType(operationId, "operation.completed");
    expect(latest?.status).toBe("success");
    expect(runComposeUp).not.toHaveBeenCalled();
    expect(runComposePull).not.toHaveBeenCalled();

    const composeAfterNoop = await readFile(composePath, "utf8");
    expect(composeAfterNoop).toBe(initialCompose);

    await rm(tempDir, { recursive: true, force: true });
  });

  it("fails update with explicit error when installed compose is missing", async () => {
    const { tempDir, composePath } = await writeCatalogComposeFile(
      "store-update-missing-",
      `services:
  app:
    image: ghcr.io/example/app:v2
`,
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      name: "Example",
      description: "example",
      platform: "Docker",
      note: "note",
      categories: ["Tools"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/example/store",
      stackFile: "Apps/example/docker-compose.yml",
      composePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/app:v2",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "app",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      stackName: "example",
      composePath: "/tmp/missing-compose/docker-compose.yml",
      status: "installed",
      webUiPort: 3100,
      env: {},
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);

    let failed = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!failed && patch.status === "error") {
          failed = true;
          resolve();
        }
      });
    });

    const { operationId } = await startStoreOperation({
      appId: "example",
      action: "update",
    });

    await done;

    const latest = await waitForLatestEventType(operationId, "operation.failed");
    expect(latest?.message).toBe("Installed compose source is unavailable");
    expect(runComposeUp).not.toHaveBeenCalled();
    await rm(tempDir, { recursive: true, force: true });
  });

  it("resolves moved installed compose path before update", async () => {
    const tempDir = await mkdtemp(path.join(os.tmpdir(), "store-update-resolved-compose-"));
    const fallbackDir = path.join(tempDir, "missing-compose", "example");
    await mkdir(fallbackDir, { recursive: true });
    await writeFile(
      path.join(fallbackDir, "docker-compose.yml"),
      "services:\n  app:\n    image: ghcr.io/example/app:v1\n",
      "utf8",
    );
    const storeComposePath = path.join(tempDir, "store-compose.yml");
    await writeFile(
      storeComposePath,
      "services:\n  app:\n    image: ghcr.io/example/app:v2\n",
      "utf8",
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      name: "Example",
      description: "example",
      platform: "Docker",
      note: "note",
      categories: ["Tools"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/example/store",
      stackFile: "Apps/example/docker-compose.yml",
      composePath: storeComposePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/app:v2",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "app",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      stackName: "example",
      composePath: path.join(tempDir, "missing-compose", "docker-compose.yml"),
      status: "installed",
      webUiPort: 3100,
      env: {},
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);
    vi.mocked(runComposeUp).mockResolvedValue();
    vi.mocked(getComposeRuntimeInfo).mockResolvedValue({
      status: "running",
      primaryContainerName: "example",
      condition: { condition: "running", exitCode: null },
    });

    const { operationId } = await startStoreOperation({
      appId: "example",
      action: "update",
    });

    const latest = await waitForLatestEventType(operationId, "operation.completed");
    expect(latest?.type).toBe("operation.completed");
    expect(runComposeUp).toHaveBeenCalledWith(
      expect.objectContaining({
        composePath: path.join(fallbackDir, "docker-compose.yml"),
      }),
    );

    await rm(tempDir, { recursive: true, force: true });
  });

  it("fails update when installed and store services do not match", async () => {
    const tempDir = await mkdtemp(path.join(os.tmpdir(), "store-update-mismatch-"));
    const composePath = path.join(tempDir, "docker-compose.yml");
    const storeComposePath = path.join(tempDir, "store-compose.yml");
    const initialCompose = `services:\n  app:\n    image: ghcr.io/example/app:v1\n`;
    await writeFile(composePath, initialCompose, "utf8");
    await writeFile(
      storeComposePath,
      `services:
  web:
    image: ghcr.io/example/app:v2
`,
      "utf8",
    );

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      name: "Example",
      description: "example",
      platform: "Docker",
      note: "note",
      categories: ["Tools"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/example/store",
      stackFile: "Apps/example/docker-compose.yml",
      composePath: storeComposePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/app:v2",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "web",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      stackName: "example",
      composePath,
      status: "installed",
      webUiPort: 3100,
      env: {},
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);

    let failed = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!failed && patch.status === "error") {
          failed = true;
          resolve();
        }
      });
    });

    await startStoreOperation({
      appId: "example",
      action: "update",
    });

    await done;

    const composeAfterFailure = await readFile(composePath, "utf8");
    expect(composeAfterFailure).toBe(initialCompose);
    expect(runComposeUp).not.toHaveBeenCalled();

    await rm(tempDir, { recursive: true, force: true });
  });

  it("restores backup compose when update apply fails", async () => {
    const tempDir = await mkdtemp(path.join(os.tmpdir(), "store-update-rollback-"));
    const composePath = path.join(tempDir, "docker-compose.yml");
    const storeComposePath = path.join(tempDir, "store-compose.yml");
    const initialCompose = `services:\n  app:\n    image: ghcr.io/example/app:v1\n`;
    await writeFile(composePath, initialCompose, "utf8");
    await writeFile(storeComposePath, `services:\n  app:\n    image: ghcr.io/example/app:v2\n`, "utf8");

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      name: "Example",
      description: "example",
      platform: "Docker",
      note: "note",
      categories: ["Tools"],
      logoUrl: null,
      port: null,
      repositoryUrl: "https://github.com/example/store",
      stackFile: "Apps/example/docker-compose.yml",
      composePath: storeComposePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/app:v2",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "app",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      stackName: "example",
      composePath,
      status: "installed",
      webUiPort: 3100,
      env: {},
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });

    vi.mocked(runComposeUp)
      .mockRejectedValueOnce(new Error("compose up failed"))
      .mockResolvedValueOnce(undefined);
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);

    let failed = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!failed && patch.status === "error") {
          failed = true;
          resolve();
        }
      });
    });

    await startStoreOperation({
      appId: "example",
      action: "update",
    });

    await done;

    const composeAfterRollback = await readFile(composePath, "utf8");
    expect(composeAfterRollback).toBe(initialCompose);
    expect(runComposeUp).toHaveBeenCalledTimes(2);

    await rm(tempDir, { recursive: true, force: true });
  });

  it("restores backup compose when update health-check fails after compose up", async () => {
    const tempDir = await mkdtemp(path.join(os.tmpdir(), "store-update-health-rollback-"));
    const composePath = path.join(tempDir, "docker-compose.yml");
    const storeComposePath = path.join(tempDir, "store-compose.yml");
    const initialCompose = `services:\n  app:\n    image: ghcr.io/example/app:v1\n`;
    await writeFile(composePath, initialCompose, "utf8");
    await writeFile(storeComposePath, `services:\n  app:\n    image: ghcr.io/example/app:v2\n`, "utf8");

    vi.mocked(findStoreCatalogTemplateByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      name: "Example",
      description: "example",
      platform: "Docker",
      note: "note",
      categories: ["Tools"],
      logoUrl: null,
      port: 3100,
      repositoryUrl: "https://github.com/example/store",
      stackFile: "Apps/example/docker-compose.yml",
      composePath: storeComposePath,
      env: [],
      screenshots: [],
      image: "ghcr.io/example/app:v2",
      volumes: [],
      scheme: "http",
      index: "/",
      mainServiceName: "app",
    });
    vi.mocked(findCustomStoreTemplateByAppId).mockResolvedValue(null);
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "example",
      templateName: "example",
      stackName: "example",
      composePath,
      status: "installed",
      webUiPort: 3100,
      env: {},
      installedAt: "2026-02-24T00:00:00.000Z",
      updatedAt: "2026-02-24T00:00:00.000Z",
    });

    vi.mocked(runComposeUp).mockResolvedValue(undefined);
    vi.mocked(getComposeRuntimeInfo).mockResolvedValue({
      status: "stopped",
      lifecycleStatus: "restarting",
      containerNames: ["example-app-1"],
      primaryContainerName: "example-app-1",
      condition: { condition: "stopped", exitCode: null },
    });
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStackUpdateStatus).mockResolvedValue(undefined);

    let failed = false;
    const done = new Promise<void>((resolve) => {
      vi.mocked(updateStoreOperation).mockImplementation(async (_id, patch) => {
        if (!failed && patch.status === "error") {
          failed = true;
          resolve();
        }
      });
    });

    await startStoreOperation({
      appId: "example",
      action: "update",
    });

    await done;

    const composeAfterRollback = await readFile(composePath, "utf8");
    expect(composeAfterRollback).toBe(initialCompose);
    expect(runComposeUp).toHaveBeenCalledTimes(2);

    await rm(tempDir, { recursive: true, force: true });
  });

  it("purges latestOperationEvent after the terminal-event grace window", async () => {
    vi.mocked(findInstalledStackByAppId).mockResolvedValue({
      appId: "purge-me",
      templateName: "purge-me",
      stackName: "purge-me",
      composePath: "/tmp/store/stacks/purge-me/docker-compose.yml",
      status: "installed",
      webUiPort: 4001,
      env: {},
      installedAt: new Date("2026-02-24T00:00:00.000Z"),
      updatedAt: new Date("2026-02-24T00:00:00.000Z"),
      isUpToDate: true,
    });
    vi.mocked(createStoreOperation).mockResolvedValue(undefined);
    vi.mocked(updateStoreOperation).mockResolvedValue(undefined);
    vi.mocked(getComposeRuntimeInfo).mockResolvedValue({
      status: "running",
      lifecycleStatus: "running",
      containerNames: ["purge"],
      primaryContainerName: "purge",
      condition: { condition: "running", exitCode: null },
    });
    vi.mocked(runComposeRestart).mockResolvedValue(undefined);

    const { operationId } = await startStoreOperation({
      appId: "purge-me",
      action: "restart",
    });

    const latest = await waitForLatestEventType(operationId, "operation.completed");
    expect(latest?.status).toBe("success");

    // Wait past the test TTL (1s) plus a margin so the unref'd timer fires.
    await new Promise((resolve) => setTimeout(resolve, 1_200));

    expect(getLatestStoreOperationEvent(operationId)).toBeNull();
  });
});
bytes
51,856
lines
1,535
Type
ts
Latest commit
16 October 2026