homeio
PublikFork dari doctor-io/homeio
service.test.ts
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");
});
});