feat: 스니펫 공용화 — 서버 PostgreSQL 테이블 + API, 프론트는 브릿지 대신 HTTP
결정(9/21): 공용 하나, 개인 로컬 없음.
- backend apps/snippets: Snippet(name PK·desc·body·category) + SnippetUsage(사람별 count/last_used).
GET/POST /api/v1/snippets, PUT/DELETE /snippets/{name}, POST /snippets/{name}/use — 옛 브릿지 5개와 1:1,
이름 정규화·중복 409·빈 내용 400 규칙 동일. import_snippets 커맨드로 옛 snippets.db 적재. 테스트 3개
- frontend snippets.api.ts 만 HTTP 로 교체(훅·페이지·타입 무변경). 테스트 axios-mock-adapter 로
- ponytail: Tauri 로컬 SQLite 커맨드는 미사용 상태로 남김. 지울 때 Rust+브릿지 테스트 함께
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
df5feaad3a
commit
2e7bae4c06
@@ -1,76 +1,59 @@
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
import { clearMocks, mockIPC } from "@tauri-apps/api/mocks"
|
||||
import MockAdapter from "axios-mock-adapter"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
import { snippetsApi } from "./snippets.api"
|
||||
|
||||
describe("snippetsApi", () => {
|
||||
afterEach(() => clearMocks())
|
||||
// memos.api.test 와 같은 axios-mock-adapter 방식. envelope 은 client 가 data 로 풀어줌.
|
||||
const mock = new MockAdapter(apiClient)
|
||||
const ok = (data: unknown) => [200, { success: true, statusCode: 200, data, errors: [] }] as const
|
||||
|
||||
it("list — snippets_list 응답을 Snippet[]로 돌려준다", async () => {
|
||||
describe("snippetsApi (server)", () => {
|
||||
afterEach(() => mock.reset())
|
||||
|
||||
it("list", async () => {
|
||||
const data = [{ name: "A", desc: "", body: "b", category: "코드", usageCount: 0, lastUsed: 0 }]
|
||||
mockIPC((command) => {
|
||||
expect(command).toBe("snippets_list")
|
||||
return data
|
||||
})
|
||||
|
||||
mock.onGet("/snippets").reply(() => [...ok(data)])
|
||||
await expect(snippetsApi.list()).resolves.toEqual(data)
|
||||
})
|
||||
|
||||
it("recordUse — snippets_record_use에 name을 넘긴다", async () => {
|
||||
const data = { name: "FOO", usageCount: 3, lastUsed: 123 }
|
||||
mockIPC((command, payload) => {
|
||||
expect(command).toBe("snippets_record_use")
|
||||
expect(payload).toEqual({ name: "FOO" })
|
||||
return data
|
||||
it("create/update/remove/recordUse 가 맞는 경로·본문으로 간다", async () => {
|
||||
const input = { name: "FOO BAR", desc: "d", body: "b", category: "코드" }
|
||||
mock
|
||||
.onPost("/snippets", input)
|
||||
.reply(() => [
|
||||
201,
|
||||
{ success: true, statusCode: 201, data: { ...input, usageCount: 0, lastUsed: 0 } },
|
||||
])
|
||||
mock
|
||||
.onPut("/snippets/FOO%20BAR", input)
|
||||
.reply(() => [...ok({ ...input, usageCount: 1, lastUsed: 5 })])
|
||||
mock.onDelete("/snippets/FOO%20BAR").reply(() => [...ok({ name: "FOO BAR" })])
|
||||
mock
|
||||
.onPost("/snippets/FOO%20BAR/use")
|
||||
.reply(() => [...ok({ name: "FOO BAR", usageCount: 2, lastUsed: 9 })])
|
||||
|
||||
await expect(snippetsApi.create(input)).resolves.toMatchObject({ name: "FOO BAR" })
|
||||
await expect(snippetsApi.update(input)).resolves.toMatchObject({ usageCount: 1 })
|
||||
await expect(snippetsApi.remove("FOO BAR")).resolves.toBeUndefined()
|
||||
await expect(snippetsApi.recordUse("FOO BAR")).resolves.toEqual({
|
||||
name: "FOO BAR",
|
||||
usageCount: 2,
|
||||
lastUsed: 9,
|
||||
})
|
||||
|
||||
await expect(snippetsApi.recordUse("FOO")).resolves.toEqual(data)
|
||||
})
|
||||
|
||||
it("command 오류를 Error로 reject한다", async () => {
|
||||
mockIPC(() => Promise.reject("디비 오류"))
|
||||
|
||||
await expect(snippetsApi.list()).rejects.toThrow("디비 오류")
|
||||
})
|
||||
|
||||
it("create — snippets_create에 snippet을 넘기고 저장 결과를 돌려준다", async () => {
|
||||
const input = { name: "FOO", desc: "d", body: "b", category: "코드" }
|
||||
const data = { ...input, usageCount: 0, lastUsed: 0 }
|
||||
mockIPC((command, payload) => {
|
||||
expect(command).toBe("snippets_create")
|
||||
expect(payload).toEqual({ snippet: input })
|
||||
return data
|
||||
})
|
||||
|
||||
await expect(snippetsApi.create(input)).resolves.toEqual(data)
|
||||
})
|
||||
|
||||
it("create — 중복 이름 오류를 그대로 reject한다", async () => {
|
||||
mockIPC(() => Promise.reject("이미 있는 이름임: FOO"))
|
||||
|
||||
it("서버 오류 message 가 Error 로 온다(훅이 그대로 토스트)", async () => {
|
||||
mock
|
||||
.onPost("/snippets")
|
||||
.reply(409, {
|
||||
success: false,
|
||||
statusCode: 409,
|
||||
code: "CONFLICT",
|
||||
message: "이미 있는 이름임: A",
|
||||
errors: [],
|
||||
})
|
||||
await expect(
|
||||
snippetsApi.create({ name: "FOO", desc: "", body: "b", category: "코드" })
|
||||
).rejects.toThrow("이미 있는 이름임: FOO")
|
||||
})
|
||||
|
||||
it("update — snippets_update에 snippet을 넘기고 갱신 결과를 돌려준다", async () => {
|
||||
const input = { name: "FOO", desc: "새 설명", body: "새 본문", category: "기타" }
|
||||
const data = { ...input, usageCount: 2, lastUsed: 100 }
|
||||
mockIPC((command, payload) => {
|
||||
expect(command).toBe("snippets_update")
|
||||
expect(payload).toEqual({ snippet: input })
|
||||
return data
|
||||
})
|
||||
|
||||
await expect(snippetsApi.update(input)).resolves.toEqual(data)
|
||||
})
|
||||
|
||||
it("remove — snippets_delete에 name을 넘기고 성공하면 undefined를 돌려준다", async () => {
|
||||
mockIPC((command, payload) => {
|
||||
expect(command).toBe("snippets_delete")
|
||||
expect(payload).toEqual({ name: "FOO" })
|
||||
return { name: "FOO" }
|
||||
})
|
||||
|
||||
await expect(snippetsApi.remove("FOO")).resolves.toBeUndefined()
|
||||
snippetsApi.create({ name: "A", desc: "", body: "b", category: "" })
|
||||
).rejects.toThrow("이미 있는 이름임: A")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// feature 유일한 데이터 진입점(contracts/snippet-data-interface.md). 뒤는 브릿지(SQLite) — @/lib/api/client 안 씀(정당 편차).
|
||||
import { request } from "@/lib/bridge/snippetBridge"
|
||||
// feature 유일한 데이터 진입점. 2026-09-21 부터 서버(공용 PostgreSQL) — 로컬 SQLite 브릿지 안 씀.
|
||||
// 서버 5개 엔드포인트는 옛 브릿지 5개와 1:1 (apps/snippets/views.py).
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/client"
|
||||
import type { Snippet, SnippetInput } from "../types"
|
||||
|
||||
export const snippetsApi = {
|
||||
list: () => request<Snippet[]>("snippets.list"),
|
||||
create: (input: SnippetInput) => request<Snippet>("snippets.create", { snippet: input }),
|
||||
update: (input: SnippetInput) => request<Snippet>("snippets.update", { snippet: input }),
|
||||
list: () => apiGet<Snippet[]>("/snippets"),
|
||||
create: (input: SnippetInput) => apiPost<Snippet>("/snippets", input),
|
||||
update: (input: SnippetInput) =>
|
||||
apiPut<Snippet>(`/snippets/${encodeURIComponent(input.name)}`, input),
|
||||
remove: (name: string) =>
|
||||
request<{ name: string }>("snippets.delete", { name }).then(() => undefined),
|
||||
apiDelete(`/snippets/${encodeURIComponent(name)}`).then(() => undefined),
|
||||
recordUse: (name: string) =>
|
||||
request<{ name: string; usageCount: number; lastUsed: number }>("snippets.recordUse", {
|
||||
name,
|
||||
}),
|
||||
apiPost<{ name: string; usageCount: number; lastUsed: number }>(
|
||||
`/snippets/${encodeURIComponent(name)}/use`,
|
||||
{}
|
||||
),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user