feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board - Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api - Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme - PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven - Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
This commit is contained in:
97
internal/app/service_test.go
Normal file
97
internal/app/service_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"havenllo/internal/app"
|
||||
"havenllo/internal/database"
|
||||
"havenllo/internal/domain"
|
||||
)
|
||||
|
||||
func newService(t *testing.T) (*app.Service, func()) {
|
||||
t.Helper()
|
||||
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return app.New(db), func() { _ = db.Close() }
|
||||
}
|
||||
|
||||
func TestLastListProtection(t *testing.T) {
|
||||
t.Parallel()
|
||||
service, closeDB := newService(t)
|
||||
defer closeDB()
|
||||
ctx := context.Background()
|
||||
lists, err := service.Lists(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, list := range lists[:2] {
|
||||
if err := service.DeleteList(ctx, list.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := service.DeleteList(ctx, lists[2].ID); !errors.Is(err, domain.ErrLastList) {
|
||||
t.Fatalf("DeleteList error = %v, want ErrLastList", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardMoveNormalizesCrowdedPositions(t *testing.T) {
|
||||
t.Parallel()
|
||||
service, closeDB := newService(t)
|
||||
defer closeDB()
|
||||
ctx := context.Background()
|
||||
lists, err := service.Lists(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := lists[0]
|
||||
a, err := service.CreateCard(ctx, first.ID, "a", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := service.CreateCard(ctx, first.ID, "b", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position := a.Position + 0.00001
|
||||
if _, err := service.UpdateCard(ctx, b.ID, app.CardPatch{Position: &position}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards, err := service.Cards(ctx, first.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 2 || cards[1].Position-cards[0].Position < 0.0001 {
|
||||
t.Fatalf("cards were not normalized: %#v", cards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardMoveAndCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
service, closeDB := newService(t)
|
||||
defer closeDB()
|
||||
ctx := context.Background()
|
||||
lists, _ := service.Lists(ctx)
|
||||
card, err := service.CreateCard(ctx, lists[0].ID, "move me", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := lists[1].ID
|
||||
moved, err := service.UpdateCard(ctx, card.ID, app.CardPatch{ListID: &target})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if moved.ListID != target {
|
||||
t.Fatalf("card list = %d, want %d", moved.ListID, target)
|
||||
}
|
||||
if err := service.DeleteList(ctx, target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Card(ctx, card.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Fatalf("card after list deletion = %v, want not found", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user