diff --git a/src/hooks/useNoteFreshness.hook.spec.ts b/src/hooks/useNoteFreshness.hook.spec.ts index 3d32620..2b980df 100644 --- a/src/hooks/useNoteFreshness.hook.spec.ts +++ b/src/hooks/useNoteFreshness.hook.spec.ts @@ -2,18 +2,27 @@ import { createPinia, setActivePinia } from "pinia" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { ref } from "vue" +import { DataType } from "@/data/DataType.enum" + const { fetchLatestSha, addFile, getRawContent, saveCacheNote, - queryFileContent + queryFileContent, + dataGet } = vi.hoisted(() => ({ fetchLatestSha: vi.fn(), addFile: vi.fn(), getRawContent: vi.fn((s: string) => s), saveCacheNote: vi.fn(), - queryFileContent: vi.fn() + queryFileContent: vi.fn(), + dataGet: vi.fn() +})) + +vi.mock("@/data/data", () => ({ + data: { get: dataGet }, + generateId: (type: string, id: string) => `${type}-${id}` })) vi.mock("@/hooks/useGitHubContent.hook", () => ({ @@ -232,3 +241,87 @@ describe("useNoteFreshness.pullLatest", () => { expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha") }) }) + +describe("useNoteFreshness.resolveMergeSources", () => { + beforeEach(() => { + fetchLatestSha.mockReset() + queryFileContent.mockReset() + dataGet.mockReset() + getRawContent.mockReset() + getRawContent.mockImplementation((s: string) => s) + skipSpinnerWait() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns null when path is empty", async () => { + const { resolveMergeSources } = setup({ path: "" }) + expect(await resolveMergeSources()).toBeNull() + }) + + it("resolves base (from cache snapshot) + theirs + remoteSha", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + queryFileContent.mockResolvedValue("THEIRS_B64") // theirs blob + dataGet.mockResolvedValue({ content: "BASE_B64" }) // base snapshot in cache + + const { resolveMergeSources, latestSha } = setup({ + sha: "live", + edited: "base-sha" + }) + const result = await resolveMergeSources() + + expect(result).toEqual({ + base: "BASE_B64", + theirs: "THEIRS_B64", + remoteSha: "remote-sha" + }) + // base must be read from its own immutable snapshot, never the path pointer + expect(dataGet).toHaveBeenCalledWith(`${DataType.Note}-base-sha`) + expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "remote-sha") + expect(latestSha.value).toBe("remote-sha") + }) + + it("falls back to fetching the base blob when not cached", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + dataGet.mockResolvedValue(null) // cache miss for base + queryFileContent.mockImplementation((_u, _r, s: string) => + Promise.resolve(s === "remote-sha" ? "THEIRS_B64" : "BASE_FROM_BLOB") + ) + + const { resolveMergeSources } = setup({ sha: "base-sha" }) + const result = await resolveMergeSources() + + expect(result?.base).toBe("BASE_FROM_BLOB") + expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "base-sha") + }) + + it("returns null when the remote sha cannot be resolved", async () => { + fetchLatestSha.mockResolvedValue({ kind: "offline" }) + + const { resolveMergeSources } = setup() + expect(await resolveMergeSources()).toBeNull() + }) + + it("returns null when the remote (theirs) blob fetch fails", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + queryFileContent.mockResolvedValue(null) + + const { resolveMergeSources, latestSha } = setup() + expect(await resolveMergeSources()).toBeNull() + // latestSha is still surfaced so a modal fallback's overwrite has a target + expect(latestSha.value).toBe("remote-sha") + }) + + it("returns null when the base blob is unavailable (cache miss + blob null)", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + dataGet.mockResolvedValue(null) + queryFileContent.mockImplementation((_u, _r, s: string) => + Promise.resolve(s === "remote-sha" ? "THEIRS_B64" : null) + ) + + const { resolveMergeSources } = setup({ sha: "base-sha" }) + expect(await resolveMergeSources()).toBeNull() + }) +}) diff --git a/src/hooks/useNoteFreshness.hook.ts b/src/hooks/useNoteFreshness.hook.ts index 129e543..c04cf43 100644 --- a/src/hooks/useNoteFreshness.hook.ts +++ b/src/hooks/useNoteFreshness.hook.ts @@ -1,8 +1,11 @@ import { Ref, ref } from "vue" +import { data, generateId } from "@/data/data" +import { DataType } from "@/data/DataType.enum" import { useGitHubContent } from "@/hooks/useGitHubContent.hook" import { markdownBuilder } from "@/hooks/useMarkdown.hook" import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache" +import { Note } from "@/modules/note/models/Note" import { queryFileContent } from "@/modules/repo/services/repo" import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" @@ -114,11 +117,52 @@ export const useNoteFreshness = ({ return { raw: getRawContent(fileContent), failureStatus: null } } + // The base blob (common ancestor) must come from its own immutable snapshot, + // never from the path pointer — that holds the latest, which is "theirs". + const getCachedBlob = async (blobSha: string): Promise => { + const note = await data.get( + generateId(DataType.Note, blobSha) + ) + return note?.content ?? null + } + + // Resolves the decoded raw text needed for a 3-way merge: `base` is the + // version we edited from (the rejected sha), `theirs` is the current remote. + // Returns null when any piece can't be resolved (offline, missing base). + const resolveMergeSources = async (): Promise<{ + base: string + theirs: string + remoteSha: string + } | null> => { + if (!path.value) return null + + const { sha: remoteSha } = await resolveRemoteSha(path.value) + if (!remoteSha) return null + // Surface the resolved sha so a modal fallback's Overwrite can use it. + latestSha.value = remoteSha + const theirsBlob = await queryFileContent(user, repo, remoteSha) + if (!theirsBlob) return null + + const baseSha = (await getEditedSha()) ?? sha.value + const baseBlob = + (await getCachedBlob(baseSha)) ?? + (await queryFileContent(user, repo, baseSha)) + if (!baseBlob) return null + + const { getRawContent } = markdownBuilder(sha.value) + return { + base: getRawContent(baseBlob), + theirs: getRawContent(theirsBlob), + remoteSha + } + } + return { status, lastCheckedAt, latestSha, check, - pullLatest + pullLatest, + resolveMergeSources } }