Merge branch 'main' of ssh://git.apoena.dev:22222/remanso-space/remanso
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Julien Calixte
2026-06-09 14:34:31 +02:00
30 changed files with 2690 additions and 8 deletions

View File

@@ -0,0 +1,76 @@
import { mount } from "@vue/test-utils"
import { afterEach, describe, expect, it, vi } from "vitest"
import { defineComponent, ref } from "vue"
const escape = ref(false)
vi.mock("@vueuse/core", () => ({
useMagicKeys: () => ({ escape })
}))
import { useEditionMode } from "./useEditionMode"
const host = (slot: (api: ReturnType<typeof useEditionMode>) => void) =>
mount(
defineComponent({
template: "<div/>",
setup() {
const api = useEditionMode()
slot(api)
return api
}
})
)
describe("useEditionMode", () => {
afterEach(() => {
escape.value = false
})
it("starts in read mode", () => {
let mode: unknown
host((api) => {
mode = api.mode.value
})
expect(mode).toBe("read")
})
it("toggleMode flips read ↔ edit", () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
api = a
})
api!.toggleMode()
expect(api!.mode.value).toBe("edit")
api!.toggleMode()
expect(api!.mode.value).toBe("read")
})
it("escape key exits edit mode", async () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
a.toggleMode()
api = a
})
expect(api!.mode.value).toBe("edit")
escape.value = true
await new Promise((r) => setTimeout(r, 0))
expect(api!.mode.value).toBe("read")
})
it("escape key is a no-op when already in read mode", async () => {
let api: ReturnType<typeof useEditionMode> | undefined
host((a) => {
api = a
})
escape.value = true
await new Promise((r) => setTimeout(r, 0))
expect(api!.mode.value).toBe("read")
})
})

View File

@@ -0,0 +1,67 @@
import { mount } from "@vue/test-utils"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { defineComponent } from "vue"
const push = vi.fn()
vi.mock("vue-router", () => ({
useRouter: () => ({ push })
}))
import { useForm } from "./useForm.hook"
const host = () => {
let api!: ReturnType<typeof useForm>
mount(
defineComponent({
template: "<div/>",
setup() {
api = useForm()
return api
}
})
)
return api
}
describe("useForm", () => {
beforeEach(() => {
push.mockReset()
})
it("starts with empty user and repo inputs", () => {
const api = host()
expect(api.userInput.value).toBe("")
expect(api.repoInput.value).toBe("")
})
it("submit is a no-op when userInput is empty", () => {
const api = host()
api.repoInput.value = "notes"
api.submit()
expect(push).not.toHaveBeenCalled()
})
it("submit is a no-op when repoInput is empty", () => {
const api = host()
api.userInput.value = "alice"
api.submit()
expect(push).not.toHaveBeenCalled()
})
it("submit pushes the FluxNoteView route with user/repo params", () => {
const api = host()
api.userInput.value = "alice"
api.repoInput.value = "notes"
api.submit()
expect(push).toHaveBeenCalledWith({
name: "FluxNoteView",
params: { user: "alice", repo: "notes" }
})
})
})

View 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"
)
})
})

View 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")
})
})

View 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")
})
})