src/lib/logger/__tests__/log-store.test.ts

Total Symbols
2
Lines of Code
549
Avg Complexity
1.0
Symbol Types
1

Symbols by Kind

function 2

All Symbols

Name Kind Visibility Status Lines Signature
makeEntry function - 11-21 makeEntry( overrides: Partial<import("@/lib/logger/log-store").LogEntry> = {}, ): : import("@/lib/logger/log-store").LogEntry
mockChrome function - 23-35 mockChrome()

Full Source

import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";

let addLogEntry: (typeof import("@/lib/logger/log-store"))["addLogEntry"];
let getLogEntries: (typeof import("@/lib/logger/log-store"))["getLogEntries"];
let clearLogEntries: (typeof import("@/lib/logger/log-store"))["clearLogEntries"];
let loadLogEntries: (typeof import("@/lib/logger/log-store"))["loadLogEntries"];
let onLogUpdate: (typeof import("@/lib/logger/log-store"))["onLogUpdate"];
let initLogStore: (typeof import("@/lib/logger/log-store"))["initLogStore"];
let configureLogStore: (typeof import("@/lib/logger/log-store"))["configureLogStore"];

function makeEntry(
  overrides: Partial<import("@/lib/logger/log-store").LogEntry> = {},
): import("@/lib/logger/log-store").LogEntry {
  return {
    ts: new Date().toISOString(),
    level: "info",
    ns: "[FillAll/Test]",
    msg: "test message",
    ...overrides,
  };
}

function mockChrome() {
  vi.stubGlobal("chrome", {
    storage: {
      session: {
        get: vi.fn().mockResolvedValue({}),
        set: vi.fn().mockResolvedValue(undefined),
      },
      onChanged: {
        addListener: vi.fn(),
      },
    },
  });
}

