Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions scripts/provision-obsidian-e2e-vault.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ const PLUGIN_ID = "podnotes";
// "=> true" so an echoed command can't be mistaken for a positive result.
export const PODNOTES_READY_EVAL = `Boolean(app.plugins.plugins[${JSON.stringify(PLUGIN_ID)}])`;

// A valid, empty PodNotes settings document. Mirrors DEFAULT_SETTINGS in
// src/constants.ts so a freshly provisioned vault loads with clean state instead
// of QuickAdd's { choices, migrations } shape. Keep in sync with constants.ts.
// (currentEpisode is intentionally omitted DEFAULT_SETTINGS sets it to
// A valid, empty PodNotes schema-v1 document. Mirrors DEFAULT_SETTINGS plus the
// persistence marker so a freshly provisioned vault loads with clean state
// instead of QuickAdd's { choices, migrations } shape. Keep in sync with
// constants.ts. (currentEpisode is intentionally omitted - DEFAULT_SETTINGS sets it to
// undefined, which JSON cannot represent and PodNotes treats as absent.)
export const DEFAULT_PODNOTES_DATA = {
schemaVersion: 1,
savedFeeds: {},
podNotes: {},
defaultPlaybackRate: 1,
Expand Down
11 changes: 6 additions & 5 deletions scripts/provision-obsidian-e2e-vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,15 @@ describe("provision-obsidian-e2e-vault", () => {
expect(options.vaultPath).toBe("/tmp/podnotes-repo/vaults/podnotes-a");
});

it("seeds a data.json that mirrors the real DEFAULT_SETTINGS", () => {
it("seeds schema v1 data that mirrors the real DEFAULT_SETTINGS", () => {
// JSON cannot represent currentEpisode: undefined, so compare the
// serialized forms this is exactly what lands in the vault's data.json
// serialized forms - this is exactly what lands in the vault's data.json
// and it fails if a new setting is added to src/constants.ts without
// updating DEFAULT_PODNOTES_DATA.
expect(JSON.parse(JSON.stringify(DEFAULT_PODNOTES_DATA))).toEqual(
JSON.parse(JSON.stringify(DEFAULT_SETTINGS)),
);
expect(JSON.parse(JSON.stringify(DEFAULT_PODNOTES_DATA))).toEqual({
schemaVersion: 1,
...JSON.parse(JSON.stringify(DEFAULT_SETTINGS)),
});
});

it("defaults the vault name to podnotes-<worktree>", () => {
Expand Down
9 changes: 9 additions & 0 deletions src/TemplateEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,15 @@ describe("empty tag arguments (NT-05/CH-09)", () => {
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
expect(NoteTemplateEngine("{{date:YYYY}}", demoEpisode)).toBe("2024");
});

it("renders a JSON-restored episode date without throwing", () => {
const restoredEpisode = {
...demoEpisode,
episodeDate: "2024-01-01T00:00:00.000Z" as unknown as Date,
};

expect(NoteTemplateEngine("{{date:YYYY-MM-DD}}", restoredEpisode)).toBe("2024-01-01");
});
});

describe("NoteTemplateEngine feed-scoped tags (#163)", () => {
Expand Down
151 changes: 151 additions & 0 deletions src/main.persistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "./constants";
import PodNotes from "./main";

function makePlugin(
saveData: (data: unknown) => Promise<void> = vi.fn().mockResolvedValue(undefined),
): PodNotes {
const plugin = Object.create(PodNotes.prototype) as PodNotes;
Object.assign(plugin, {
isReady: true,
settings: structuredClone(DEFAULT_SETTINGS),
pendingSave: null,
pendingSaveWaiters: [],
saveScheduled: false,
saveChain: Promise.resolve(),
persistenceUnknownFields: {},
saveData,
});
return plugin;
}

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

describe("PodNotes persistence integration", () => {
it("loads legacy JSON dates as Date instances", async () => {
const plugin = makePlugin();
Object.assign(plugin, {
loadData: vi.fn().mockResolvedValue({
currentEpisode: {
title: "Restored",
streamUrl: "restored.mp3",
url: "",
description: "",
content: "",
podcastName: "Podcast",
episodeDate: "2024-03-01T10:05:03.000Z",
},
}),
});

await plugin.loadSettings();

expect(plugin.settings.currentEpisode?.episodeDate).toEqual(
new Date("2024-03-01T10:05:03.000Z"),
);
});

it("refuses future data before any save can overwrite it", async () => {
const saveData = vi.fn().mockResolvedValue(undefined);
const plugin = makePlugin(saveData);
Object.assign(plugin, {
loadData: vi.fn().mockResolvedValue({ schemaVersion: 2 }),
});
vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(plugin.loadSettings()).rejects.toThrow(/schema v2/);
expect(saveData).not.toHaveBeenCalled();
});

it("writes schema v1, canonical dates, and preserved unknown fields", async () => {
const saveData = vi.fn().mockResolvedValue(undefined);
const plugin = makePlugin(saveData);
plugin.settings.currentEpisode = {
title: "Current",
streamUrl: "current.mp3",
url: "",
description: "",
content: "",
podcastName: "Podcast",
episodeDate: new Date("2024-03-01T10:05:03.000Z"),
};
Object.assign(plugin, { persistenceUnknownFields: { retained: { enabled: true } } });

await plugin.saveSettings();

expect(saveData).toHaveBeenCalledWith(
expect.objectContaining({
schemaVersion: 1,
retained: { enabled: true },
currentEpisode: expect.objectContaining({
episodeDate: "2024-03-01T10:05:03.000Z",
}),
}),
);
});

it("rejects a strict caller when saveData fails", async () => {
const failure = new Error("disk full");
const plugin = makePlugin(vi.fn().mockRejectedValue(failure));
vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(plugin.saveSettingsStrict()).rejects.toBe(failure);
});

it("keeps best-effort saves nonrejecting while logging disk failure", async () => {
const failure = new Error("disk full");
const plugin = makePlugin(vi.fn().mockRejectedValue(failure));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);

await expect(plugin.saveSettings()).resolves.toBeUndefined();
expect(consoleError).toHaveBeenCalledWith("PodNotes: failed to save settings", failure);
});

it("normalizes a synchronous snapshot failure into the two save contracts", async () => {
const failure = new Error("cannot clone");
const plugin = makePlugin();
vi.spyOn(globalThis, "structuredClone").mockImplementation(() => {
throw failure;
});
vi.spyOn(console, "error").mockImplementation(() => undefined);

let strict: Promise<void> | undefined;
expect(() => {
strict = plugin.saveSettingsStrict();
}).not.toThrow();
await expect(strict).rejects.toBe(failure);
await expect(plugin.saveSettings()).resolves.toBeUndefined();
});

it("keeps later callers pending until their newer snapshot is durable", async () => {
const resolvers: Array<() => void> = [];
const writes: unknown[] = [];
const plugin = makePlugin(
vi.fn((data: unknown) => {
writes.push(data);
return new Promise<void>((resolve) => resolvers.push(resolve));
}),
);

const first = plugin.saveSettingsStrict();
await vi.waitFor(() => expect(writes).toHaveLength(1));
plugin.settings.defaultVolume = 0.25;
const second = plugin.saveSettingsStrict();
let secondResolved = false;
void second.then(() => {
secondResolved = true;
});

resolvers[0]();
await first;
await vi.waitFor(() => expect(writes).toHaveLength(2));
expect(secondResolved).toBe(false);
expect(writes[1]).toEqual(expect.objectContaining({ defaultVolume: 0.25 }));

resolvers[1]();
await second;
expect(secondResolved).toBe(true);
});
});
Loading