f5b46585c3
Implement | prefix for titles and // separator for descriptions across the full stack: parser, schema, API, CLI, and web frontend. - Parser: line-aware extraction for |title, |title // desc, // leading desc, body // inline desc. URL-safe (skips :// lines). Modifiers (#tag, @time, ^card) extracted from all segments. - Schema: ALTER TABLE migration adds title, description columns - DB: Entity/EntityUpdate structs, all CRUD queries updated - API: title/description on create/update/response, body validation relaxed (title-only entries valid) - CLI: shows title as scan label when present - Web: parseInput mirrors Go parser, list shows title, detail pane renders title + description with double-click inline editing - Tests: 10 new cases (grammar, entity, API) — 71 total, all pass
540 lines
12 KiB
Go
540 lines
12 KiB
Go
package db
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func ptr[T any](v T) *T {
|
|
return &v
|
|
}
|
|
|
|
func TestCreate_Note(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "hello world", Glyph: GlyphNote}
|
|
if err := s.Create(e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e.ID == "" {
|
|
t.Fatal("ID not set")
|
|
}
|
|
|
|
got, err := s.Get(e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Body != "hello world" {
|
|
t.Errorf("body: got %q", got.Body)
|
|
}
|
|
if got.Glyph != GlyphNote {
|
|
t.Errorf("glyph: got %q", got.Glyph)
|
|
}
|
|
}
|
|
|
|
func TestCreate_TodoWithTimeAnchor(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")}
|
|
if err := s.Create(e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.Get(e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.TimeAnchor == nil || *got.TimeAnchor != "14:00" {
|
|
t.Errorf("time_anchor: got %v", got.TimeAnchor)
|
|
}
|
|
}
|
|
|
|
func TestCreate_WithTags(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}
|
|
if err := s.Create(e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.Get(e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got.Tags) != 2 {
|
|
t.Fatalf("expected 2 tags, got %d", len(got.Tags))
|
|
}
|
|
}
|
|
|
|
func TestCreate_WithCardType(t *testing.T) {
|
|
s := testStore(t)
|
|
ct := CardSnippet
|
|
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
|
if err := s.Create(e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.Get(e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.CardType == nil || *got.CardType != CardSnippet {
|
|
t.Errorf("card_type: got %v", got.CardType)
|
|
}
|
|
}
|
|
|
|
func TestGet_NotFound(t *testing.T) {
|
|
s := testStore(t)
|
|
_, err := s.Get("01NONEXISTENT0000000000000")
|
|
if err != ErrNotFound {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestList_DefaultParams(t *testing.T) {
|
|
s := testStore(t)
|
|
for i := 0; i < 3; i++ {
|
|
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
|
|
}
|
|
|
|
entities, err := s.List(DefaultListParams())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entities) != 3 {
|
|
t.Fatalf("expected 3, got %d", len(entities))
|
|
}
|
|
// desc order: newest first
|
|
if entities[0].CreatedAt.Before(entities[2].CreatedAt) {
|
|
t.Error("expected newest first")
|
|
}
|
|
}
|
|
|
|
func TestList_FilterByTag(t *testing.T) {
|
|
s := testStore(t)
|
|
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
|
|
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
|
|
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
|
|
|
|
p := DefaultListParams()
|
|
tag := "ops"
|
|
p.Tag = &tag
|
|
|
|
entities, err := s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entities) != 2 {
|
|
t.Errorf("expected 2 entities with tag ops, got %d", len(entities))
|
|
}
|
|
}
|
|
|
|
func TestList_FilterByDate(t *testing.T) {
|
|
s := testStore(t)
|
|
s.Create(&Entity{Body: "today", Glyph: GlyphNote})
|
|
|
|
p := DefaultListParams()
|
|
date := time.Now().UTC().Format("2006-01-02")
|
|
p.Date = &date
|
|
|
|
entities, err := s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entities) != 1 {
|
|
t.Errorf("expected 1, got %d", len(entities))
|
|
}
|
|
|
|
otherDate := "2020-01-01"
|
|
p.Date = &otherDate
|
|
entities, err = s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entities) != 0 {
|
|
t.Errorf("expected 0 for past date, got %d", len(entities))
|
|
}
|
|
}
|
|
|
|
func TestList_CardsOnly(t *testing.T) {
|
|
s := testStore(t)
|
|
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote})
|
|
ct := CardSnippet
|
|
s.Create(&Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
|
|
|
|
p := DefaultListParams()
|
|
p.CardsOnly = true
|
|
entities, err := s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entities) != 1 {
|
|
t.Fatalf("expected 1 card, got %d", len(entities))
|
|
}
|
|
if entities[0].Body != "card" {
|
|
t.Errorf("expected card entity, got %q", entities[0].Body)
|
|
}
|
|
}
|
|
|
|
func TestList_IncludeDeleted(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
s.SoftDelete(e.ID)
|
|
|
|
p := DefaultListParams()
|
|
entities, err := s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entities) != 0 {
|
|
t.Error("deleted entity should be excluded by default")
|
|
}
|
|
|
|
p.IncludeDeleted = true
|
|
entities, err = s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entities) != 1 {
|
|
t.Error("deleted entity should appear with include_deleted")
|
|
}
|
|
}
|
|
|
|
func TestList_SortByUseCount(t *testing.T) {
|
|
s := testStore(t)
|
|
ct := CardSnippet
|
|
e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct}
|
|
e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct}
|
|
s.Create(e1)
|
|
s.Create(e2)
|
|
s.IncrementUse(e2.ID)
|
|
s.IncrementUse(e2.ID)
|
|
|
|
p := DefaultListParams()
|
|
p.Sort = "use_count"
|
|
entities, err := s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if entities[0].Body != "high" {
|
|
t.Errorf("expected highest use first, got %q", entities[0].Body)
|
|
}
|
|
}
|
|
|
|
func TestList_Pagination(t *testing.T) {
|
|
s := testStore(t)
|
|
for i := 0; i < 10; i++ {
|
|
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
|
|
}
|
|
|
|
p := DefaultListParams()
|
|
p.Limit = 3
|
|
p.Offset = 0
|
|
page1, err := s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(page1) != 3 {
|
|
t.Fatalf("expected 3, got %d", len(page1))
|
|
}
|
|
|
|
p.Offset = 3
|
|
page2, err := s.List(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(page2) != 3 {
|
|
t.Fatalf("expected 3, got %d", len(page2))
|
|
}
|
|
if page1[0].ID == page2[0].ID {
|
|
t.Error("pages should not overlap")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_Body(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "old", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
time.Sleep(1100 * time.Millisecond)
|
|
newBody := "new"
|
|
if err := s.Update(e.ID, &EntityUpdate{Body: &newBody}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if got.Body != "new" {
|
|
t.Errorf("body not updated: %q", got.Body)
|
|
}
|
|
if !got.ModifiedAt.After(e.ModifiedAt) {
|
|
t.Error("modified_at not updated")
|
|
}
|
|
}
|
|
|
|
func TestUpdate_Tags(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}}
|
|
s.Create(e)
|
|
|
|
newTags := []string{"new1", "new2"}
|
|
if err := s.Update(e.ID, &EntityUpdate{Tags: &newTags}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if len(got.Tags) != 2 {
|
|
t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags)
|
|
}
|
|
}
|
|
|
|
func TestPromote_Success(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
if err := s.Promote(e.ID, CardSnippet, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if got.CardType == nil || *got.CardType != CardSnippet {
|
|
t.Errorf("expected snippet, got %v", got.CardType)
|
|
}
|
|
}
|
|
|
|
func TestPromote_AlreadyPromoted(t *testing.T) {
|
|
s := testStore(t)
|
|
ct := CardSnippet
|
|
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
|
s.Create(e)
|
|
|
|
if err := s.Promote(e.ID, CardTemplate, nil); err != ErrAlreadyPromoted {
|
|
t.Errorf("expected ErrAlreadyPromoted, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDemote_Success(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
s.Promote(e.ID, CardSnippet, nil)
|
|
|
|
if err := s.Demote(e.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if got.CardType != nil {
|
|
t.Errorf("expected nil card_type, got %v", got.CardType)
|
|
}
|
|
if got.UseCount != 0 {
|
|
t.Errorf("expected use_count reset to 0, got %d", got.UseCount)
|
|
}
|
|
}
|
|
|
|
func TestDemote_AlreadyFluid(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
if err := s.Demote(e.ID); err != ErrAlreadyFluid {
|
|
t.Errorf("expected ErrAlreadyFluid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSoftDelete_First(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
result, err := s.SoftDelete(e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result != DeletedSoft {
|
|
t.Errorf("expected DeletedSoft, got %d", result)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if got.DeletedAt == nil {
|
|
t.Error("expected deleted_at to be set")
|
|
}
|
|
}
|
|
|
|
func TestSoftDelete_Second(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
s.SoftDelete(e.ID)
|
|
result, err := s.SoftDelete(e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result != DeletedHard {
|
|
t.Errorf("expected DeletedHard, got %d", result)
|
|
}
|
|
|
|
_, err = s.Get(e.ID)
|
|
if err != ErrNotFound {
|
|
t.Errorf("expected ErrNotFound after hard delete, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSoftDelete_NotFound(t *testing.T) {
|
|
s := testStore(t)
|
|
_, err := s.SoftDelete("01NONEXISTENT0000000000000")
|
|
if err != ErrNotFound {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestIncrementUse(t *testing.T) {
|
|
s := testStore(t)
|
|
ct := CardSnippet
|
|
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
|
s.Create(e)
|
|
|
|
if err := s.IncrementUse(e.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if got.UseCount != 1 {
|
|
t.Errorf("expected use_count=1, got %d", got.UseCount)
|
|
}
|
|
if got.LastUsedAt == nil {
|
|
t.Error("expected last_used_at to be set")
|
|
}
|
|
}
|
|
|
|
func TestResolve_FullID(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "test", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
got, err := s.Resolve(e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != e.ID {
|
|
t.Errorf("expected %s, got %s", e.ID, got)
|
|
}
|
|
}
|
|
|
|
func TestResolve_Prefix(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "test", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
got, err := s.Resolve(e.ID[:6])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got != e.ID {
|
|
t.Errorf("expected %s, got %s", e.ID, got)
|
|
}
|
|
}
|
|
|
|
func TestResolve_NotFound(t *testing.T) {
|
|
s := testStore(t)
|
|
_, err := s.Resolve("ZZZZZZZZZ")
|
|
if err != ErrNotFound {
|
|
t.Errorf("expected ErrNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreate_WithTitleAndDescription(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{
|
|
Body: "body text",
|
|
Title: ptr("nginx trick"),
|
|
Description: ptr("always forget this"),
|
|
Glyph: GlyphNote,
|
|
Tags: []string{"ops"},
|
|
}
|
|
if err := s.Create(e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := s.Get(e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Title == nil || *got.Title != "nginx trick" {
|
|
t.Errorf("title: got %v", got.Title)
|
|
}
|
|
if got.Description == nil || *got.Description != "always forget this" {
|
|
t.Errorf("description: got %v", got.Description)
|
|
}
|
|
if got.Body != "body text" {
|
|
t.Errorf("body: got %q", got.Body)
|
|
}
|
|
}
|
|
|
|
func TestCreate_WithoutTitle(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "just body", Glyph: GlyphNote}
|
|
if err := s.Create(e); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if got.Title != nil {
|
|
t.Errorf("expected nil title, got %v", got.Title)
|
|
}
|
|
if got.Description != nil {
|
|
t.Errorf("expected nil description, got %v", got.Description)
|
|
}
|
|
}
|
|
|
|
func TestUpdate_Title(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "body", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
newTitle := "new title"
|
|
if err := s.Update(e.ID, &EntityUpdate{Title: &newTitle}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if got.Title == nil || *got.Title != "new title" {
|
|
t.Errorf("title: got %v", got.Title)
|
|
}
|
|
}
|
|
|
|
func TestUpdate_Description(t *testing.T) {
|
|
s := testStore(t)
|
|
e := &Entity{Body: "body", Glyph: GlyphNote}
|
|
s.Create(e)
|
|
|
|
newDesc := "new desc"
|
|
if err := s.Update(e.ID, &EntityUpdate{Description: &newDesc}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(e.ID)
|
|
if got.Description == nil || *got.Description != "new desc" {
|
|
t.Errorf("description: got %v", got.Description)
|
|
}
|
|
}
|
|
|
|
func TestAbsorb_PreservesTargetTitle(t *testing.T) {
|
|
s := testStore(t)
|
|
target := &Entity{Body: "target body", Title: ptr("target title"), Glyph: GlyphNote}
|
|
source := &Entity{Body: "source body", Title: ptr("source title"), Glyph: GlyphNote}
|
|
s.Create(target)
|
|
s.Create(source)
|
|
|
|
if err := s.Absorb(target.ID, source.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, _ := s.Get(target.ID)
|
|
if got.Title == nil || *got.Title != "target title" {
|
|
t.Errorf("target title should be preserved, got %v", got.Title)
|
|
}
|
|
if got.Body != "target body\nsource body" {
|
|
t.Errorf("body: got %q", got.Body)
|
|
}
|
|
}
|