chore: add tests

This commit is contained in:
Julien Calixte
2026-06-06 22:13:14 +02:00
parent 8a8509a0f3
commit 1a2d8f4ebf
23 changed files with 1943 additions and 3 deletions

View File

@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/modules/atproto/getAuthor", () => ({
getAuthor: vi.fn()
}))
import { getAuthor } from "@/modules/atproto/getAuthor"
import { getUrl } from "./getUrl"
describe("getUrl", () => {
beforeEach(() => {
vi.mocked(getAuthor).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null when the author cannot be resolved", async () => {
vi.mocked(getAuthor).mockResolvedValue(null)
expect(
await getUrl({ did: "did:plc:abc", rkey: "r1" })
).toBeNull()
})
it("builds a getRecord URL with the right query params on the author's PDS", async () => {
vi.mocked(getAuthor).mockResolvedValue({
handle: "alice.bsky.social",
pds: "https://pds.example.com"
})
const url = await getUrl({ did: "did:plc:abc", rkey: "rkey1" })
const parsed = new URL(url as string)
expect(parsed.origin).toBe("https://pds.example.com")
expect(parsed.pathname).toBe("/xrpc/com.atproto.repo.getRecord")
expect(parsed.searchParams.get("repo")).toBe("did:plc:abc")
expect(parsed.searchParams.get("collection")).toBe("space.remanso.note")
expect(parsed.searchParams.get("rkey")).toBe("rkey1")
})
it("passes the did to getAuthor", async () => {
vi.mocked(getAuthor).mockResolvedValue({
handle: "h",
pds: "https://pds.example.com"
})
await getUrl({ did: "did:web:example.com", rkey: "r2" })
expect(getAuthor).toHaveBeenCalledWith("did:web:example.com")
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest"
import { parseAtUri } from "./parseAtUri"
describe("parseAtUri", () => {
it("parses a did:plc AT URI", () => {
expect(
parseAtUri("at://did:plc:abc123/app.bsky.feed.post/rkey-xyz")
).toEqual({
did: "did:plc:abc123",
rkey: "rkey-xyz"
})
})
it("parses a did:web AT URI", () => {
expect(
parseAtUri("at://did:web:example.com/space.remanso.note/note-1")
).toEqual({
did: "did:web:example.com",
rkey: "note-1"
})
})
it("treats rkeys with slashes as a single trailing segment", () => {
expect(
parseAtUri("at://did:plc:abc/space.remanso.note/multi/segment")
).toEqual({
did: "did:plc:abc",
rkey: "multi/segment"
})
})
it("throws when the URI does not start with at://", () => {
expect(() =>
parseAtUri("https://did:plc:abc/collection/rkey")
).toThrow(/Invalid AT URI/)
})
it("throws when the DID prefix is missing", () => {
expect(() => parseAtUri("at://abc/collection/rkey")).toThrow(
/Invalid AT URI/
)
})
it("throws when the collection or rkey is missing", () => {
expect(() => parseAtUri("at://did:plc:abc/onlycollection")).toThrow(
/Invalid AT URI/
)
})
it("throws on empty input", () => {
expect(() => parseAtUri("")).toThrow(/Invalid AT URI/)
})
})

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest"
import { fromShortDid, toShortDid } from "./shortDid"
describe("toShortDid", () => {
it("strips did:plc: prefix", () => {
expect(toShortDid("did:plc:abc123")).toBe("abc123")
})
it("strips did: prefix but keeps the method when non-plc", () => {
expect(toShortDid("did:web:example.com")).toBe("web:example.com")
})
it("returns input unchanged when there is no did: prefix", () => {
expect(toShortDid("abc123")).toBe("abc123")
})
})
describe("fromShortDid", () => {
it("adds did:plc: prefix to bare identifiers", () => {
expect(fromShortDid("abc123")).toBe("did:plc:abc123")
})
it("adds did: prefix when method is already present", () => {
expect(fromShortDid("web:example.com")).toBe("did:web:example.com")
})
it("passes through fully-qualified DIDs unchanged", () => {
expect(fromShortDid("did:plc:abc123")).toBe("did:plc:abc123")
expect(fromShortDid("did:web:example.com")).toBe("did:web:example.com")
})
})
describe("round-trip toShortDid → fromShortDid", () => {
it.each(["did:plc:abc123", "did:web:example.com", "did:key:zXyZ"])(
"is identity for %s",
(did) => {
expect(fromShortDid(toShortDid(did))).toBe(did)
}
)
})

View File

@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("./octo", () => ({
getOctokit: vi.fn(),
runWithAuthRetry: vi.fn()
}))
// Stub heavyweight transitive imports so loading repo.ts doesn't spin up the
// data Web Worker or the full markdown rendering stack.
vi.mock("@/hooks/useMarkdown.hook", () => ({
markdownBuilder: () => ({ render: (s: string) => s })
}))
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
prepareNoteCache: () => ({
getCachedNote: async () => ({ note: null }),
saveCacheNote: vi.fn()
})
}))
import { getOctokit, runWithAuthRetry } from "./octo"
import { getFiles, queryFileContent } from "./repo"
const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({
request: vi.fn(impl)
})
describe("getFiles", () => {
beforeEach(() => {
vi.mocked(getOctokit).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns empty array when owner is missing", async () => {
expect(await getFiles("", "repo")).toEqual([])
expect(getOctokit).not.toHaveBeenCalled()
})
it("returns empty array when repo is missing", async () => {
expect(await getFiles("owner", "")).toEqual([])
expect(getOctokit).not.toHaveBeenCalled()
})
it("returns empty array when there are no commits", async () => {
const octokit = makeOctokitWithRequest((route) => {
if (route === "GET /repos/{owner}/{repo}/commits") {
return { data: [] }
}
throw new Error("unexpected route " + route)
})
vi.mocked(getOctokit).mockResolvedValue(octokit as never)
expect(await getFiles("owner", "repo")).toEqual([])
})
it("fetches the tree from the latest commit and filters out non-blob entries", async () => {
const octokit = makeOctokitWithRequest((route, params) => {
if (route === "GET /repos/{owner}/{repo}/commits") {
return {
data: [{ commit: { tree: { sha: "TREE_SHA" } } }]
}
}
if (route === "GET /repos/{owner}/{repo}/git/trees/{tree_sha}") {
expect((params as { tree_sha: string }).tree_sha).toBe("TREE_SHA")
expect((params as { recursive: string }).recursive).toBe("true")
return {
data: {
tree: [
{ path: "README.md", type: "blob", sha: "a" },
{ path: "src", type: "tree", sha: "b" },
{ path: "src/note.md", type: "blob", sha: "c" }
]
}
}
}
throw new Error("unexpected route " + route)
})
vi.mocked(getOctokit).mockResolvedValue(octokit as never)
const files = await getFiles("owner", "repo")
expect(files.map((f) => f.path)).toEqual(["README.md", "src/note.md"])
})
})
describe("queryFileContent", () => {
beforeEach(() => {
vi.mocked(runWithAuthRetry).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns null when owner or repo is missing", async () => {
expect(await queryFileContent("", "repo", "sha")).toBeNull()
expect(await queryFileContent("owner", "", "sha")).toBeNull()
expect(runWithAuthRetry).not.toHaveBeenCalled()
})
it("returns the blob content via runWithAuthRetry", async () => {
vi.mocked(runWithAuthRetry).mockImplementation(async (call) => {
const octokit = {
request: vi.fn().mockResolvedValue({
data: { content: "BASE64" }
})
}
return call(octokit as never)
})
expect(await queryFileContent("owner", "repo", "SHA")).toBe("BASE64")
})
it("returns null and swallows errors when the call fails", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("boom"))
expect(await queryFileContent("owner", "repo", "SHA")).toBeNull()
expect(warn).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/modules/user/service/signIn", () => ({
getAccessToken: vi.fn(),
refreshToken: vi.fn()
}))
import {
getAccessToken,
refreshToken
} from "@/modules/user/service/signIn"
import { runWithAuthRetry } from "./octo"
const unauthorized = () => Object.assign(new Error("Bad credentials"), { status: 401 })
const notFound = () => Object.assign(new Error("Not found"), { status: 404 })
describe("runWithAuthRetry", () => {
beforeEach(() => {
vi.mocked(getAccessToken).mockResolvedValue({
token: "t1"
} as Awaited<ReturnType<typeof getAccessToken>>)
vi.mocked(refreshToken).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
it("returns the call result on success without refreshing", async () => {
const call = vi.fn().mockResolvedValue("ok")
const result = await runWithAuthRetry(call)
expect(result).toBe("ok")
expect(call).toHaveBeenCalledTimes(1)
expect(refreshToken).not.toHaveBeenCalled()
})
it("rethrows non-401 errors immediately without refreshing", async () => {
const err = notFound()
const call = vi.fn().mockRejectedValue(err)
await expect(runWithAuthRetry(call)).rejects.toBe(err)
expect(refreshToken).not.toHaveBeenCalled()
expect(call).toHaveBeenCalledTimes(1)
})
it("refreshes and retries once on 401, returning the retry result", async () => {
vi.mocked(refreshToken).mockResolvedValue({
token: "t2"
} as Awaited<ReturnType<typeof refreshToken>>)
const call = vi
.fn()
.mockRejectedValueOnce(unauthorized())
.mockResolvedValueOnce("after-refresh")
const result = await runWithAuthRetry(call)
expect(result).toBe("after-refresh")
expect(refreshToken).toHaveBeenCalledWith({ force: true })
expect(call).toHaveBeenCalledTimes(2)
})
it("rethrows the original 401 when refresh returns null", async () => {
vi.mocked(refreshToken).mockResolvedValue(null)
const err = unauthorized()
const call = vi.fn().mockRejectedValue(err)
await expect(runWithAuthRetry(call)).rejects.toBe(err)
expect(call).toHaveBeenCalledTimes(1)
})
it("rethrows the original 401 when refresh itself throws", async () => {
vi.mocked(refreshToken).mockRejectedValue(new Error("network down"))
const err = unauthorized()
const call = vi.fn().mockRejectedValue(err)
await expect(runWithAuthRetry(call)).rejects.toBe(err)
})
it("propagates a retry-time error after a successful refresh", async () => {
vi.mocked(refreshToken).mockResolvedValue({
token: "t2"
} as Awaited<ReturnType<typeof refreshToken>>)
const retryErr = unauthorized()
const call = vi
.fn()
.mockRejectedValueOnce(unauthorized())
.mockRejectedValueOnce(retryErr)
await expect(runWithAuthRetry(call)).rejects.toBe(retryErr)
expect(call).toHaveBeenCalledTimes(2)
})
})

View File

@@ -0,0 +1,283 @@
import { createPinia, setActivePinia } from "pinia"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
vi.mock("@/data/data", () => ({
data: {
get: vi.fn().mockResolvedValue(null),
update: vi.fn().mockResolvedValue(undefined),
add: vi.fn().mockResolvedValue(undefined)
},
generateId: (type: string, id: string) => `${type}-${id}`
}))
vi.mock("@/modules/repo/services/repo", () => ({
getFiles: vi.fn().mockResolvedValue([]),
getMainReadme: vi.fn().mockResolvedValue(null),
getCachedMainReadme: vi.fn().mockResolvedValue(null),
getUserSettingsContent: vi.fn().mockResolvedValue(null)
}))
vi.mock("@/modules/user/service/signIn", () => ({
refreshToken: vi.fn().mockResolvedValue(null)
}))
import { data } from "@/data/data"
import {
getCachedMainReadme,
getFiles,
getMainReadme,
getUserSettingsContent
} from "@/modules/repo/services/repo"
import { useUserRepoStore } from "./userRepo.store"
const flushAsync = () => new Promise((r) => setTimeout(r, 0))
describe("userRepo store — synchronous mutations", () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
afterEach(() => {
vi.clearAllMocks()
})
it("resetUserRepo clears user, repo, files, and settings", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
store.userSettings = { $type: 1 } as never
store.resetUserRepo()
expect(store.user).toBe("")
expect(store.repo).toBe("")
expect(store.files).toEqual([])
expect(store.userSettings).toBeUndefined()
})
it("resetFiles clears files and sets readme to null", () => {
const store = useUserRepoStore()
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
store.readme = "<p>hi</p>"
store.resetFiles()
expect(store.files).toEqual([])
expect(store.readme).toBeNull()
})
it("addFile appends a new file with a unique sha", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.files = [{ sha: "old", path: "a.md", type: "blob" }] as never
store.addFile({ sha: "new", path: "b.md", type: "blob" } as never)
expect(store.files.map((f) => f.sha)).toEqual(["old", "new"])
expect(vi.mocked(data.update)).toHaveBeenCalled()
})
it("addFile is a no-op when the sha already exists", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
store.addFile({ sha: "x", path: "duplicate.md", type: "blob" } as never)
expect(store.files).toHaveLength(1)
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
})
it("addFile is a no-op when sha is missing", () => {
const store = useUserRepoStore()
store.files = []
store.addFile({ path: "no-sha.md", type: "blob" } as never)
expect(store.files).toHaveLength(0)
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
})
it("setFontFamily initializes userSettings when absent and persists to localStorage", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.setFontFamily("Inter")
expect(store.userSettings?.chosenFontFamily).toBe("Inter")
const persisted = JSON.parse(
localStorage.getItem("remanso:layout:alice:notes") as string
)
expect(persisted.chosenFontFamily).toBe("Inter")
})
it("setFontSize, setTitleFont, setBodyFont each persist their respective field", () => {
const store = useUserRepoStore()
store.user = "alice"
store.repo = "notes"
store.setFontSize("18px")
store.setTitleFont("Serif")
store.setBodyFont("Sans")
const persisted = JSON.parse(
localStorage.getItem("remanso:layout:alice:notes") as string
)
expect(persisted.chosenFontSize).toBe("18px")
expect(persisted.chosenTitleFont).toBe("Serif")
expect(persisted.chosenBodyFont).toBe("Sans")
})
})
describe("userRepo store — setUserRepo", () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.clearAllMocks()
vi.mocked(data.get).mockResolvedValue(null as never)
vi.mocked(getFiles).mockResolvedValue([])
vi.mocked(getMainReadme).mockResolvedValue(null)
vi.mocked(getCachedMainReadme).mockResolvedValue(null)
vi.mocked(getUserSettingsContent).mockResolvedValue(null)
})
it("sets user and repo immediately and clears loadError", async () => {
const store = useUserRepoStore()
store.loadError = "auth"
await store.setUserRepo("alice", "notes")
expect(store.user).toBe("alice")
expect(store.repo).toBe("notes")
expect(store.loadError).toBeNull()
})
it("populates files from getFiles on success", async () => {
vi.mocked(getFiles).mockResolvedValue([
{ sha: "a", path: "x.md", type: "blob" } as never
])
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.files.map((f) => f.sha)).toEqual(["a"])
})
it("sets readme from getMainReadme on success", async () => {
vi.mocked(getMainReadme).mockResolvedValue("<p>hi</p>")
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.readme).toBe("<p>hi</p>")
})
it("classifies 401 errors from getFiles as auth", async () => {
vi.mocked(getFiles).mockRejectedValue(
Object.assign(new Error("Unauthorized"), { status: 401 })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.loadError).toBe("auth")
})
it("classifies TimeoutError as network", async () => {
vi.mocked(getFiles).mockRejectedValue(
Object.assign(new Error("Timed out"), { name: "TimeoutError" })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.loadError).toBe("network")
})
it("classifies 500-range errors as network", async () => {
vi.mocked(getFiles).mockRejectedValue(
Object.assign(new Error("Server error"), { status: 503 })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.loadError).toBe("network")
})
it("does NOT surface loadError from getMainReadme when a cached readme is present", async () => {
vi.mocked(getCachedMainReadme).mockResolvedValue("<p>cached</p>")
vi.mocked(getMainReadme).mockRejectedValue(
Object.assign(new Error("Server error"), { status: 503 })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.readme).toBe("<p>cached</p>")
expect(store.loadError).toBeNull()
})
it("surfaces loadError from getMainReadme when no cached readme is present", async () => {
vi.mocked(getCachedMainReadme).mockResolvedValue(null)
vi.mocked(getMainReadme).mockRejectedValue(
Object.assign(new Error("Unauthorized"), { status: 401 })
)
vi.spyOn(console, "warn").mockImplementation(() => {})
const store = useUserRepoStore()
await store.setUserRepo("alice", "notes")
await flushAsync()
await flushAsync()
expect(store.readme).toBeNull()
expect(store.loadError).toBe("auth")
})
it("ignores stale getFiles results when a newer setUserRepo has been called (race guard)", async () => {
let resolveStale: (files: never[]) => void = () => {}
vi.mocked(getFiles).mockImplementationOnce(
() =>
new Promise<never[]>((r) => {
resolveStale = r
})
)
vi.mocked(getFiles).mockImplementationOnce(async () => [
{ sha: "fresh", path: "fresh.md", type: "blob" } as never
])
const store = useUserRepoStore()
await store.setUserRepo("alice", "stale-repo")
await store.setUserRepo("alice", "fresh-repo")
resolveStale([{ sha: "stale", path: "stale.md", type: "blob" } as never])
await flushAsync()
await flushAsync()
expect(store.repo).toBe("fresh-repo")
expect(store.files.map((f) => f.sha)).toEqual(["fresh"])
})
})