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 { 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"
|
import { snippetsApi } from "./snippets.api"
|
||||||
|
|
||||||
describe("snippetsApi", () => {
|
// memos.api.test 와 같은 axios-mock-adapter 방식. envelope 은 client 가 data 로 풀어줌.
|
||||||
afterEach(() => clearMocks())
|
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 }]
|
const data = [{ name: "A", desc: "", body: "b", category: "코드", usageCount: 0, lastUsed: 0 }]
|
||||||
mockIPC((command) => {
|
mock.onGet("/snippets").reply(() => [...ok(data)])
|
||||||
expect(command).toBe("snippets_list")
|
|
||||||
return data
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(snippetsApi.list()).resolves.toEqual(data)
|
await expect(snippetsApi.list()).resolves.toEqual(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("recordUse — snippets_record_use에 name을 넘긴다", async () => {
|
it("create/update/remove/recordUse 가 맞는 경로·본문으로 간다", async () => {
|
||||||
const data = { name: "FOO", usageCount: 3, lastUsed: 123 }
|
const input = { name: "FOO BAR", desc: "d", body: "b", category: "코드" }
|
||||||
mockIPC((command, payload) => {
|
mock
|
||||||
expect(command).toBe("snippets_record_use")
|
.onPost("/snippets", input)
|
||||||
expect(payload).toEqual({ name: "FOO" })
|
.reply(() => [
|
||||||
return data
|
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("서버 오류 message 가 Error 로 온다(훅이 그대로 토스트)", async () => {
|
||||||
|
mock
|
||||||
|
.onPost("/snippets")
|
||||||
|
.reply(409, {
|
||||||
|
success: false,
|
||||||
|
statusCode: 409,
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "이미 있는 이름임: A",
|
||||||
|
errors: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
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"))
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
snippetsApi.create({ name: "FOO", desc: "", body: "b", category: "코드" })
|
snippetsApi.create({ name: "A", desc: "", body: "b", category: "" })
|
||||||
).rejects.toThrow("이미 있는 이름임: FOO")
|
).rejects.toThrow("이미 있는 이름임: A")
|
||||||
})
|
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
// feature 유일한 데이터 진입점(contracts/snippet-data-interface.md). 뒤는 브릿지(SQLite) — @/lib/api/client 안 씀(정당 편차).
|
// feature 유일한 데이터 진입점. 2026-09-21 부터 서버(공용 PostgreSQL) — 로컬 SQLite 브릿지 안 씀.
|
||||||
import { request } from "@/lib/bridge/snippetBridge"
|
// 서버 5개 엔드포인트는 옛 브릿지 5개와 1:1 (apps/snippets/views.py).
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/client"
|
||||||
import type { Snippet, SnippetInput } from "../types"
|
import type { Snippet, SnippetInput } from "../types"
|
||||||
|
|
||||||
export const snippetsApi = {
|
export const snippetsApi = {
|
||||||
list: () => request<Snippet[]>("snippets.list"),
|
list: () => apiGet<Snippet[]>("/snippets"),
|
||||||
create: (input: SnippetInput) => request<Snippet>("snippets.create", { snippet: input }),
|
create: (input: SnippetInput) => apiPost<Snippet>("/snippets", input),
|
||||||
update: (input: SnippetInput) => request<Snippet>("snippets.update", { snippet: input }),
|
update: (input: SnippetInput) =>
|
||||||
|
apiPut<Snippet>(`/snippets/${encodeURIComponent(input.name)}`, input),
|
||||||
remove: (name: string) =>
|
remove: (name: string) =>
|
||||||
request<{ name: string }>("snippets.delete", { name }).then(() => undefined),
|
apiDelete(`/snippets/${encodeURIComponent(name)}`).then(() => undefined),
|
||||||
recordUse: (name: string) =>
|
recordUse: (name: string) =>
|
||||||
request<{ name: string; usageCount: number; lastUsed: number }>("snippets.recordUse", {
|
apiPost<{ name: string; usageCount: number; lastUsed: number }>(
|
||||||
name,
|
`/snippets/${encodeURIComponent(name)}/use`,
|
||||||
}),
|
{}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetsConfig(AppConfig):
|
||||||
|
name = "apps.snippets"
|
||||||
|
verbose_name = "공용 스니펫"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""옛 로컬 snippets.db(SQLite) → 서버. 같은 이름은 건너뜀(--overwrite 로 덮어씀).
|
||||||
|
|
||||||
|
python manage.py import_snippets ../CODE_ASSISTANT_DB/snippets.db
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.snippets.models import Snippet
|
||||||
|
from apps.snippets.views import normalize_name
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "로컬 snippets.db 를 서버 스니펫으로 가져옴"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("path")
|
||||||
|
parser.add_argument("--overwrite", action="store_true")
|
||||||
|
|
||||||
|
def handle(self, path, overwrite, **_):
|
||||||
|
con = sqlite3.connect(path)
|
||||||
|
rows = con.execute("SELECT name, desc, body, category FROM snippets").fetchall()
|
||||||
|
added = updated = skipped = 0
|
||||||
|
for name, desc, body, category in rows:
|
||||||
|
name = normalize_name(name)
|
||||||
|
if not name or not (body or "").strip():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
d = {"desc": desc or "", "body": body, "category": (category or "").strip() or "코드"}
|
||||||
|
if overwrite:
|
||||||
|
_, created = Snippet.objects.update_or_create(name=name, defaults=d)
|
||||||
|
added += created
|
||||||
|
updated += not created
|
||||||
|
else:
|
||||||
|
_, created = Snippet.objects.get_or_create(name=name, defaults=d)
|
||||||
|
added += created
|
||||||
|
skipped += not created
|
||||||
|
self.stdout.write(f"추가 {added} / 갱신 {updated} / 건너뜀 {skipped} (총 {len(rows)})")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.1.15 on 2026-09-21 07:01
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Snippet',
|
||||||
|
fields=[
|
||||||
|
('name', models.CharField(max_length=200, primary_key=True, serialize=False)),
|
||||||
|
('desc', models.CharField(blank=True, default='', max_length=500)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('category', models.CharField(default='코드', max_length=100)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SnippetUsage',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('count', models.IntegerField(default=0)),
|
||||||
|
('last_used', models.DateTimeField(null=True)),
|
||||||
|
('snippet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='usages', to='snippets.snippet')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('snippet', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""공용 스니펫 — 로컬 SQLite(snippets/usage) 를 그대로 서버 PostgreSQL 로. 전원 읽기, 사용 기록은 사람별."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Snippet(models.Model):
|
||||||
|
name = models.CharField(max_length=200, primary_key=True) # 대문자·공백→_ 정규화된 이름(옛 DB 와 동일)
|
||||||
|
desc = models.CharField(max_length=500, blank=True, default="")
|
||||||
|
body = models.TextField()
|
||||||
|
category = models.CharField(max_length=100, default="코드")
|
||||||
|
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["name"]
|
||||||
|
|
||||||
|
def as_dto(self, usage: "SnippetUsage | None" = None) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"desc": self.desc,
|
||||||
|
"body": self.body,
|
||||||
|
"category": self.category,
|
||||||
|
"usageCount": usage.count if usage else 0,
|
||||||
|
"lastUsed": int(usage.last_used.timestamp()) if usage and usage.last_used else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetUsage(models.Model):
|
||||||
|
"""사람별 사용 횟수 — 랭킹은 내 기준으로."""
|
||||||
|
|
||||||
|
snippet = models.ForeignKey(Snippet, on_delete=models.CASCADE, related_name="usages")
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+")
|
||||||
|
count = models.IntegerField(default=0)
|
||||||
|
last_used = models.DateTimeField(null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("snippet", "user")]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import SnippetDetailView, SnippetListView, SnippetUseView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", SnippetListView.as_view()),
|
||||||
|
path("/<str:name>", SnippetDetailView.as_view()),
|
||||||
|
path("/<str:name>/use", SnippetUseView.as_view()),
|
||||||
|
]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""스니펫 API — 프론트 `features/snippets/api/snippets.api.ts` 5개 호출과 1:1. 옛 Tauri 브릿지 규칙 그대로.
|
||||||
|
|
||||||
|
GET /api/v1/snippets → Snippet[] (내 usage 합쳐서)
|
||||||
|
POST /api/v1/snippets {name,desc,body,category} → Snippet (이름 중복이면 409)
|
||||||
|
PUT /api/v1/snippets/{name} {desc,body,category} → Snippet
|
||||||
|
DELETE /api/v1/snippets/{name} → {name}
|
||||||
|
POST /api/v1/snippets/{name}/use → {name, usageCount, lastUsed}
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from common.envelope import CodedError
|
||||||
|
|
||||||
|
from .models import Snippet, SnippetUsage
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name(name: str) -> str:
|
||||||
|
return (name or "").strip().upper().replace(" ", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate(body: dict, *, need_name: bool) -> dict:
|
||||||
|
name = normalize_name(body.get("name", ""))
|
||||||
|
if need_name and not name:
|
||||||
|
raise CodedError(400, "VALIDATION_ERROR", "이름을 입력해야 함")
|
||||||
|
if not (body.get("body") or "").strip():
|
||||||
|
raise CodedError(400, "VALIDATION_ERROR", "내용을 입력해야 함")
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"desc": body.get("desc") or "",
|
||||||
|
"body": body["body"],
|
||||||
|
"category": (body.get("category") or "").strip() or "코드",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetListView(APIView):
|
||||||
|
def get(self, request):
|
||||||
|
usage = {u.snippet_id: u for u in SnippetUsage.objects.filter(user=request.user)}
|
||||||
|
return Response([s.as_dto(usage.get(s.name)) for s in Snippet.objects.all()])
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
d = _validate(request.data, need_name=True)
|
||||||
|
try:
|
||||||
|
with transaction.atomic(): # 중복 INSERT 실패가 바깥 트랜잭션을 깨지 않게(savepoint)
|
||||||
|
s = Snippet.objects.create(**d, created_by=request.user)
|
||||||
|
except IntegrityError:
|
||||||
|
raise CodedError(409, "CONFLICT", f"이미 있는 이름임: {d['name']}")
|
||||||
|
return Response(s.as_dto(), status=201)
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetDetailView(APIView):
|
||||||
|
def put(self, request, name: str):
|
||||||
|
s = get_object_or_404(Snippet, name=normalize_name(name))
|
||||||
|
d = _validate({**request.data, "name": s.name}, need_name=False)
|
||||||
|
s.desc, s.body, s.category = d["desc"], d["body"], d["category"]
|
||||||
|
s.save(update_fields=["desc", "body", "category", "updated_at"])
|
||||||
|
usage = SnippetUsage.objects.filter(snippet=s, user=request.user).first()
|
||||||
|
return Response(s.as_dto(usage))
|
||||||
|
|
||||||
|
def delete(self, request, name: str):
|
||||||
|
s = get_object_or_404(Snippet, name=normalize_name(name))
|
||||||
|
deleted = s.name # delete() 가 pk 를 None 으로 지움
|
||||||
|
s.delete()
|
||||||
|
return Response({"name": deleted})
|
||||||
|
|
||||||
|
|
||||||
|
class SnippetUseView(APIView):
|
||||||
|
def post(self, request, name: str):
|
||||||
|
s = get_object_or_404(Snippet, name=normalize_name(name))
|
||||||
|
u, _ = SnippetUsage.objects.get_or_create(snippet=s, user=request.user)
|
||||||
|
u.count += 1
|
||||||
|
u.last_used = timezone.now()
|
||||||
|
u.save(update_fields=["count", "last_used"])
|
||||||
|
return Response({"name": s.name, "usageCount": u.count, "lastUsed": int(u.last_used.timestamp())})
|
||||||
@@ -104,6 +104,7 @@ INSTALLED_APPS = [
|
|||||||
"apps.chat",
|
"apps.chat",
|
||||||
"apps.gateway",
|
"apps.gateway",
|
||||||
"apps.stats",
|
"apps.stats",
|
||||||
|
"apps.snippets",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ urlpatterns = [
|
|||||||
path("api/v1/users/me", MeView.as_view()),
|
path("api/v1/users/me", MeView.as_view()),
|
||||||
path("api/v1/chat/", include("apps.chat.urls")),
|
path("api/v1/chat/", include("apps.chat.urls")),
|
||||||
path("api/v1/admin/", include("apps.stats.urls")), # 관리자 대시보드 집계
|
path("api/v1/admin/", include("apps.stats.urls")), # 관리자 대시보드 집계
|
||||||
|
path("api/v1/snippets", include("apps.snippets.urls")), # 공용 스니펫(PostgreSQL). 슬래시 없이 — 프론트가 /snippets 로 침
|
||||||
path("", include("apps.gateway.urls")), # /api/ito/* — 사내 LLM(FabriX) 중계
|
path("", include("apps.gateway.urls")), # /api/ito/* — 사내 LLM(FabriX) 중계
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""/api/v1/snippets — 공용 스니펫 CRUD + 내 usage."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
from apps.snippets.models import Snippet
|
||||||
|
|
||||||
|
|
||||||
|
def test_crud_and_usage(auth_api, user):
|
||||||
|
r = auth_api.post("/api/v1/snippets", {"name": " alv filter ", "body": "x", "category": ""}, format="json")
|
||||||
|
assert r.status_code == 201 and r.json()["data"]["name"] == "ALV_FILTER" and r.json()["data"]["category"] == "코드"
|
||||||
|
assert auth_api.post("/api/v1/snippets", {"name": "ALV_FILTER", "body": "y"}, format="json").status_code == 409
|
||||||
|
assert auth_api.post("/api/v1/snippets", {"name": "", "body": "y"}, format="json").status_code == 400
|
||||||
|
assert auth_api.post("/api/v1/snippets", {"name": "B", "body": " "}, format="json").status_code == 400
|
||||||
|
|
||||||
|
r = auth_api.put("/api/v1/snippets/alv_filter", {"desc": "d", "body": "z", "category": "ALV"}, format="json")
|
||||||
|
assert r.status_code == 200 and r.json()["data"]["body"] == "z" and r.json()["data"]["category"] == "ALV"
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
r = auth_api.post("/api/v1/snippets/ALV_FILTER/use")
|
||||||
|
assert r.json()["data"]["usageCount"] == 2 and r.json()["data"]["lastUsed"] > 0
|
||||||
|
lst = auth_api.get("/api/v1/snippets").json()["data"]
|
||||||
|
assert lst[0]["name"] == "ALV_FILTER" and lst[0]["usageCount"] == 2
|
||||||
|
|
||||||
|
assert auth_api.delete("/api/v1/snippets/ALV_FILTER").json()["data"] == {"name": "ALV_FILTER"}
|
||||||
|
assert auth_api.get("/api/v1/snippets").json()["data"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_usage_is_per_user(auth_api, user, django_user_model, api):
|
||||||
|
Snippet.objects.create(name="S", body="b")
|
||||||
|
auth_api.post("/api/v1/snippets/S/use")
|
||||||
|
other = django_user_model.objects.create_user(username="o@x.com", email="o@x.com", password="x")
|
||||||
|
api.force_authenticate(other)
|
||||||
|
assert api.get("/api/v1/snippets").json()["data"][0]["usageCount"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_from_sqlite(db, tmp_path):
|
||||||
|
p = tmp_path / "old.db"
|
||||||
|
con = sqlite3.connect(p)
|
||||||
|
con.execute("CREATE TABLE snippets(name TEXT PRIMARY KEY, desc TEXT, body TEXT, category TEXT)")
|
||||||
|
con.executemany("INSERT INTO snippets VALUES (?,?,?,?)", [("a b", "d", "x", "코드"), ("EMPTY", "", " ", "코드"), ("C", "", "y", "")])
|
||||||
|
con.commit()
|
||||||
|
call_command("import_snippets", str(p))
|
||||||
|
names = {s.name: s for s in Snippet.objects.all()}
|
||||||
|
assert set(names) == {"A_B", "C"} and names["C"].category == "코드"
|
||||||
|
call_command("import_snippets", str(p)) # 재실행해도 중복 없음
|
||||||
|
assert Snippet.objects.count() == 2
|
||||||
@@ -25,4 +25,4 @@
|
|||||||
- [ ] 보낸 이미지가 재진입 시 사라짐 — ChatMessage 는 content(텍스트)만 저장, 이미지는 OpenCode 로만 넘김. 서버 파일로 저장하고 경로를 메시지에 붙여 조회 API 가 같이 돌려주게. 프론트 Message 렌더 포함, 반나절.
|
- [ ] 보낸 이미지가 재진입 시 사라짐 — ChatMessage 는 content(텍스트)만 저장, 이미지는 OpenCode 로만 넘김. 서버 파일로 저장하고 경로를 메시지에 붙여 조회 API 가 같이 돌려주게. 프론트 Message 렌더 포함, 반나절.
|
||||||
- [x] 9/21 고객사 -12: SAP MCP 별도 기동(도구 6개), PostgreSQL 전환(외부 PG, 스키마 codeassist, sslmode=disable 임시 → env 화 패치 예정)
|
- [x] 9/21 고객사 -12: SAP MCP 별도 기동(도구 6개), PostgreSQL 전환(외부 PG, 스키마 codeassist, sslmode=disable 임시 → env 화 패치 예정)
|
||||||
- [ ] -12 코드 최신화 — stats 앱·타임아웃 env·sslmode env·MCP 템플릿. 패치 zip 으로. 앱은 재빌드·재설치(로고 제거·삼성 팔레트·Esc 중단·관리자 화면)
|
- [ ] -12 코드 최신화 — stats 앱·타임아웃 env·sslmode env·MCP 템플릿. 패치 zip 으로. 앱은 재빌드·재설치(로고 제거·삼성 팔레트·Esc 중단·관리자 화면)
|
||||||
- [ ] **스니펫 공용화(결정 9/21: 공용 PostgreSQL 하나, 개인 로컬 없음)** — backend `snippets` 앱(모델·CRUD·143개 import), 프론트 훅을 API 로, Tauri 로컬 snippets.db 코드 제거. 시드는 `../CODE_ASSISTANT_DB/snippets.db`(143건). 10월 초, 1.5일
|
- [x] **스니펫 공용화** (9/21) — backend `apps/snippets` + 프론트 api 교체. 시드 143건 import. ponytail: Tauri 로컬 SQLite 커맨드(`snippets_*`)·`snippetBridge.ts` 는 이제 안 쓰지만 남김 — 지울 때 Rust·브릿지 테스트 같이. 고객사: 패치 zip + `migrate` + `import_snippets`
|
||||||
|
|||||||
@@ -19,3 +19,4 @@
|
|||||||
| 15:28 | 새 대화 전송 시 세션 생성(OpenCode 왕복) 동안 헤로에 머물던 것 — 즉시 내 말풍선+'생각하는 중' 시머로 바꿔 넘어간 느낌, 실패하면 헤로 복귀+토스트(전엔 pending 안 풀려 영원히 '응답 중'). mutate 콜백 패턴으로 |
|
| 15:28 | 새 대화 전송 시 세션 생성(OpenCode 왕복) 동안 헤로에 머물던 것 — 즉시 내 말풍선+'생각하는 중' 시머로 바꿔 넘어간 느낌, 실패하면 헤로 복귀+토스트(전엔 pending 안 풀려 영원히 '응답 중'). mutate 콜백 패턴으로 |
|
||||||
| 15:34 | '생각하는 중' 자리가 답변(Message)과 다른 모양(테두리 카드, AI 배지 없음)이던 것 — ThinkingBubble 로 통일. 세 군데(새 대화·스트리밍 첫 토큰 전·재진입 대기) |
|
| 15:34 | '생각하는 중' 자리가 답변(Message)과 다른 모양(테두리 카드, AI 배지 없음)이던 것 — ThinkingBubble 로 통일. 세 군데(새 대화·스트리밍 첫 토큰 전·재진입 대기) |
|
||||||
| 15:59 | 스니펫 저장 방식 결정 — 공용 PostgreSQL 하나(개인 로컬 없음). 시드 143건은 팀원이 준 CODE_ASSISTANT_DB/snippets.db |
|
| 15:59 | 스니펫 저장 방식 결정 — 공용 PostgreSQL 하나(개인 로컬 없음). 시드 143건은 팀원이 준 CODE_ASSISTANT_DB/snippets.db |
|
||||||
|
| 16:06 | 스니펫 공용화 — 백엔드 apps/snippets(Snippet·사람별 Usage, CRUD 5개, import 커맨드), 프론트 snippets.api 를 브릿지→HTTP 로 교체(훅·페이지 무변경). 시드 143건 로컬 DB 에 적재. Tauri 로컬 SQLite 커맨드는 안 쓰지만 남김(삭제는 나중) |
|
||||||
|
|||||||
Reference in New Issue
Block a user