From bb3a4a4dad047d04092ba4d2f1bbeccba607ddfc Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 6 Jun 2026 23:36:17 +0200 Subject: [PATCH] test: cover post-merge gaps in utils, services, and hooks Adds specs for the files added or modified in the recent merge that weren't yet covered: oauthReturnPath, withATProtoImages, link, displayLanguage, and the useGitHubContent / useImageUpload / useNoteFreshness hooks. Locks in the new pullLatest contract ({raw, failureStatus}) and the fetchLatestSha branch semantics (ok/unauthorized/offline). --- src/hooks/useGitHubContent.hook.spec.ts | 197 +++++++++++++++ src/hooks/useImageUpload.hook.spec.ts | 164 ++++++++++++ src/hooks/useNoteFreshness.hook.spec.ts | 234 ++++++++++++++++++ src/modules/atproto/withATProtoImages.spec.ts | 55 ++++ .../user/service/oauthReturnPath.spec.ts | 30 +++ src/utils/displayLanguage.spec.ts | 38 +++ src/utils/link.spec.ts | 33 +++ 7 files changed, 751 insertions(+) create mode 100644 src/hooks/useGitHubContent.hook.spec.ts create mode 100644 src/hooks/useImageUpload.hook.spec.ts create mode 100644 src/hooks/useNoteFreshness.hook.spec.ts create mode 100644 src/modules/atproto/withATProtoImages.spec.ts create mode 100644 src/modules/user/service/oauthReturnPath.spec.ts create mode 100644 src/utils/displayLanguage.spec.ts create mode 100644 src/utils/link.spec.ts diff --git a/src/hooks/useGitHubContent.hook.spec.ts b/src/hooks/useGitHubContent.hook.spec.ts new file mode 100644 index 0000000..ced0045 --- /dev/null +++ b/src/hooks/useGitHubContent.hook.spec.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/modules/repo/services/octo", () => ({ + getOctokit: vi.fn(), + runWithAuthRetry: vi.fn() +})) + +vi.mock("@/utils/notif", () => ({ + confirmMessage: vi.fn(), + errorMessage: vi.fn() +})) + +import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo" +import { confirmMessage, errorMessage } from "@/utils/notif" + +import { useGitHubContent } from "./useGitHubContent.hook" + +const make = () => useGitHubContent({ user: "alice", repo: "notes" }) + +describe("useGitHubContent.fetchLatestSha", () => { + beforeEach(() => { + vi.mocked(runWithAuthRetry).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns the sha from a file response", async () => { + vi.mocked(runWithAuthRetry).mockResolvedValue({ + data: { sha: "abc123" } + } as never) + + expect(await make().fetchLatestSha("a.md")).toEqual({ + kind: "ok", + sha: "abc123" + }) + }) + + it("returns sha=null when the path resolves to a directory (array response)", async () => { + vi.mocked(runWithAuthRetry).mockResolvedValue({ data: [] } as never) + + expect(await make().fetchLatestSha("dir/")).toEqual({ + kind: "ok", + sha: null + }) + }) + + it("returns sha=null when the data object has no sha field", async () => { + vi.mocked(runWithAuthRetry).mockResolvedValue({ + data: { type: "submodule" } + } as never) + + expect(await make().fetchLatestSha("path")).toEqual({ + kind: "ok", + sha: null + }) + }) + + it("returns kind=unauthorized on 401", async () => { + vi.mocked(runWithAuthRetry).mockRejectedValue( + Object.assign(new Error("Unauthorized"), { status: 401 }) + ) + + expect(await make().fetchLatestSha("path")).toEqual({ + kind: "unauthorized" + }) + }) + + it("returns kind=offline on non-401 errors (404, network, etc.)", async () => { + vi.mocked(runWithAuthRetry).mockRejectedValue( + Object.assign(new Error("Not found"), { status: 404 }) + ) + expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" }) + + vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("network")) + expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" }) + }) +}) + +describe("useGitHubContent.updateFile / createFile (putFile)", () => { + const mockOctokitRequest = vi.fn() + + beforeEach(() => { + mockOctokitRequest.mockReset() + vi.mocked(getOctokit).mockResolvedValue({ + request: mockOctokitRequest + } as never) + vi.mocked(confirmMessage).mockReset() + vi.mocked(errorMessage).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns the new sha and calls confirmMessage on success", async () => { + mockOctokitRequest.mockResolvedValue({ + data: { content: { sha: "new-sha" } } + }) + + const result = await make().updateFile({ + content: "# hello", + path: "a.md", + sha: "old-sha" + }) + + expect(result).toEqual({ sha: "new-sha", conflict: false }) + expect(confirmMessage).toHaveBeenCalledWith("✅ Note saved") + }) + + it("flags conflict=true on 409 and 422 responses", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + + for (const status of [409, 422]) { + mockOctokitRequest.mockRejectedValueOnce( + Object.assign(new Error("Conflict"), { status }) + ) + const result = await make().createFile({ content: "x", path: "a.md" }) + expect(result).toEqual({ sha: null, conflict: true }) + } + expect(errorMessage).toHaveBeenCalledWith( + "⚠ Conflict: this note changed on GitHub" + ) + }) + + it("returns conflict=false and calls errorMessage on non-conflict errors", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + mockOctokitRequest.mockRejectedValue( + Object.assign(new Error("Server error"), { status: 500 }) + ) + + const result = await make().updateFile({ + content: "x", + path: "a.md", + sha: "old" + }) + + expect(result).toEqual({ sha: null, conflict: false }) + expect(errorMessage).toHaveBeenCalledWith("❌ Note could not be saved") + }) +}) + +describe("useGitHubContent.uploadBinaryFile", () => { + const mockOctokitRequest = vi.fn() + + beforeEach(() => { + mockOctokitRequest.mockReset() + vi.mocked(getOctokit).mockResolvedValue({ + request: mockOctokitRequest + } as never) + vi.mocked(errorMessage).mockReset() + vi.mocked(confirmMessage).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("uploads pre-base64-encoded content without re-encoding", async () => { + mockOctokitRequest.mockResolvedValue({ + data: { content: { sha: "img-sha" } } + }) + + const result = await make().uploadBinaryFile({ + base64: "ZGF0YQ==", + path: "img/cover.png" + }) + + expect(result).toEqual({ sha: "img-sha", conflict: false }) + expect(mockOctokitRequest).toHaveBeenCalledWith( + "PUT /repos/{owner}/{repo}/contents/{+path}", + expect.objectContaining({ + path: "img/cover.png", + content: "ZGF0YQ==" + }) + ) + expect(confirmMessage).toHaveBeenCalledWith("✅ Image uploaded") + }) + + it("surfaces a conflict on existing file (422)", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + mockOctokitRequest.mockRejectedValue( + Object.assign(new Error("Exists"), { status: 422 }) + ) + + const result = await make().uploadBinaryFile({ + base64: "Zm9v", + path: "img.png" + }) + + expect(result).toEqual({ sha: null, conflict: true }) + expect(errorMessage).toHaveBeenCalledWith( + "⚠ A file already exists at this path on GitHub" + ) + }) +}) diff --git a/src/hooks/useImageUpload.hook.spec.ts b/src/hooks/useImageUpload.hook.spec.ts new file mode 100644 index 0000000..3453ab4 --- /dev/null +++ b/src/hooks/useImageUpload.hook.spec.ts @@ -0,0 +1,164 @@ +import { createPinia, setActivePinia } from "pinia" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const uploadBinaryFile = vi.fn() +const registerUploadedFile = vi.fn() + +vi.mock("@/hooks/useGitHubContent.hook", () => ({ + useGitHubContent: () => ({ uploadBinaryFile }) +})) + +vi.mock("@/modules/repo/store/userRepo.store", () => ({ + useUserRepoStore: () => ({ + files: [{ path: "alice/notes/already-here.png", sha: "x" }], + registerUploadedFile + }) +})) + +vi.mock("@/utils/notif", () => ({ + errorMessage: vi.fn() +})) + +import { errorMessage } from "@/utils/notif" + +import { useImageUpload } from "./useImageUpload.hook" + +const makeFile = (name: string, body = "fake-image-bytes") => + new File([body], name, { type: "image/png" }) + +describe("useImageUpload", () => { + beforeEach(() => { + setActivePinia(createPinia()) + uploadBinaryFile.mockReset() + registerUploadedFile.mockReset() + vi.mocked(errorMessage).mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns null and shows an error when notePath is missing", async () => { + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: undefined + }) + + expect(await uploadImage(makeFile("img.png"))).toBeNull() + expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed") + expect(uploadBinaryFile).not.toHaveBeenCalled() + }) + + it("uploads to the note's directory using the note basename and the file extension", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "alice/notes/my-note.md" + }) + + const result = await uploadImage(makeFile("photo.PNG")) + + expect(result).toEqual({ filename: "my-note.png" }) + expect(uploadBinaryFile).toHaveBeenCalledWith( + expect.objectContaining({ + path: "alice/notes/my-note.png" + }) + ) + }) + + it("deduplicates filenames against existing files in the store", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "alice/notes/already-here.md" + }) + + const result = await uploadImage(makeFile("photo.png")) + + expect(result?.filename).toBe("already-here-2.png") + }) + + it("returns null on conflict and does NOT register the file", async () => { + uploadBinaryFile.mockResolvedValue({ sha: null, conflict: true }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "x/note.md" + }) + + expect(await uploadImage(makeFile("img.png"))).toBeNull() + expect(registerUploadedFile).not.toHaveBeenCalled() + }) + + it("registers the uploaded file in the store on success", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false }) + const file = makeFile("img.png", "data") + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "x/note.md" + }) + + await uploadImage(file) + + expect(registerUploadedFile).toHaveBeenCalledWith({ + path: "x/note.png", + sha: "abc", + type: "blob", + size: file.size + }) + }) + + it("falls back to .png when the file name has no extension", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "x/note.md" + }) + + await uploadImage(makeFile("noextension")) + + expect(uploadBinaryFile).toHaveBeenCalledWith( + expect.objectContaining({ path: "x/note.png" }) + ) + }) + + it("uses the root path when the note is at the repo root", async () => { + uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false }) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "root.md" + }) + + await uploadImage(makeFile("img.png")) + + expect(uploadBinaryFile).toHaveBeenCalledWith( + expect.objectContaining({ path: "root.png" }) + ) + }) + + it("returns null and notifies on unexpected errors", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + uploadBinaryFile.mockRejectedValue(new Error("boom")) + + const { uploadImage } = useImageUpload({ + user: "alice", + repo: "notes", + notePath: "x/note.md" + }) + + expect(await uploadImage(makeFile("img.png"))).toBeNull() + expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed") + }) +}) diff --git a/src/hooks/useNoteFreshness.hook.spec.ts b/src/hooks/useNoteFreshness.hook.spec.ts new file mode 100644 index 0000000..3d32620 --- /dev/null +++ b/src/hooks/useNoteFreshness.hook.spec.ts @@ -0,0 +1,234 @@ +import { createPinia, setActivePinia } from "pinia" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { ref } from "vue" + +const { + fetchLatestSha, + addFile, + getRawContent, + saveCacheNote, + queryFileContent +} = vi.hoisted(() => ({ + fetchLatestSha: vi.fn(), + addFile: vi.fn(), + getRawContent: vi.fn((s: string) => s), + saveCacheNote: vi.fn(), + queryFileContent: vi.fn() +})) + +vi.mock("@/hooks/useGitHubContent.hook", () => ({ + useGitHubContent: () => ({ fetchLatestSha }) +})) + +vi.mock("@/hooks/useMarkdown.hook", () => ({ + markdownBuilder: () => ({ getRawContent, render: (s: string) => s }) +})) + +vi.mock("@/modules/note/cache/prepareNoteCache", () => ({ + prepareNoteCache: () => ({ + getCachedNote: async () => ({ note: null }), + saveCacheNote + }) +})) + +vi.mock("@/modules/repo/services/repo", () => ({ + queryFileContent +})) + +vi.mock("@/modules/repo/store/userRepo.store", () => ({ + useUserRepoStore: () => ({ addFile }) +})) + +import { useNoteFreshness } from "./useNoteFreshness.hook" + +const setup = (overrides: { sha?: string; path?: string; edited?: string | null } = {}) => { + setActivePinia(createPinia()) + const sha = ref(overrides.sha ?? "local-sha") + const path = ref(overrides.path ?? "note.md") + const getEditedSha = vi.fn().mockResolvedValue(overrides.edited ?? null) + return useNoteFreshness({ + user: "alice", + repo: "notes", + sha, + path, + getEditedSha + }) +} + +// Make performance.now() return values where elapsed > MIN_SPINNER_MS so the +// 400ms spinner-min-time wait is skipped. +const skipSpinnerWait = () => { + let call = 0 + vi.spyOn(performance, "now").mockImplementation(() => + call++ === 0 ? 0 : 10_000 + ) +} + +describe("useNoteFreshness.check", () => { + beforeEach(() => { + fetchLatestSha.mockReset() + addFile.mockReset() + saveCacheNote.mockReset() + queryFileContent.mockReset() + skipSpinnerWait() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("does nothing when path is empty", async () => { + const { check, status } = setup({ path: "" }) + + await check() + + expect(status.value).toBe("unknown") + expect(fetchLatestSha).not.toHaveBeenCalled() + }) + + it("sets status to 'verified' when remote sha matches local sha", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "local-sha" }) + + const { check, status, latestSha, lastCheckedAt } = setup() + await check() + + expect(status.value).toBe("verified") + expect(latestSha.value).toBe("local-sha") + expect(lastCheckedAt.value).toBeInstanceOf(Date) + }) + + it("sets status to 'outdated' when remote sha differs from local sha", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + + const { check, status } = setup() + await check() + + expect(status.value).toBe("outdated") + }) + + it("prefers the edited sha over the live sha when comparing", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "edited-sha" }) + + const { check, status } = setup({ sha: "live-sha", edited: "edited-sha" }) + await check() + + expect(status.value).toBe("verified") + }) + + it("sets status to 'unauthorized' on auth failure", async () => { + fetchLatestSha.mockResolvedValue({ kind: "unauthorized" }) + + const { check, status } = setup() + await check() + + expect(status.value).toBe("unauthorized") + }) + + it("sets status to 'offline' on network failure", async () => { + fetchLatestSha.mockResolvedValue({ kind: "offline" }) + + const { check, status } = setup() + await check() + + expect(status.value).toBe("offline") + }) + + it("sets status to 'offline' when remote returns sha=null (e.g. directory)", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: null }) + + const { check, status } = setup() + await check() + + expect(status.value).toBe("offline") + }) +}) + +describe("useNoteFreshness.pullLatest", () => { + beforeEach(() => { + fetchLatestSha.mockReset() + addFile.mockReset() + saveCacheNote.mockReset() + queryFileContent.mockReset() + getRawContent.mockClear() + skipSpinnerWait() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns raw=null with no failureStatus when path is empty", async () => { + const { pullLatest } = setup({ path: "" }) + + expect(await pullLatest()).toEqual({ raw: null, failureStatus: null }) + expect(fetchLatestSha).not.toHaveBeenCalled() + }) + + it("returns the fetched content and updates state on success", async () => { + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + queryFileContent.mockResolvedValue("BASE64") + getRawContent.mockReturnValue("# raw") + + const { pullLatest, status, latestSha, lastCheckedAt } = setup() + const result = await pullLatest() + + expect(result).toEqual({ raw: "# raw", failureStatus: null }) + expect(status.value).toBe("verified") + expect(latestSha.value).toBe("remote-sha") + expect(lastCheckedAt.value).toBeInstanceOf(Date) + expect(addFile).toHaveBeenCalledWith({ path: "note.md", sha: "remote-sha" }) + expect(saveCacheNote).toHaveBeenCalled() + }) + + it("surfaces 'unauthorized' failureStatus when remote resolve is unauthorized", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + fetchLatestSha.mockResolvedValue({ kind: "unauthorized" }) + + const { pullLatest, status } = setup() + expect(await pullLatest()).toEqual({ + raw: null, + failureStatus: "unauthorized" + }) + expect(status.value).toBe("unauthorized") + }) + + it("surfaces 'offline' failureStatus when remote resolve is offline", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + fetchLatestSha.mockResolvedValue({ kind: "offline" }) + + const { pullLatest, status } = setup() + expect(await pullLatest()).toEqual({ + raw: null, + failureStatus: "offline" + }) + expect(status.value).toBe("offline") + }) + + it("surfaces 'offline' failureStatus when blob content fetch fails", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" }) + queryFileContent.mockResolvedValue(null) + + const { pullLatest, status } = setup() + expect(await pullLatest()).toEqual({ + raw: null, + failureStatus: "offline" + }) + expect(status.value).toBe("offline") + }) + + it("uses the cached latestSha to skip a redundant fetchLatestSha call", async () => { + fetchLatestSha.mockResolvedValueOnce({ kind: "ok", sha: "cached-sha" }) + queryFileContent.mockResolvedValue("BASE64") + + const api = setup() + await api.check() // populates latestSha + expect(fetchLatestSha).toHaveBeenCalledTimes(1) + + fetchLatestSha.mockClear() + await api.pullLatest() + + expect(fetchLatestSha).not.toHaveBeenCalled() + expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha") + }) +}) diff --git a/src/modules/atproto/withATProtoImages.spec.ts b/src/modules/atproto/withATProtoImages.spec.ts new file mode 100644 index 0000000..e75afbd --- /dev/null +++ b/src/modules/atproto/withATProtoImages.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { withATProtoImages } from "./withATProtoImages" + +const PDS = "https://pds.example.com" +const DID = "did:plc:abc123" + +describe("withATProtoImages", () => { + it("rewrites a bafkrei CID image to a getBlob URL", () => { + const out = withATProtoImages( + "![cover](bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i)", + { pds: PDS, did: DID } + ) + + expect(out).toContain( + `${PDS}/xrpc/com.atproto.sync.getBlob` + ) + expect(out).toContain("did=did%3Aplc%3Aabc123") + expect(out).toContain( + "cid=bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i" + ) + }) + + it("preserves the alt text", () => { + const out = withATProtoImages("![my cover](bafkreiabc)", { + pds: PDS, + did: DID + }) + expect(out).toMatch(/!\[my cover\]/) + }) + + it("rewrites multiple images in one pass", () => { + const md = "![a](bafkreiaaa) and ![b](bafkreibbb)" + const out = withATProtoImages(md, { pds: PDS, did: DID }) + + expect(out.match(/com\.atproto\.sync\.getBlob/g)).toHaveLength(2) + expect(out).toContain("cid=bafkreiaaa") + expect(out).toContain("cid=bafkreibbb") + }) + + it("leaves regular image URLs untouched", () => { + const md = "![pic](https://example.com/a.png)" + expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md) + }) + + it("leaves CIDs that don't match the bafkrei prefix untouched", () => { + const md = "![x](sha256-abc123)" + expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md) + }) + + it("preserves an empty alt text", () => { + const out = withATProtoImages("![](bafkreiabc)", { pds: PDS, did: DID }) + expect(out).toMatch(/^!\[\]/) + }) +}) diff --git a/src/modules/user/service/oauthReturnPath.spec.ts b/src/modules/user/service/oauthReturnPath.spec.ts new file mode 100644 index 0000000..ff69482 --- /dev/null +++ b/src/modules/user/service/oauthReturnPath.spec.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from "vitest" + +import { + consumeGithubOAuthReturnPath, + GITHUB_OAUTH_RETURN_PATH_KEY +} from "./oauthReturnPath" + +describe("consumeGithubOAuthReturnPath", () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it("returns the stored path and clears the key", () => { + sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/alice/notes") + + expect(consumeGithubOAuthReturnPath()).toBe("/alice/notes") + expect(sessionStorage.getItem(GITHUB_OAUTH_RETURN_PATH_KEY)).toBeNull() + }) + + it("returns null when nothing is stored", () => { + expect(consumeGithubOAuthReturnPath()).toBeNull() + }) + + it("returns null on the second call (consume-once semantics)", () => { + sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/x") + + expect(consumeGithubOAuthReturnPath()).toBe("/x") + expect(consumeGithubOAuthReturnPath()).toBeNull() + }) +}) diff --git a/src/utils/displayLanguage.spec.ts b/src/utils/displayLanguage.spec.ts new file mode 100644 index 0000000..872ea02 --- /dev/null +++ b/src/utils/displayLanguage.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { displayLanguage } from "./displayLanguage" + +describe("displayLanguage", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("returns null when no language code is given", () => { + expect(displayLanguage()).toBeNull() + expect(displayLanguage("")).toBeNull() + }) + + it("returns a human-readable name for a known language code", () => { + expect(displayLanguage("en")).toMatch(/^English/i) + }) + + it("works for French", () => { + expect(displayLanguage("fr")).toBeTruthy() + }) + + it("returns null and logs a warning when Intl.DisplayNames throws", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + const original = Intl.DisplayNames + // Force the constructor to throw + ;(Intl as unknown as { DisplayNames: unknown }).DisplayNames = function () { + throw new Error("boom") + } + + try { + expect(displayLanguage("en")).toBeNull() + expect(warn).toHaveBeenCalled() + } finally { + ;(Intl as unknown as { DisplayNames: unknown }).DisplayNames = original + } + }) +}) diff --git a/src/utils/link.spec.ts b/src/utils/link.spec.ts new file mode 100644 index 0000000..d766ea2 --- /dev/null +++ b/src/utils/link.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" + +import { isExternalLink } from "./link" + +describe("isExternalLink", () => { + const ORIGIN = window.location.origin + + it("returns false for same-origin absolute URLs", () => { + expect(isExternalLink(`${ORIGIN}/alice/notes`)).toBe(false) + }) + + it("returns true for an https URL on a different origin", () => { + expect(isExternalLink("https://github.com/anywhere")).toBe(true) + }) + + it("returns true for an http URL on a different origin", () => { + expect(isExternalLink("http://example.com")).toBe(true) + }) + + it("returns false for relative paths (no http/https prefix)", () => { + expect(isExternalLink("/alice/notes")).toBe(false) + expect(isExternalLink("./neighbor.md")).toBe(false) + }) + + it("returns false for mailto and other non-http schemes", () => { + expect(isExternalLink("mailto:user@example.com")).toBe(false) + expect(isExternalLink("ftp://files.example.com")).toBe(false) + }) + + it("returns false for an https URL that happens to start with the origin", () => { + expect(isExternalLink(`${ORIGIN}/deep/path`)).toBe(false) + }) +})