import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; import type * as FsPromises 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/modules/store/catalog-config", () => ({ OFFICIAL_STORE_SOURCE_ID: "official-casaos", readStoreCatalogConfig: vi.fn(), readStoreCatalogSources: vi.fn(), resolveStoreCatalogsRoot: vi.fn(() => "/tmp"), writeStoreCatalogConfig: vi.fn(), writeStoreCatalogSources: vi.fn(), })); vi.mock("@/lib/server/storage/data-root", () => ({ ensureDataRootDirectories: vi.fn(), })); vi.mock("node:fs/promises", async () => { const actual = await vi.importActual("node:fs/promises"); return { ...actual, stat: vi.fn(actual.stat) }; }); import { readStoreCatalogConfig, readStoreCatalogSources, writeStoreCatalogConfig, } from "@/lib/server/modules/store/catalog-config"; import { ensureDataRootDirectories } from "@/lib/server/storage/data-root"; describe("store catalog", () => { let repoRoot = ""; beforeEach(async () => { vi.resetModules(); repoRoot = await mkdtemp(path.join(os.tmpdir(), "casaos-catalog-")); await mkdir(path.join(repoRoot, "Apps", "AdGuardHome"), { recursive: true }); await writeFile( path.join(repoRoot, "Apps", "AdGuardHome", "docker-compose.yml"), `name: adguardhome services: adguardhome: image: adguard/adguardhome:v1 ports: - "3001:3000" x-casaos: main: adguardhome category: Network title: en_us: AdGuard Home description: en_us: Network DNS blocker icon: https://example.com/logo.png screenshot_link: - https://example.com/screenshot.png tips: before_install: en_us: Read this first scheme: http index: / port_map: "3001" `, "utf8", ); await writeFile( path.join(repoRoot, "category-list.json"), JSON.stringify([{ name: "Network", description: "Network apps" }]), "utf8", ); await writeFile( path.join(repoRoot, "featured-apps.json"), JSON.stringify([{ appid: "adguardhome" }]), "utf8", ); await writeFile( path.join(repoRoot, "recommend-list.json"), JSON.stringify([{ appid: "adguardhome" }]), "utf8", ); vi.mocked(readStoreCatalogConfig).mockResolvedValue({ defaultCatalogPath: repoRoot, repoUrl: "https://github.com/IceWhaleTech/CasaOS-AppStore", updatedAt: new Date().toISOString(), }); vi.mocked(readStoreCatalogSources).mockResolvedValue([ { id: "official-casaos", name: "Official CasaOS", url: "https://github.com/IceWhaleTech/CasaOS-AppStore", kind: "official", enabled: true, status: "ready", sourcePath: repoRoot, suppressedAppIds: [], addedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lastSyncedAt: new Date().toISOString(), lastError: null, }, ]); }); afterEach(() => { vi.clearAllMocks(); }); it("indexes CasaOS compose apps from the local catalog checkout", async () => { const { getStoreCatalogSnapshot } = await import("@/lib/server/modules/store/catalog"); const snapshot = await getStoreCatalogSnapshot({ bypassCache: true }); expect(ensureDataRootDirectories).toHaveBeenCalled(); expect(snapshot.apps).toHaveLength(1); expect(snapshot.apps[0]).toMatchObject({ appId: "adguardhome", name: "AdGuard Home", description: "Network DNS blocker", categories: ["Network"], repositoryUrl: "https://github.com/IceWhaleTech/CasaOS-AppStore", stackFile: "Apps/AdGuardHome/docker-compose.yml", port: 3001, screenshots: ["https://example.com/screenshot.png"], }); expect(snapshot.categories).toEqual([ { id: "network", name: "Network", description: "Network apps", appCount: 1, }, ]); expect(snapshot.featuredAppIds).toEqual(["adguardhome"]); expect(snapshot.recommendedAppIds).toEqual(["adguardhome"]); expect(snapshot.sources).toHaveLength(1); expect(snapshot.apps[0]).toMatchObject({ sourceId: "official-casaos", sourceKind: "official", sourceName: "Official CasaOS", }); }); it("does not re-stat the catalog on every request", async () => { // buildSignature stats one file per app. Doing that per request pegged the // CPU on real catalogs (~1000 apps) — every App Store search keystroke // walked the whole store before the cache was even consulted. const { getStoreCatalogSnapshot } = await import("@/lib/server/modules/store/catalog"); const { stat } = await import("node:fs/promises"); await getStoreCatalogSnapshot(); const statCallsAfterFirst = vi.mocked(stat).mock.calls.length; expect(statCallsAfterFirst).toBeGreaterThan(0); await getStoreCatalogSnapshot(); expect(vi.mocked(stat).mock.calls.length).toBe(statCallsAfterFirst); }); it("does not rewrite the catalog registry on every store request", async () => { // Each write takes the registry lock: store requests used to queue behind each other. const { getStoreCatalogSnapshot } = await import("@/lib/server/modules/store/catalog"); await getStoreCatalogSnapshot(); await getStoreCatalogSnapshot(); expect(writeStoreCatalogConfig).not.toHaveBeenCalled(); }); it("records the catalog when its location changed", async () => { vi.mocked(readStoreCatalogConfig).mockResolvedValue({ defaultCatalogPath: "/somewhere/else", repoUrl: "https://github.com/IceWhaleTech/CasaOS-AppStore", updatedAt: new Date().toISOString(), }); const { getStoreCatalogSnapshot } = await import("@/lib/server/modules/store/catalog"); await getStoreCatalogSnapshot(); expect(writeStoreCatalogConfig).toHaveBeenCalledWith({ defaultCatalogPath: repoRoot, repoUrl: "https://github.com/IceWhaleTech/CasaOS-AppStore", }); }); it("shares one revalidation between requests arriving together", async () => { vi.useFakeTimers(); try { const { getStoreCatalogSnapshot } = await import("@/lib/server/modules/store/catalog"); const { stat } = await import("node:fs/promises"); await getStoreCatalogSnapshot(); const statsPerRevalidation = vi.mocked(stat).mock.calls.length; vi.advanceTimersByTime(31_000); const before = vi.mocked(stat).mock.calls.length; await Promise.all(Array.from({ length: 5 }, () => getStoreCatalogSnapshot())); expect(vi.mocked(stat).mock.calls.length - before).toBeLessThanOrEqual(statsPerRevalidation); } finally { vi.useRealTimers(); } }); it("still picks up on-disk changes once the revalidation window elapses", async () => { vi.useFakeTimers(); try { const { getStoreCatalogSnapshot } = await import("@/lib/server/modules/store/catalog"); const first = await getStoreCatalogSnapshot(); expect(first.apps).toHaveLength(1); await mkdir(path.join(repoRoot, "Apps", "Jellyfin"), { recursive: true }); await writeFile( path.join(repoRoot, "Apps", "Jellyfin", "docker-compose.yml"), `name: jellyfin services: jellyfin: image: jellyfin/jellyfin:v1 x-casaos: main: jellyfin category: Media title: en_us: Jellyfin description: en_us: Media server icon: https://example.com/jellyfin.png scheme: http index: / port_map: "8096" `, "utf8", ); // Still cached — the new app must not appear yet. expect((await getStoreCatalogSnapshot()).apps).toHaveLength(1); vi.advanceTimersByTime(31_000); expect((await getStoreCatalogSnapshot()).apps).toHaveLength(2); } finally { vi.useRealTimers(); } }); });