feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
Some checks failed
Build and deploy Havenllo / Verify Go and frontend (push) Failing after 7s
Build and deploy Havenllo / Build and push image (push) Has been skipped
Build and deploy Havenllo / Apply and restart Havenllo (push) Has been skipped

- 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:
Hermes
2026-07-14 10:13:51 -03:00
commit 041c3fab4b
44 changed files with 5931 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
package api_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"testing/fstest"
"havenllo/internal/api"
"havenllo/internal/app"
"havenllo/internal/database"
)
func newHandler(t *testing.T) http.Handler {
t.Helper()
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
return api.NewHandler(app.New(db), fstest.MapFS{"index.html": {Data: []byte("<html></html>")}})
}
func request(t *testing.T, handler http.Handler, method, path, body string, jsonBody bool) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
if jsonBody {
r.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
return w
}
func TestBoardAndHealth(t *testing.T) {
t.Parallel()
handler := newHandler(t)
for _, path := range []string{"/api/health", "/api/board"} {
response := request(t, handler, http.MethodGet, path, "", false)
if response.Code != http.StatusOK {
t.Fatalf("%s status = %d, body %s", path, response.Code, response.Body)
}
}
var snapshot struct {
Lists []json.RawMessage `json:"lists"`
}
if err := json.Unmarshal(request(t, handler, http.MethodGet, "/api/board", "", false).Body.Bytes(), &snapshot); err != nil {
t.Fatal(err)
}
if len(snapshot.Lists) != 3 {
t.Fatalf("snapshot lists = %d, want 3", len(snapshot.Lists))
}
}
func TestJSONValidationAndLastListConflict(t *testing.T) {
t.Parallel()
handler := newHandler(t)
if got := request(t, handler, http.MethodPost, "/api/lists", `{"name":"later","unknown":true}`, true); got.Code != http.StatusBadRequest {
t.Fatalf("unknown field status = %d", got.Code)
}
if got := request(t, handler, http.MethodPost, "/api/lists", `{"name":"later"}`, false); got.Code != http.StatusUnsupportedMediaType {
t.Fatalf("content type status = %d", got.Code)
}
response := request(t, handler, http.MethodGet, "/api/lists", "", false)
var lists struct {
Lists []struct {
ID int64 `json:"id"`
} `json:"lists"`
}
if err := json.Unmarshal(response.Body.Bytes(), &lists); err != nil {
t.Fatal(err)
}
for _, list := range lists.Lists[:2] {
if got := request(t, handler, http.MethodDelete, "/api/lists/"+strconv.FormatInt(list.ID, 10), "", false); got.Code != http.StatusNoContent {
t.Fatalf("delete list status = %d", got.Code)
}
}
if got := request(t, handler, http.MethodDelete, "/api/lists/"+strconv.FormatInt(lists.Lists[2].ID, 10), "", false); got.Code != http.StatusConflict {
t.Fatalf("last list status = %d", got.Code)
}
}