IKHSAN
EN Read in EnglishID

homeio

Publik

Fork dari doctor-io/homeio

Branch
main
Commit terakhir
958d74616 Oktober 2026

service.test.ts

Buka raw
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";

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

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

const tempRoots = vi.hoisted(() => ({
  dataRoot: "",
  stacksRoot: "",
}));

vi.mock("node:fs/promises", async () => {
  const actual = await vi.importActual("node:fs/promises");

  return {
    ...actual,
    readFile: vi.fn(),
  };
});

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

vi.mock("@/lib/server/storage/data-root", () => ({
  resolveDataRootDirectory: () => tempRoots.dataRoot,
  resolveStoreStacksRoot: () => tempRoots.stacksRoot,
}));

import {
    findActiveStoreOperationsByAppIds,
    listInstalledAppsFromDb,
} from "@/lib/server/modules/apps/repository";
import {
    invalidateInstalledAppsCache,
    listInstalledApps,
} from "@/lib/server/modules/apps/service";
import {
    extractPrimaryServiceWithName,
    parseComposeFile,
} from "@/lib/server/modules/docker/compose-parser";
import { getComposeRuntimeInfo } from "@/lib/server/modules/docker/compose-runner";
import { readFile } from "node:fs/promises";

describe("apps service", () => {
  const repositoryMock = vi.mocked(listInstalledAppsFromDb);
  const activeOperationsMock = vi.mocked(findActiveStoreOperationsByAppIds);
  const runtimeInfoMock = vi.mocked(getComposeRuntimeInfo);
  const readFileMock = vi.mocked(readFile);
  const parseComposeFileMock = vi.mocked(parseComposeFile);
  const extractPrimaryServiceMock = vi.mocked(extractPrimaryServiceWithName);

  beforeEach(() => {
    invalidateInstalledAppsCache();
    repositoryMock.mockReset();
    activeOperationsMock.mockReset();
    runtimeInfoMock.mockReset();
    readFileMock.mockReset();
    parseComposeFileMock.mockReset();
    extractPrimaryServiceMock.mockReset();
    runtimeInfoMock.mockResolvedValue({
      status: "running",
      containerNames: ["nextcloud-app-1"],
      primaryContainerName: "nextcloud-app-1",
    });
    activeOperationsMock.mockResolvedValue({});
    readFileMock.mockResolvedValue("");
    parseComposeFileMock.mockReturnValue(null);
    extractPrimaryServiceMock.mockReturnValue(null);
    tempRoots.dataRoot = "";
    tempRoots.stacksRoot = "";
  });

  it("caches installed apps", async () => {
    repositoryMock.mockResolvedValueOnce([
      {
        id: "nextcloud",
        name: "Nextcloud",
        stackName: "nextcloud",
        composePath: "/DATA/AppData/nextcloud/docker-compose.yml",
        status: "unknown",
        activeOperation: null,
        updatedAt: "2026-02-22T10:00:00.000Z",
      },
    ]);

    const first = await listInstalledApps();
    const second = await listInstalledApps();

    expect(first[0]?.containerName).toBe("nextcloud-app-1");
    expect(first).toEqual(second);
    expect(repositoryMock).toHaveBeenCalledTimes(1);
  });

  it("bypasses cache when requested", async () => {
    repositoryMock.mockResolvedValue([
      {
        id: "immich",
        name: "Immich",
        stackName: "immich",
        composePath: "/DATA/AppData/immich/docker-compose.yml",
        status: "unknown",
        activeOperation: null,
        updatedAt: "2026-02-22T11:00:00.000Z",
      },
    ]);

    await listInstalledApps({ bypassCache: true });
    await listInstalledApps({ bypassCache: true });

    expect(repositoryMock).toHaveBeenCalledTimes(2);
  });

  it("falls back to empty list when db is unavailable", async () => {
    const unavailableError = Object.assign(
      new Error("connect ECONNREFUSED 127.0.0.1:5432"),
      { code: "ECONNREFUSED" },
    );
    repositoryMock.mockRejectedValueOnce(unavailableError);

    const first = await listInstalledApps({ bypassCache: true });
    const second = await listInstalledApps();

    expect(first).toEqual([]);
    expect(second).toEqual([]);
    expect(repositoryMock).toHaveBeenCalledTimes(1);
  });

  it("infers web ui port from installed compose when DB port is null", async () => {
    repositoryMock.mockResolvedValueOnce([
      {
        id: "dozzle",
        name: "Dozzle",
        stackName: "dozzle",
        composePath: "/DATA/AppData/dozzle/docker-compose.yml",
        webUiPort: null,
        status: "unknown",
        activeOperation: null,
        updatedAt: "2026-02-22T10:00:00.000Z",
      },
    ]);
    readFileMock.mockResolvedValue(
      'services:\n  app:\n    ports:\n      - "9999:8080"\n',
    );
    parseComposeFileMock.mockReturnValue({
      services: {
        app: {
          ports: ["9999:8080"],
          environment: {},
        },
      },
    });
    extractPrimaryServiceMock.mockReturnValue({
      name: "app",
      service: {
        ports: ["9999:8080"],
        environment: {},
      },
    });

    const apps = await listInstalledApps({ bypassCache: true });

    expect(apps[0]?.webUiPort).toBe(9999);
    expect(readFileMock).toHaveBeenCalledWith(
      "/DATA/AppData/dozzle/docker-compose.yml",
      "utf8",
    );
  });

  it("hydrates active operations for installed apps", async () => {
    repositoryMock.mockResolvedValueOnce([
      {
        id: "jellyfin",
        name: "Jellyfin",
        stackName: "jellyfin",
        composePath: "/DATA/AppData/jellyfin/docker-compose.yml",
        status: "unknown",
        activeOperation: null,
        updatedAt: "2026-02-22T12:00:00.000Z",
      },
    ]);
    activeOperationsMock.mockResolvedValueOnce({
      jellyfin: {
        id: "op-1",
        action: "redeploy",
        status: "running",
        progressPercent: 64,
        currentStep: "compose-up",
        updatedAt: "2026-02-22T12:00:05.000Z",
      },
    });

    const apps = await listInstalledApps({ bypassCache: true });

    expect(apps[0]?.activeOperation).toEqual({
      id: "op-1",
      action: "redeploy",
      status: "running",
      progressPercent: 64,
      currentStep: "compose-up",
      updatedAt: "2026-02-22T12:00:05.000Z",
    });
  });

  it("normalizes stale compose paths after restore and uses the active stack root", async () => {
    const actualRoot = await mkdtemp(path.join(os.tmpdir(), "homeio-apps-"));
    tempRoots.dataRoot = actualRoot;
    tempRoots.stacksRoot = path.join(actualRoot, "missing-stacks");

    const actualComposePath = path.join(
      actualRoot,
      "data-root",
      "Apps",
      "grafana",
      "docker-compose.yml",
    );
    await mkdir(path.dirname(actualComposePath), { recursive: true });
    await writeFile(
      actualComposePath,
      "services:\n  grafana:\n    image: grafana/grafana:latest\n",
      "utf8",
    );

    repositoryMock.mockResolvedValueOnce([
      {
        id: "grafana",
        name: "Grafana",
        stackName: "grafana",
        composePath: "/var/lib/home-server/Apps/grafana/docker-compose.yml",
        status: "unknown",
        activeOperation: null,
        updatedAt: "2026-02-22T10:00:00.000Z",
      },
    ]);

    runtimeInfoMock.mockResolvedValueOnce({
      status: "running",
      containerNames: ["grafana"],
      primaryContainerName: "grafana",
    });

    const apps = await listInstalledApps({ bypassCache: true });

    expect(runtimeInfoMock).toHaveBeenCalledWith({
      composePath: actualComposePath,
      envPath: path.join(path.dirname(actualComposePath), ".env"),
      stackName: "grafana",
    });
    expect(apps[0]?.composePath).toBe(actualComposePath);
    expect(apps[0]?.containerName).toBe("grafana");
  });
});
byte
7.744
baris
262
Jenis
ts
Commit terakhir
16 Oktober 2026