IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

configurator-mapper.test.ts

Open raw
import { describe, expect, it } from "vitest";

import {
  buildInstallPayloadFromClassic,
  buildSettingsPayloadFromClassic,
  buildInitialComposeDraft,
  classicStateToCompose,
  composeToClassicState,
  createDefaultClassicState,
  safeComposeToClassicState,
} from "@/modules/apps/components/configurator/configurator-mapper";

describe("configurator-mapper", () => {
  it("maps compose draft to classic state", () => {
    const composeDraft = `services:\n  app:\n    image: ghcr.io/example/app:latest\n    ports:\n      - \"8080:80\"\n    volumes:\n      - \"/data/app:/config\"\n    environment:\n      APP_URL: \"http://example.local:8080\"\n      TZ: \"UTC\"\n    network_mode: bridge\n    restart: unless-stopped\n    privileged: true\n    cap_add:\n      - NET_ADMIN\n    hostname: example-app\n    devices:\n      - /dev/ttyUSB0:/dev/ttyUSB0\n    command: [\"--config\",\"/config/settings.yaml\"]\n`;

    const state = composeToClassicState({
      composeDraft,
      seed: {
        title: "Example",
        iconUrl: "",
        fallbackPort: 8080,
      },
    });

    expect(state.dockerImage).toBe("ghcr.io/example/app:latest");
    expect(state.webUi.port).toBe("8080");
    expect(state.ports[0]?.container).toBe("80");
    expect(state.volumes[0]?.host).toBe("/data/app");
    expect(state.envVars.find((entry) => entry.key === "TZ")?.value).toBe("UTC");
    expect(state.restartPolicy).toBe("unless-stopped");
    expect(state.privileged).toBe(true);
    expect(state.capabilities).toContain("NET_ADMIN");
    expect(state.devices[0]).toBe("/dev/ttyUSB0:/dev/ttyUSB0");
    expect(state.containerCommands).toEqual(["--config", "/config/settings.yaml"]);
  });

  it("maps long syntax ports and volumes to classic state", () => {
    const composeDraft = `services:
  app:
    image: ghcr.io/example/app:latest
    ports:
      - target: 80
        published: 8080
        protocol: tcp
    volumes:
      - type: bind
        source: /data/app
        target: /config
      - type: volume
        source: app-cache
        target: /cache
`;

    const state = composeToClassicState({
      composeDraft,
      seed: {
        title: "Example",
        iconUrl: "",
      },
    });

    expect(state.ports[0]?.host).toBe("8080");
    expect(state.ports[0]?.container).toBe("80");
    expect(state.volumes[0]?.host).toBe("/data/app");
    expect(state.volumes[0]?.container).toBe("/config");
    expect(state.volumes[1]?.host).toBe("app-cache");
    expect(state.volumes[1]?.container).toBe("/cache");
  });

  it("prefers app service over db sidecar when appId matches sidecar name", () => {
    const composeDraft = `services:
  app:
    image: photoprism/photoprism:240915
    ports:
      - "2342:2342"
    network_mode: host
  photoprism-db:
    image: mariadb:10.8
`;

    const state = composeToClassicState({
      composeDraft,
      appId: "photoprism",
      seed: {
        title: "Photoprism",
        iconUrl: "",
      },
    });

    expect(state.dockerImage).toBe("photoprism/photoprism:240915");
    expect(state.webUi.port).toBe("2342");
    expect(state.network).toBe("host");
    expect(state.ports[0]?.host).toBe("2342");
  });

  it("replaces loopback APP_URL host with fallback host for editing", () => {
    const composeDraft = `services:
  app:
    image: ghcr.io/example/app:latest
    environment:
      APP_URL: "http://localhost:3000"
`;

    const state = composeToClassicState({
      composeDraft,
      seed: {
        title: "Example",
        iconUrl: "",
        fallbackPort: 3000,
        fallbackHost: "192.168.1.12",
      },
    });

    expect(state.webUi.host).toBe("192.168.1.12");
    expect(state.webUi.port).toBe("3000");
  });

  it("maps classic state back to compose while keeping unknown top-level fields", () => {
    const seed = createDefaultClassicState({
      title: "Sample",
      iconUrl: "",
      fallbackPort: 8080,
    });

    const nextState = {
      ...seed,
      dockerImage: "nginx:1.27",
      hostname: "sample-app",
      envVars: [{ id: "env-1", key: "TZ", value: "UTC" }],
      ports: [{ id: "p-1", host: "18080", container: "80", protocol: "TCP" as const }],
    };

    const previousCompose = `name: sample-stack\nservices:\n  app:\n    image: old:image\n`;
    const compose = classicStateToCompose(nextState, previousCompose);

    expect(compose).toContain("name: sample-stack");
    expect(compose).toContain("image: nginx:1.27");
    expect(compose).toContain("18080:80");
    expect(compose).toContain("hostname: sample-app");
    expect(compose).toContain("TZ: UTC");
  });

  it("removes ports when network mode is host", () => {
    const seed = createDefaultClassicState({
      title: "Host App",
      iconUrl: "",
      fallbackPort: 8123,
    });

    const compose = classicStateToCompose({
      ...seed,
      network: "host",
      ports: [{ id: "p-1", host: "8123", container: "8123", protocol: "TCP" as const }],
    });

    expect(compose).toContain("network_mode: host");
    expect(compose).not.toContain("ports:");
    expect(compose).not.toContain("8123:8123");
  });

  it("returns parse error for invalid yaml", () => {
    const parsed = safeComposeToClassicState({
      composeDraft: "services:\n  app: [",
      seed: {
        title: "Broken",
        iconUrl: "",
      },
    });

    expect(parsed.state).toBeNull();
    expect(parsed.error).toBeTruthy();
  });

  it("builds install payload including env and web ui port", () => {
    const state = createDefaultClassicState({
      title: "Install Me",
      iconUrl: "",
      fallbackPort: 8080,
      fallbackHost: "localhost",
    });

    const payload = buildInstallPayloadFromClassic({
      appId: "install-me",
      state: {
        ...state,
        envVars: [{ id: "env-1", key: "TZ", value: "UTC" }],
      },
    });

    expect(payload.appId).toBe("install-me");
    expect(payload.webUiPort).toBe(8080);
    expect(payload.env?.TZ).toBe("UTC");
    expect(payload.env?.APP_URL).toContain("localhost:8080");
    expect(payload.env?.VOLUMES).toBeUndefined();
  });

  it("sends the link override on its own, without touching the compose", () => {
    const initial = createDefaultClassicState({
      title: "Reverse Proxied",
      iconUrl: "",
      fallbackPort: 8080,
      fallbackHost: "homeio.example.com",
    });

    const payload = buildSettingsPayloadFromClassic({
      appId: "reverse-proxied",
      current: { ...initial, webUiLink: "https://myapp.example.com" },
      initial,
    });

    expect(payload.webUiUrl).toBe("https://myapp.example.com");
    // Nothing that lives in the compose file changed, so no redeploy.
    expect(payload.webUiPort).toBeUndefined();
    expect(payload.env).toBeUndefined();
  });

  it("clears the link override when the field is emptied", () => {
    const initial = createDefaultClassicState({
      title: "Back To Auto",
      iconUrl: "",
      fallbackPort: 8080,
      fallbackHost: "homeio.example.com",
      webUiLink: "https://myapp.example.com",
    });

    const payload = buildSettingsPayloadFromClassic({
      appId: "back-to-auto",
      current: { ...initial, webUiLink: "  " },
      initial,
    });

    expect(payload.webUiUrl).toBeNull();
  });

  it("leaves the link alone when only the web ui port changes", () => {
    const initial = createDefaultClassicState({
      title: "Port Only",
      iconUrl: "",
      fallbackPort: 8080,
      fallbackHost: "homeio.example.com",
    });

    const payload = buildSettingsPayloadFromClassic({
      appId: "port-only",
      current: { ...initial, webUi: { ...initial.webUi, port: "9090" } },
      initial,
    });

    expect(payload.webUiUrl).toBeUndefined();
    expect(payload.webUiPort).toBe(9090);
  });

  it("keeps the container APP_URL separate from the link override", () => {
    const initial = createDefaultClassicState({
      title: "Two Concerns",
      iconUrl: "",
      fallbackPort: 8080,
      fallbackHost: "homeio.example.com",
    });

    const payload = buildSettingsPayloadFromClassic({
      appId: "two-concerns",
      current: {
        ...initial,
        webUi: { ...initial.webUi, host: "internal.example.com" },
      },
      initial,
    });

    // The Web UI row still composes the container's APP_URL...
    expect(payload.env?.APP_URL).toBe("http://internal.example.com:8080");
    // ...and no longer doubles as the rendered link.
    expect(payload.webUiUrl).toBeUndefined();
  });

  it("does not rebuild compose volumes from legacy VOLUMES env metadata", () => {
    const compose = buildInitialComposeDraft({
      appId: "legacy-app",
      template: {
        id: "legacy-app",
        name: "Legacy App",
        description: "",
        platform: "linux",
        categories: [],
        logoUrl: null,
        repositoryUrl: "",
        stackFile: "docker-compose.yml",
        status: "installed",
        webUiPort: null,
        updateAvailable: false,
        localDigest: null,
        remoteDigest: null,
        note: "",
        env: [],
        screenshots: [],
        installedConfig: {
          appId: "legacy-app",
          templateName: "legacy-app",
          stackName: "legacy-app",
          composePath: "/tmp/docker-compose.yml",
          status: "installed",
          webUiPort: null,
          env: {
            VOLUMES: '[{"host":"/DATA/AppData/legacy-app/data","container":"/data"}]',
          },
          displayName: "Legacy App",
          iconUrl: null,
          installedAt: null,
          updatedAt: new Date().toISOString(),
          isUpToDate: true,
          lastUpdateCheck: null,
          localDigest: null,
          remoteDigest: null,
        },
      },
    });

    expect(compose).not.toContain("/DATA/AppData/legacy-app/data:/data");
    expect(compose).toContain("environment:");
  });

  it("patches only immich primary service and preserves other compose content", () => {
    const immichCompose = `services:
  immich-server:
    image: ghcr.io/immich-app/immich-server:v2.5.0
    ports:
      - "2283:2283"
    depends_on:
      - redis
      - database
    environment:
      TZ: UTC
  immich-machine-learning:
    image: ghcr.io/immich-app/immich-machine-learning:v2.5.0
  redis:
    image: redis:7
  database:
    image: postgres:14
    volumes:
      - pgdata:/var/lib/postgresql/data
networks:
  default:
    name: immich_default
volumes:
  pgdata:
`;

    const parsed = safeComposeToClassicState({
      composeDraft: immichCompose,
      seed: { title: "Immich", iconUrl: "" },
      appId: "immich",
      primaryServiceName: "immich-server",
    });
    expect(parsed.error).toBeNull();
    expect(parsed.state?.dockerImage).toBe("ghcr.io/immich-app/immich-server:v2.5.0");

    const nextCompose = classicStateToCompose(
      {
        ...parsed.state!,
        dockerImage: "ghcr.io/immich-app/immich-server:v2.5.6",
        envVars: [{ id: "env-1", key: "TZ", value: "Europe/Paris" }],
      },
      immichCompose,
      { appId: "immich", primaryServiceName: "immich-server" },
    );

    expect(nextCompose).toContain("immich-server:");
    expect(nextCompose).toContain("image: ghcr.io/immich-app/immich-server:v2.5.6");
    expect(nextCompose).toContain("immich-machine-learning:");
    expect(nextCompose).toContain("redis:");
    expect(nextCompose).toContain("depends_on:");
    expect(nextCompose).toContain("volumes:");
    expect(nextCompose).toContain("pgdata:");
    expect(nextCompose).toContain("networks:");
    expect(nextCompose).not.toContain("\n  app:\n");
  });
});
bytes
11,546
lines
379
Type
ts
Latest commit
16 October 2026