diff --git a/internal/store/listitems_test.go b/internal/store/listitems_test.go new file mode 100644 index 0000000..eb2a464 --- /dev/null +++ b/internal/store/listitems_test.go @@ -0,0 +1,149 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +var listNow = time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC) + +func TestAddListItemDedupesTheOpenList(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + first, err := s.AddListItem(ctx, ListItem{Item: "молоко", Source: "tap:voice", CreatedTs: listNow}) + if err != nil { + t.Fatalf("add: %v", err) + } + if !first.Created { + t.Fatal("the first молоко did not create a row") + } + again, err := s.AddListItem(ctx, ListItem{Item: " Молоко ", Source: "tap:voice", CreatedTs: listNow}) + if err != nil { + t.Fatalf("add again: %v", err) + } + if again.Created { + t.Error("молоко was added twice") + } + if again.ID != first.ID { + t.Errorf("second add points at %d; want the existing %d", again.ID, first.ID) + } + if _, err := s.AddListItem(ctx, ListItem{Item: " "}); !errors.Is(err, ErrListItemEmpty) { + t.Errorf("empty item: %v; want ErrListItemEmpty", err) + } +} + +// A crossed-off item does not block the next one: buying milk again next week +// is a new line, the way saying an errand again is a new task. +func TestCrossedOffItemComesBack(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + first, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow}) + if err != nil { + t.Fatalf("add: %v", err) + } + if err := s.SetListItemStatus(ctx, first.ID, ListItemDone, listNow); err != nil { + t.Fatalf("cross off: %v", err) + } + next, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow.Add(time.Hour)}) + if err != nil { + t.Fatalf("add after: %v", err) + } + if !next.Created || next.ID == first.ID { + t.Errorf("second молоко reused row %d; want a new one", next.ID) + } + open, err := s.ListItems(ctx, "", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(open) != 1 || open[0].ID != next.ID { + t.Errorf("open list %+v; want only the new row", open) + } +} + +// Lists are separate stores under one table: the same word on two lists is two +// items, and reading one never reads the other. +func TestListsDoNotSeeEachOther(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + if _, err := s.AddListItem(ctx, ListItem{List: "покупки", Item: "вода", CreatedTs: listNow}); err != nil { + t.Fatalf("add: %v", err) + } + if _, err := s.AddListItem(ctx, ListItem{List: "Аптека", Item: "вода", CreatedTs: listNow}); err != nil { + t.Fatalf("add: %v", err) + } + for _, c := range []struct{ list, want string }{ + {"покупки", "покупки"}, + {"аптека", "аптека"}, + {"", "покупки"}, + } { + got, err := s.ListItems(ctx, c.list, "") + if err != nil { + t.Fatalf("list %q: %v", c.list, err) + } + if len(got) != 1 { + t.Fatalf("list %q has %d items; want 1", c.list, len(got)) + } + if got[0].List != c.want { + t.Errorf("list %q returned tag %q; want %q", c.list, got[0].List, c.want) + } + } +} + +func TestClearListCrossesOffEverythingOpen(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + for _, item := range []string{"молоко", "хлеб", "яйца"} { + if _, err := s.AddListItem(ctx, ListItem{Item: item, CreatedTs: listNow}); err != nil { + t.Fatalf("add %s: %v", item, err) + } + } + if _, err := s.AddListItem(ctx, ListItem{List: "аптека", Item: "бинт", CreatedTs: listNow}); err != nil { + t.Fatalf("add: %v", err) + } + n, err := s.ClearList(ctx, "покупки", listNow) + if err != nil { + t.Fatalf("clear: %v", err) + } + if n != 3 { + t.Errorf("cleared %d; want 3", n) + } + left, err := s.ListItems(ctx, "покупки", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(left) != 0 { + t.Errorf("%d items still open; want none", len(left)) + } + done, err := s.ListItems(ctx, "покупки", ListItemDone) + if err != nil { + t.Fatalf("list done: %v", err) + } + if len(done) != 3 || done[0].ResolvedTs == nil { + t.Errorf("done list %+v; want 3 rows carrying a resolved time", done) + } + other, err := s.ListItems(ctx, "аптека", "") + if err != nil { + t.Fatalf("list: %v", err) + } + if len(other) != 1 { + t.Error("clearing покупки touched аптека") + } +} + +func TestSetListItemStatusRejectsWhatIsNotAStatus(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + if err := s.SetListItemStatus(ctx, 1, "куплено", listNow); !errors.Is(err, ErrListItemStatus) { + t.Errorf("bad status: %v; want ErrListItemStatus", err) + } + if err := s.SetListItemStatus(ctx, 999, ListItemDone, listNow); !errors.Is(err, ErrListItemNotFound) { + t.Errorf("missing row: %v; want ErrListItemNotFound", err) + } +}