161 lines
5.1 KiB
Go
161 lines
5.1 KiB
Go
package httpapi
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"io"
|
||
|
|
"log/slog"
|
||
|
|
"net/http"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/go-chi/chi/v5"
|
||
|
|
|
||
|
|
"github.com/kristianwind/verdande/internal/config"
|
||
|
|
"github.com/kristianwind/verdande/internal/store"
|
||
|
|
)
|
||
|
|
|
||
|
|
// The notes edition is a different program, and this is what makes that true.
|
||
|
|
//
|
||
|
|
// A gate in a route tree is the easiest thing in the world to believe and the
|
||
|
|
// hardest to see: the code reads `if full {`, somebody confirms the mechanism
|
||
|
|
// exists, and nothing ever asks whether it covers the routes it was written for.
|
||
|
|
// So this does not check that a gate is *present*. It walks both routers and
|
||
|
|
// compares the two sets of routes they actually serve.
|
||
|
|
//
|
||
|
|
// The difference is written down below. A route added later that differs between
|
||
|
|
// the editions — in either direction — fails here until somebody says which half
|
||
|
|
// it belongs to. That is the point: the list is the decision, and the walk is
|
||
|
|
// what keeps the decision honest.
|
||
|
|
func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) {
|
||
|
|
full := routesOf(t, config.EditionFull)
|
||
|
|
notes := routesOf(t, config.EditionNotes)
|
||
|
|
|
||
|
|
// Every route the full edition has and the notes edition does not. Written as
|
||
|
|
// the whole list rather than a prefix rule: a prefix is a description of what
|
||
|
|
// somebody meant, and this has to be a description of what is there.
|
||
|
|
withheld := map[string]bool{}
|
||
|
|
for _, r := range []string{
|
||
|
|
"DELETE /api/v1/filters/{filterID}",
|
||
|
|
"DELETE /api/v1/labels/{labelID}",
|
||
|
|
"DELETE /api/v1/reminders/{reminderID}",
|
||
|
|
"DELETE /api/v1/tasks/{taskID}",
|
||
|
|
"GET /api/v1/delegated",
|
||
|
|
"GET /api/v1/filters",
|
||
|
|
"GET /api/v1/filters/preview",
|
||
|
|
"GET /api/v1/filters/{filterID}/tasks",
|
||
|
|
"GET /api/v1/labels",
|
||
|
|
"GET /api/v1/tasks",
|
||
|
|
"GET /api/v1/tasks/quick-add/preview",
|
||
|
|
"GET /api/v1/tasks/{taskID}",
|
||
|
|
"GET /api/v1/tasks/{taskID}/comments",
|
||
|
|
"GET /api/v1/tasks/{taskID}/reminders",
|
||
|
|
"GET /api/v1/upcoming",
|
||
|
|
"PATCH /api/v1/filters/{filterID}",
|
||
|
|
"PATCH /api/v1/labels/{labelID}",
|
||
|
|
"PATCH /api/v1/tasks/{taskID}",
|
||
|
|
"POST /api/v1/filters",
|
||
|
|
"POST /api/v1/labels",
|
||
|
|
"POST /api/v1/tasks",
|
||
|
|
"POST /api/v1/tasks/quick-add",
|
||
|
|
"POST /api/v1/tasks/{taskID}/attachments",
|
||
|
|
"POST /api/v1/tasks/{taskID}/comments",
|
||
|
|
"POST /api/v1/tasks/{taskID}/complete",
|
||
|
|
"POST /api/v1/tasks/{taskID}/move",
|
||
|
|
"POST /api/v1/tasks/{taskID}/reminders",
|
||
|
|
"POST /api/v1/tasks/{taskID}/reopen",
|
||
|
|
"POST /api/v1/tasks/{taskID}/snooze",
|
||
|
|
} {
|
||
|
|
withheld[r] = true
|
||
|
|
}
|
||
|
|
|
||
|
|
var unexpected, stillThere []string
|
||
|
|
for r := range full {
|
||
|
|
if notes[r] {
|
||
|
|
if withheld[r] {
|
||
|
|
stillThere = append(stillThere, r)
|
||
|
|
}
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
if !withheld[r] {
|
||
|
|
unexpected = append(unexpected, r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// And the other direction: the notes edition must not grow a route the full
|
||
|
|
// one lacks. A gate that adds is not a gate either.
|
||
|
|
var onlyInNotes []string
|
||
|
|
for r := range notes {
|
||
|
|
if !full[r] {
|
||
|
|
onlyInNotes = append(onlyInNotes, r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
sort.Strings(unexpected)
|
||
|
|
sort.Strings(stillThere)
|
||
|
|
sort.Strings(onlyInNotes)
|
||
|
|
for _, r := range unexpected {
|
||
|
|
t.Errorf("withheld from the notes edition but not written down here: %s", r)
|
||
|
|
}
|
||
|
|
for _, r := range stillThere {
|
||
|
|
t.Errorf("listed as withheld and still served by the notes edition: %s", r)
|
||
|
|
}
|
||
|
|
for _, r := range onlyInNotes {
|
||
|
|
t.Errorf("served only by the notes edition: %s", r)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Printed on the green as well as the red. "The editions differ correctly" over
|
||
|
|
// zero withheld routes and over twenty-nine are the same pass, and only one of
|
||
|
|
// them means anything — so the number is on screen either way.
|
||
|
|
t.Logf("full: %d routes · notes: %d · withheld: %d", len(full), len(notes), len(full)-len(notes))
|
||
|
|
|
||
|
|
// The half that stops this being a deletion rather than a gate: what the notes
|
||
|
|
// edition is FOR has to still be there.
|
||
|
|
for _, must := range []string{
|
||
|
|
"GET /api/v1/notes",
|
||
|
|
"POST /api/v1/notes",
|
||
|
|
"GET /api/v1/export/notes.zip",
|
||
|
|
"GET /api/v1/projects", // a notebook, in this edition
|
||
|
|
"GET /api/v1/search",
|
||
|
|
"GET /api/v1/auth/me",
|
||
|
|
} {
|
||
|
|
if !notes[must] {
|
||
|
|
t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// routesOf builds a server at one edition and returns every route it serves.
|
||
|
|
func routesOf(t *testing.T, edition string) map[string]bool {
|
||
|
|
t.Helper()
|
||
|
|
|
||
|
|
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("open db: %v", err)
|
||
|
|
}
|
||
|
|
t.Cleanup(func() { db.Close() })
|
||
|
|
|
||
|
|
srv := New(&config.Config{
|
||
|
|
BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), Edition: edition,
|
||
|
|
SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour,
|
||
|
|
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
||
|
|
|
||
|
|
out := map[string]bool{}
|
||
|
|
err = chi.Walk(srv.router,
|
||
|
|
func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
|
||
|
|
if route != "/" {
|
||
|
|
route = strings.TrimSuffix(route, "/")
|
||
|
|
}
|
||
|
|
out[method+" "+route] = true
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("walk routes: %v", err)
|
||
|
|
}
|
||
|
|
if len(out) < 50 {
|
||
|
|
// A walk that found almost nothing would make every comparison below pass.
|
||
|
|
t.Fatalf("only %d routes at edition %q — the walk found nothing to compare", len(out), edition)
|
||
|
|
}
|
||
|
|
return out
|
||
|
|
}
|