describe("log-store", () => {
  beforeEach(async () => {
    vi.resetModules();
    vi.restoreAllMocks();
    vi.useFakeTimers();

    mockChrome();

    const store = await import("@/lib/logger/log-store");
    addLogEntry = store.addLogEntry;
    getLogEntries = store.getLogEntries;
    clearLogEntries = store.clearLogEntries;
    loadLogEntries = store.loadLogEntries;
    onLogUpdate = store.onLogUpdate;
    initLogStore = store.initLogStore;
    configureLogStore = store.configureLogStore;
  });

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

  // ── addLogEntry / getLogEntries ─────────────────────────────────────────

  describe("addLogEntry + getLogEntries", () => {
    it("stores entries in memory", () => {
      const entry = makeEntry();
      addLogEntry(entry);

      const entries = getLogEntries();
      expect(entries).toHaveLength(1);
      expect(entries[0]).toEqual(entry);
    });

    it("stores multiple entries in order", () => {
      addLogEntry(makeEntry({ msg: "first" }));
      addLogEntry(makeEntry({ msg: "second" }));
      addLogEntry(makeEntry({ msg: "third" }));

      const entries = getLogEntries();
      expect(entries).toHaveLength(3);
      expect(entries[0].msg).toBe("first");
      expect(entries[2].msg).toBe("third");
    });

    it("evicts old entries when exceeding MAX_ENTRIES (1000)", () => {
      for (let i = 0; i < 1005; i++) {
        addLogEntry(makeEntry({ msg: `entry-${i}` }));
      }

      const entries = getLogEntries();
      expect(entries.length).toBeLessThanOrEqual(1000);
      // The first 5 should have been evicted
      expect(entries[0].msg).toBe("entry-5");
    });
  });

  // ── flush to storage ────────────────────────────────────────────────────

  describe("flush to storage", () => {
    it("schedules flush on addLogEntry", async () => {
      await initLogStore();
      addLogEntry(makeEntry({ msg: "will-flush" }));

      // Before flush
      expect(chrome.storage.session.set).not.toHaveBeenCalled();

      // Advance timer to trigger debounced flush
      await vi.advanceTimersByTimeAsync(600);

      expect(chrome.storage.session.set).toHaveBeenCalled();
    });

    it("batches multiple entries in a single flush", async () => {
      await initLogStore();

      addLogEntry(makeEntry({ msg: "batch-1" }));
      addLogEntry(makeEntry({ msg: "batch-2" }));
      addLogEntry(makeEntry({ msg: "batch-3" }));

      await vi.advanceTimersByTimeAsync(600);

      // Only 1 call to storage.set, not 3
      expect(chrome.storage.session.set).toHaveBeenCalledTimes(1);
    });

    it("merges with existing storage entries on flush", async () => {
      (
        chrome.storage.session.get as ReturnType<typeof vi.fn>
      ).mockResolvedValue({
        fill_all_log_entries: [makeEntry({ msg: "existing" })],
      });

      await initLogStore();

      addLogEntry(makeEntry({ msg: "new" }));
      await vi.advanceTimersByTimeAsync(600);

      const setCall = (chrome.storage.session.set as ReturnType<typeof vi.fn>)
        .mock.calls[0]?.[0];
      const allEntries = setCall?.fill_all_log_entries;
      expect(allEntries).toHaveLength(2);
      expect(allEntries[0].msg).toBe("existing");
      expect(allEntries[1].msg).toBe("new");
    });

    it("evicts oldest entries when storage exceeds MAX_ENTRIES on flush", async () => {
      // Simulate existing storage already at 999 entries
      const existing = Array.from({ length: 999 }, (_, i) =>
        makeEntry({ msg: `old-${i}` }),
      );
      (
        chrome.storage.session.get as ReturnType<typeof vi.fn>
      ).mockResolvedValue({
        fill_all_log_entries: existing,
      });

      await initLogStore();

      // Add 5 new entries → total 1004 > 1000 → triggers eviction
      for (let i = 0; i < 5; i++) {
        addLogEntry(makeEntry({ msg: `new-${i}` }));
      }
      await vi.advanceTimersByTimeAsync(600);

      const setCall = (chrome.storage.session.set as ReturnType<typeof vi.fn>)
        .mock.calls[0]?.[0];
      const allEntries = setCall?.fill_all_log_entries;
      expect(allEntries).toHaveLength(1000);
      // Newest entries should be preserved (slice keeps the tail)
      expect(allEntries[allEntries.length - 1].msg).toBe("new-4");
    });

    it("handles storage.session.set failure silently", async () => {
      await initLogStore();

      (
        chrome.storage.session.set as ReturnType<typeof vi.fn>
      ).mockRejectedValueOnce(new Error("quota exceeded"));

      addLogEntry(makeEntry({ msg: "no-crash" }));
      await vi.advanceTimersByTimeAsync(600);

      // Entries remain in local memory
      const entries = getLogEntries();
      expect(entries.some((e) => e.msg === "no-crash")).toBe(true);
    });
  });

  // ── initLogStore ────────────────────────────────────────────────────────

  describe("initLogStore", () => {
    it("loads existing entries from chrome.storage.session", async () => {
      (
        chrome.storage.session.get as ReturnType<typeof vi.fn>
      ).mockResolvedValue({
        fill_all_log_entries: [
          makeEntry({ msg: "persisted-1" }),
          makeEntry({ msg: "persisted-2" }),
        ],
      });

      await initLogStore();

      const entries = getLogEntries();
      expect(entries).toHaveLength(2);
      expect(entries[0].msg).toBe("persisted-1");
    });

    it("is idempotent — second call is a no-op", async () => {
      await initLogStore();
      await initLogStore();

      expect(chrome.storage.session.get).toHaveBeenCalledTimes(1);
    });

    it("subscribes to storage.onChanged for cross-context sync", async () => {
      await initLogStore();

      expect(chrome.storage.onChanged.addListener).toHaveBeenCalled();
    });

    it("syncs local entries when storage.onChanged fires from another context", async () => {
      await initLogStore();

      // Capture the listener registered during init
      const listenerCb = (
        chrome.storage.onChanged.addListener as ReturnType<typeof vi.fn>
      ).mock.calls[0][0];

      const externalEntries = [
        makeEntry({ msg: "from-popup" }),
        makeEntry({ msg: "from-devtools" }),
      ];

      // Simulate cross-context storage change
      listenerCb(
        {
          fill_all_log_entries: { newValue: externalEntries },
        },
        "session",
      );

      const entries = getLogEntries();
      expect(entries).toHaveLength(2);
      expect(entries[0].msg).toBe("from-popup");
      expect(entries[1].msg).toBe("from-devtools");
    });

    it("notifies listeners when cross-context sync updates entries", async () => {
      await initLogStore();

      const listener = vi.fn();
      onLogUpdate(listener);

      const listenerCb = (
        chrome.storage.onChanged.addListener as ReturnType<typeof vi.fn>
      ).mock.calls[0][0];

      listenerCb(
        {
          fill_all_log_entries: { newValue: [makeEntry({ msg: "external" })] },
        },
        "session",
      );

      expect(listener).toHaveBeenCalledTimes(1);
    });

    it("ignores storage.onChanged for non-session areas", async () => {
      await initLogStore();
      addLogEntry(makeEntry({ msg: "local" }));

      const listenerCb = (
        chrome.storage.onChanged.addListener as ReturnType<typeof vi.fn>
      ).mock.calls[0][0];

      // Fire for "local" area — should be ignored
      listenerCb(
        { fill_all_log_entries: { newValue: [makeEntry({ msg: "nope" })] } },
        "local",
      );

      const entries = getLogEntries();
      expect(entries).toHaveLength(1);
      expect(entries[0].msg).toBe("local");
    });

    it("ignores storage.onChanged when key is not present", async () => {
      await initLogStore();
      addLogEntry(makeEntry({ msg: "keep" }));

      const listenerCb = (
        chrome.storage.onChanged.addListener as ReturnType<typeof vi.fn>
      ).mock.calls[0][0];

      // Fire for session area but different key
      listenerCb({ some_other_key: { newValue: "data" } }, "session");

      const entries = getLogEntries();
      expect(entries).toHaveLength(1);
      expect(entries[0].msg).toBe("keep");
    });

    it("ignores storage.onChanged when newValue is not an array", async () => {
      await initLogStore();
      addLogEntry(makeEntry({ msg: "safe" }));

      const listenerCb = (
        chrome.storage.onChanged.addListener as ReturnType<typeof vi.fn>
      ).mock.calls[0][0];

      listenerCb(
        { fill_all_log_entries: { newValue: "not-an-array" } },
        "session",
      );

      const entries = getLogEntries();
      expect(entries).toHaveLength(1);
      expect(entries[0].msg).toBe("safe");
    });

    it("handles missing chrome.storage.session gracefully", async () => {
      vi.stubGlobal("chrome", { storage: {} });
      vi.resetModules();
      const store = await import("@/lib/logger/log-store");

      await expect(store.initLogStore()).resolves.not.toThrow();
    });

    it("handles storage.session.get failure gracefully", async () => {
      (
        chrome.storage.session.get as ReturnType<typeof vi.fn>
      ).mockRejectedValueOnce(new Error("access denied"));

      await expect(initLogStore()).resolves.not.toThrow();
    });
  });

  // ── clearLogEntries ─────────────────────────────────────────────────────

  describe("clearLogEntries", () => {
    it("clears all entries from memory", async () => {
      addLogEntry(makeEntry({ msg: "before-clear" }));
      expect(getLogEntries()).toHaveLength(1);

      await clearLogEntries();
      expect(getLogEntries()).toHaveLength(0);
    });

    it("clears entries from chrome.storage.session", async () => {
      await initLogStore();
      addLogEntry(makeEntry());
      await clearLogEntries();

      expect(chrome.storage.session.set).toHaveBeenCalledWith({
        fill_all_log_entries: [],
      });
    });

    it("cancels any pending flush timer", async () => {
      await initLogStore();
      addLogEntry(makeEntry({ msg: "pending" }));

      // Clear before flush has a chance
      await clearLogEntries();
      await vi.advanceTimersByTimeAsync(600);

      // Only the clear call should have written (empty array)
      const setCalls = (chrome.storage.session.set as ReturnType<typeof vi.fn>)
        .mock.calls;
      const lastCall = setCalls[setCalls.length - 1][0];
      expect(lastCall.fill_all_log_entries).toEqual([]);
    });
  });

  // ── loadLogEntries ──────────────────────────────────────────────────────

  describe("loadLogEntries", () => {
    it("refreshes local entries from storage", async () => {
      await initLogStore();

      (
        chrome.storage.session.get as ReturnType<typeof vi.fn>
      ).mockResolvedValueOnce({
        fill_all_log_entries: [makeEntry({ msg: "loaded-fresh" })],
      });

      const entries = await loadLogEntries();
      expect(entries).toHaveLength(1);
      expect(entries[0].msg).toBe("loaded-fresh");
    });

    it("returns local entries when chrome is unavailable", async () => {
      vi.stubGlobal("chrome", undefined);
      vi.resetModules();
      const store = await import("@/lib/logger/log-store");
      store.addLogEntry(makeEntry({ msg: "local-only" }));

      const entries = await store.loadLogEntries();
      expect(entries.some((e) => e.msg === "local-only")).toBe(true);
    });

    it("handles storage.session.get failure without losing local entries", async () => {
      await initLogStore();
      addLogEntry(makeEntry({ msg: "safe" }));

      (
        chrome.storage.session.get as ReturnType<typeof vi.fn>
      ).mockRejectedValueOnce(new Error("fail"));

      const entries = await loadLogEntries();
      expect(entries.some((e) => e.msg === "safe")).toBe(true);
    });
  });

  // ── onLogUpdate ─────────────────────────────────────────────────────────

  describe("onLogUpdate", () => {
    it("notifies listener on addLogEntry", () => {
      const listener = vi.fn();
      const unsub = onLogUpdate(listener);

      addLogEntry(makeEntry({ msg: "trigger" }));
      expect(listener).toHaveBeenCalledTimes(1);
      expect(listener.mock.calls[0][0]).toEqual(
        expect.arrayContaining([expect.objectContaining({ msg: "trigger" })]),
      );

      unsub();
    });

    it("stops notifying after unsubscribe", () => {
      const listener = vi.fn();
      const unsub = onLogUpdate(listener);

      addLogEntry(makeEntry({ msg: "before" }));
      expect(listener).toHaveBeenCalledTimes(1);

      unsub();
      addLogEntry(makeEntry({ msg: "after" }));
      expect(listener).toHaveBeenCalledTimes(1); // not called again
    });

    it("handles listener errors silently", () => {
      const badListener = vi.fn(() => {
        throw new Error("listener crash");
      });
      onLogUpdate(badListener);

      // Should not throw
      expect(() => addLogEntry(makeEntry())).not.toThrow();
    });

    it("supports multiple concurrent listeners", () => {
      const listener1 = vi.fn();
      const listener2 = vi.fn();
      onLogUpdate(listener1);
      onLogUpdate(listener2);

      addLogEntry(makeEntry());

      expect(listener1).toHaveBeenCalledTimes(1);
      expect(listener2).toHaveBeenCalledTimes(1);
    });
  });

  // ── configureLogStore ───────────────────────────────────────────────────

  describe("configureLogStore", () => {
    it("updates maxEntries when provided", () => {
      configureLogStore({ maxEntries: 100 });
      // Fill beyond new limit to verify eviction respects it
      for (let i = 0; i < 105; i++) {
        addLogEntry(makeEntry({ msg: `entry-${i}` }));
      }
      expect(getLogEntries().length).toBeLessThanOrEqual(100);
    });

    it("clamps maxEntries to minimum of 50", () => {
      configureLogStore({ maxEntries: 10 });
      for (let i = 0; i < 60; i++) {
        addLogEntry(makeEntry({ msg: `entry-${i}` }));
      }
      expect(getLogEntries().length).toBeLessThanOrEqual(50);
    });

    it("does not change maxEntries when undefined is passed", () => {
      configureLogStore({});
      // Default 1000 limit should still apply — add 1005 entries
      for (let i = 0; i < 1005; i++) {
        addLogEntry(makeEntry({ msg: `entry-${i}` }));
      }
      expect(getLogEntries().length).toBeLessThanOrEqual(1000);
    });
  });

  // ── clearLogEntries (extra branches) ────────────────────────────────────

  describe("clearLogEntries (edge cases)", () => {
    it("clears without crashing when no flush timer is active", async () => {
      // Never called addLogEntry, so flushTimer is null
      await expect(clearLogEntries()).resolves.not.toThrow();
      expect(getLogEntries()).toHaveLength(0);
    });

    it("skips storage write when session storage is unavailable", async () => {
      vi.stubGlobal("chrome", {
        storage: { onChanged: { addListener: vi.fn() } },
      });
      vi.resetModules();
      const store = await import("@/lib/logger/log-store");

      await expect(store.clearLogEntries()).resolves.not.toThrow();
    });
  });

  // ── loadLogEntries (edge cases) ─────────────────────────────────────────

  describe("loadLogEntries (edge cases)", () => {
    it("does not update localEntries when stored value is not an array", async () => {
      await initLogStore();
      addLogEntry(makeEntry({ msg: "in-memory" }));

      (
        chrome.storage.session.get as ReturnType<typeof vi.fn>
      ).mockResolvedValueOnce({
        fill_all_log_entries: "not-an-array",
      });

      const entries = await loadLogEntries();
      // Local entry should be preserved since stored value was ignored
      expect(entries.some((e) => e.msg === "in-memory")).toBe(true);
    });
  });

  // ── flushToStorage (edge cases) ─────────────────────────────────────────

  describe("flushToStorage (edge cases)", () => {
    it("skips storage write when session storage becomes unavailable before flush", async () => {
      await initLogStore();
      addLogEntry(makeEntry({ msg: "queued" }));

      // Make session storage unavailable before flush fires
      vi.stubGlobal("chrome", { storage: {} });

      await vi.advanceTimersByTimeAsync(600);
      // Should not throw and entry should still be in local memory
      expect(getLogEntries().some((e) => e.msg === "queued")).toBe(true);
    });
  });
});