test: cover post-merge gaps in utils, services, and hooks
All checks were successful
CI / verify (push) Successful in 59s
All checks were successful
CI / verify (push) Successful in 59s
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).
This commit is contained in:
197
src/hooks/useGitHubContent.hook.spec.ts
Normal file
197
src/hooks/useGitHubContent.hook.spec.ts
Normal file
@@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
164
src/hooks/useImageUpload.hook.spec.ts
Normal file
164
src/hooks/useImageUpload.hook.spec.ts
Normal file
@@ -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")
|
||||
})
|
||||
})
|
||||
234
src/hooks/useNoteFreshness.hook.spec.ts
Normal file
234
src/hooks/useNoteFreshness.hook.spec.ts
Normal file
@@ -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")
|
||||
})
|
||||
})
|
||||
55
src/modules/atproto/withATProtoImages.spec.ts
Normal file
55
src/modules/atproto/withATProtoImages.spec.ts
Normal file
@@ -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(
|
||||
"",
|
||||
{ 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("", {
|
||||
pds: PDS,
|
||||
did: DID
|
||||
})
|
||||
expect(out).toMatch(/!\[my cover\]/)
|
||||
})
|
||||
|
||||
it("rewrites multiple images in one pass", () => {
|
||||
const md = " and "
|
||||
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 = ""
|
||||
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
|
||||
})
|
||||
|
||||
it("leaves CIDs that don't match the bafkrei prefix untouched", () => {
|
||||
const md = ""
|
||||
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
|
||||
})
|
||||
|
||||
it("preserves an empty alt text", () => {
|
||||
const out = withATProtoImages("", { pds: PDS, did: DID })
|
||||
expect(out).toMatch(/^!\[\]/)
|
||||
})
|
||||
})
|
||||
30
src/modules/user/service/oauthReturnPath.spec.ts
Normal file
30
src/modules/user/service/oauthReturnPath.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
38
src/utils/displayLanguage.spec.ts
Normal file
38
src/utils/displayLanguage.spec.ts
Normal file
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
33
src/utils/link.spec.ts
Normal file
33
src/utils/link.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user