chore: add tests
This commit is contained in:
123
src/modules/repo/services/repo.spec.ts
Normal file
123
src/modules/repo/services/repo.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
97
src/modules/repo/services/runWithAuthRetry.spec.ts
Normal file
97
src/modules/repo/services/runWithAuthRetry.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
283
src/modules/repo/store/userRepo.store.spec.ts
Normal file
283
src/modules/repo/store/userRepo.store.spec.ts
Normal 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"])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user