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:
lee-hyeon-cheol
2026-09-21 16:06:14 +09:00
co-authored by Claude Fable 5.1
parent df5feaad3a
commit 2e7bae4c06
17 changed files with 322 additions and 72 deletions
@@ -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`,
{}
),
}