Første skridt mod noter som et selvstændigt produkt. Kun routeren: fladen, navnet og runen kommer bagefter. --- hvorfor én kodebase og ikke en fork --- Målt før noget blev skrevet. Noternes Go-kode er 2.386 af 49.407 linjer, og den nævner `Task` nul gange, `quickadd` nul, `sections` nul. Den anden vej er også nul. De to halvdele rører hinanden to steder: en `note_links`- række, hvis `kind` tilfældigvis er "task", og rettigheder, hvor en note i et projekt arver projektets rolle. Delingen findes allerede uden projekter. `note_shares` er en tildeling pr. note — note, person, rolle — med del, fjern, rolleopslag, publikum og `SyncLinkedShares`. Og adgangsreglen har allerede grenen for en note uden projekt: `WHERE project_id IS NULL AND created_by = ?`. Et selvstændigt noteprogram er de regler, der allerede er der, med projektgrenen fjernet. Det, der IKKE kan skilles ad, er fundamentet: konti, sessioner, 2FA, invitationer, beskeder, vedhæftninger, søgning, sprog, PWA, runen, beaconen, OpenAPI-dokumentet, e2e-stilladset. Begge programmer skal bruge det hele. To kopier af det er den fejl, der står flest gange i vores egen playbook — de syv plugins med et kopieret ci/-kit, den frysede delte fil. Projekter bliver derfor stående i begge udgaver. I noter-udgaven er et projekt en notesbog: samme kolonne, samme deling, andet ord. En anden grupperingsmodel ved siden af `note_shares` ville være én ting mere at holde i trit, for en forskel ingen kan se. --- skakten --- Opgaveruterne er ikke skjult i fladen. De er fraværende fra routeren, så en instans, der ikke er et opgaveprogram, heller ikke kan bedes om at være det gennem sit API. En ukendt værdi stopper programmet ved opstart. "note", "Notes", "none" — alle afvist. Det er den stille retning af fejlen: ellers ville en instans, der blev sat op uden opgaver, servere hver eneste opgaverute, og den eneste måde at opdage det på ville være at bemærke, at noget virker, som ikke burde. --- prøven --- Den tjekker ikke, at der STÅR en skakt. Den vandrer begge routere og sammenligner, hvad de faktisk serverer: 200 ruter mod 171, 29 tilbageholdt. De 29 er skrevet ned enkeltvis frem for som en præfiks- regel — et præfiks beskriver, hvad nogen mente, og det her skal beskrive, hvad der er. En rute tilføjet senere, der er forskellig mellem udgaverne, fejler i begge retninger, til nogen siger hvilken halvdel den hører til. Og den anden halvdel: noter-udgaven skal STADIG servere noter, eksport, søgning, projekter og /auth/me. En skakt, der også slukker for det, den er til for, er en sletning. Efterprøvet ved at plante fejlen: /labels-skakten åbnet, prøven fejlede og navngav alle seks ruter. Tallet står også på det grønne — "udgaverne er forskellige" over nul tilbageholdte ruter og over 29 er det samme pass. Go-suiten grøn. Fuld chromium-suite: 104 bestået, 0 fejlet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
|
|
}
|