IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

catalog-config.test.ts

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

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

import { resolveDataRootDirectory } from "@/lib/server/storage/data-root";

function buildRemoteSource(id: string) {
  return {
    id,
    name: "Community Store",
    url: `https://example.com/${id}.zip`,
    kind: "remote" as const,
    enabled: true,
    status: "ready" as const,
    sourcePath: `/DATA/AppStore/sources/${id}`,
    suppressedAppIds: [] as string[],
    addedAt: "2026-03-13T10:00:00.000Z",
    updatedAt: "2026-03-13T10:00:00.000Z",
    lastSyncedAt: "2026-03-13T10:00:00.000Z",
    lastError: null,
  };
}

describe("store catalog config", () => {
  let tempRoot = "";

  beforeEach(async () => {
    vi.resetModules();
    tempRoot = await mkdtemp(path.join(os.tmpdir(), "homeio-store-config-"));
    vi.mocked(resolveDataRootDirectory).mockReturnValue(tempRoot);
    await mkdir(path.join(tempRoot, "AppStore"), { recursive: true });
  });

  afterEach(() => {
    vi.clearAllMocks();
  });

  it("migrates the legacy single-source config into an official registry source", async () => {
    await writeFile(
      path.join(tempRoot, "AppStore", "catalog-source.json"),
      JSON.stringify({
        defaultCatalogPath: "/DATA/AppStore/CasaOS-AppStore",
        repoUrl: "https://github.com/IceWhaleTech/CasaOS-AppStore",
        updatedAt: "2026-03-13T10:00:00.000Z",
      }),
      "utf8",
    );

    const { OFFICIAL_STORE_SOURCE_ID, readStoreCatalogSources } = await import(
      "@/lib/server/modules/store/catalog-config"
    );

    const sources = await readStoreCatalogSources();

    expect(sources).toEqual([
      expect.objectContaining({
        id: OFFICIAL_STORE_SOURCE_ID,
        kind: "official",
        enabled: true,
        sourcePath: "/DATA/AppStore/CasaOS-AppStore",
        url: "https://github.com/IceWhaleTech/CasaOS-AppStore",
      }),
    ]);
  });

  it("writes a versioned source registry and keeps the official source first", async () => {
    const { OFFICIAL_STORE_SOURCE_ID, writeStoreCatalogSources } = await import(
      "@/lib/server/modules/store/catalog-config"
    );

    await writeStoreCatalogSources([
      {
        id: "remote-1",
        name: "Community Store",
        url: "https://example.com/store.zip",
        kind: "remote",
        enabled: true,
        status: "ready",
        sourcePath: "/DATA/AppStore/sources/remote-1",
        suppressedAppIds: [],
        addedAt: "2026-03-13T10:00:00.000Z",
        updatedAt: "2026-03-13T10:00:00.000Z",
        lastSyncedAt: "2026-03-13T10:00:00.000Z",
        lastError: null,
      },
    ]);

    const persisted = JSON.parse(
      await readFile(path.join(tempRoot, "AppStore", "catalog-source.json"), "utf8"),
    ) as { version: number; sources: Array<{ id: string }> };

    expect(persisted.version).toBe(2);
    expect(persisted.sources[0]?.id).toBe(OFFICIAL_STORE_SOURCE_ID);
    expect(persisted.sources[1]?.id).toBe("remote-1");
  });

  it("keeps every source when mutations run concurrently", async () => {
    const { mutateStoreCatalogSources, readStoreCatalogSources } = await import(
      "@/lib/server/modules/store/catalog-config"
    );

    // Catalog snapshot loads, app installs and store search all mutate the
    // registry, and they overlap. Read-modify-write without a lock keeps only
    // the last writer's source.
    await Promise.all(
      Array.from({ length: 5 }, (_, index) =>
        mutateStoreCatalogSources((sources) => ({
          sources: [...sources, buildRemoteSource(`remote-${index}`)],
          result: undefined,
        })),
      ),
    );

    const persisted = await readStoreCatalogSources();
    expect(persisted.map((source) => source.id).sort()).toEqual([
      "official-casaos",
      "remote-0",
      "remote-1",
      "remote-2",
      "remote-3",
      "remote-4",
    ]);
  });

  it("keeps a concurrently added source when a slow reader updates its own", async () => {
    const { mutateStoreCatalogSources, readStoreCatalogSources } = await import(
      "@/lib/server/modules/store/catalog-config"
    );

    // A snapshot load reads the registry, then spends seconds scanning the
    // store on disk before it records the result of its own sync.
    const staleSources = await readStoreCatalogSources();
    expect(staleSources.map((source) => source.id)).not.toContain("remote-1");

    // Meanwhile the user adds a source, which lands on disk first.
    await mutateStoreCatalogSources((sources) => ({
      sources: [...sources, buildRemoteSource("remote-1")],
      result: undefined,
    }));

    // The slow reader finishes: its update must apply to the registry as it is
    // now, not to the list it captured before the add.
    await mutateStoreCatalogSources((sources) => ({
      sources: sources.map((source) =>
        source.id === "official-casaos"
          ? { ...source, updatedAt: "2026-03-13T11:00:00.000Z" }
          : source,
      ),
      result: undefined,
    }));

    const persisted = await readStoreCatalogSources();
    expect(persisted.map((source) => source.id)).toContain("remote-1");
  });

  it("does not fall back to the official source when the registry is unreadable", async () => {
    const registryPath = path.join(tempRoot, "AppStore", "catalog-source.json");
    const { readStoreCatalogSources, writeStoreCatalogSources } = await import(
      "@/lib/server/modules/store/catalog-config"
    );

    await writeStoreCatalogSources([buildRemoteSource("remote-1")]);

    // A torn write leaves truncated JSON behind.
    const raw = await readFile(registryPath, "utf8");
    await writeFile(registryPath, raw.slice(0, Math.floor(raw.length / 2)), "utf8");

    // Today this resolves to the official source alone, and the next write
    // persists that truncated list -- the user's source is gone for good.
    await expect(readStoreCatalogSources()).rejects.toThrow();
  });

  it("replaces the registry atomically and leaves no temp files behind", async () => {
    const { writeStoreCatalogSources } = await import(
      "@/lib/server/modules/store/catalog-config"
    );

    await writeStoreCatalogSources([buildRemoteSource("remote-1")]);

    const entries = await readdir(path.join(tempRoot, "AppStore"));
    expect(entries).toEqual(["catalog-source.json"]);
  });
});
bytes
6,547
lines
189
Type
ts
Latest commit
16 October 2026