Merge pull request #95 from kristianwind/fladen-foelger-udgaven
CI / OpenAPI is valid (push) Successful in 12s
CI / End-to-end (push) Successful in 15m10s
CI / Go (push) Failing after 16m56s
CI / Docker image builds (push) Successful in 2m40s

feat(udgaver): fladen følger udgaven, og søgningen holder opgaverne inde
This commit is contained in:
Kristian Wind
2026-10-07 00:23:16 +02:00
committed by GitHub
12 changed files with 410 additions and 50 deletions
+5
View File
@@ -44,6 +44,11 @@ node_modules.nosync/
# End-to-end runs: the throwaway instance the suite starts, the saved session it
# signs in with, and whatever a failure left behind.
/web/.playwright-data/
# Noter-udgavens egen instans i prøvekørslen. Den har sin egen database OG sin
# egen secret.key, og den nåede at blive committet én gang: et mønster, der
# nævner den ene mappe ved navn, dækker ikke den anden, selv om de er to kopier
# af det samme.
/web/.playwright-data-notes/
/web/e2e/.auth/
/web/test-results/
/web/playwright-report/
+11 -1
View File
@@ -53,7 +53,7 @@ paths:
/api/v1/auth/setup:
get:
tags: [auth]
summary: Whether this instance still needs its first account.
summary: What this instance is, and whether it still needs its first account.
security: []
responses:
"200":
@@ -64,6 +64,16 @@ paths:
type: object
properties:
needs_setup: { type: boolean }
edition:
type: string
enum: [full, notes]
description: >
`full` is tasks, projects and notes together. `notes` is
the notes half: the task routes are not mounted at all, so
an instance that is not a task manager cannot be asked to
be one through its API. The interface reads this before
anybody is signed in, because it decides what there is to
show rather than what this person may see.
post:
tags: [auth]
summary: Create the first account, which becomes the administrator.
+8 -1
View File
@@ -492,7 +492,14 @@ func (s *Server) handleSetupState(w http.ResponseWriter, r *http.Request) {
s.internal(w, r, "count users", err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"needs_setup": n == 0})
// Udgaven hører til her og ikke på /auth/me: den er en egenskab ved
// instansen, ikke ved den, der er logget ind, og fladen skal kende den, før
// nogen er. Det her er i forvejen kaldet, der spørger "hvad er det her for en
// instans", så den følger med uden en ekstra tur.
writeJSON(w, http.StatusOK, struct {
NeedsSetup bool `json:"needs_setup"`
Edition string `json:"edition"`
}{n == 0, s.edition()})
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, user *store.User) {
+16
View File
@@ -0,0 +1,16 @@
package httpapi
import "github.com/kristianwind/verdande/internal/config"
// edition is what this instance tells the world it is.
//
// Never the empty string, even when the config was built by hand in a test and
// never went through config.Load: a flade that reads "" cannot tell "this is the
// full program" from "this build forgot to say", and the first of those is the
// answer that has always been true.
func (s *Server) edition() string {
if s.cfg.NotesOnly() {
return config.EditionNotes
}
return config.EditionFull
}
+70
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
@@ -158,3 +159,72 @@ func routesOf(t *testing.T, edition string) map[string]bool {
}
return out
}
// The interface cannot gate itself on something it is not told, and it has to be
// told before anybody is signed in — an instance's shape is not a permission.
func TestSetupStateSaysWhichEdition(t *testing.T) {
for _, tc := range []struct{ edition, want string }{
{config.EditionFull, "full"},
{config.EditionNotes, "notes"},
{"", "full"}, // a config built by hand is the program as it has always been
} {
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
_, body := ts.do(t, "GET", "/api/v1/auth/setup", nil)
if got := body["edition"]; got != tc.want {
t.Errorf("edition %q reported as %v, want %q", tc.edition, got, tc.want)
}
// And the field it has always had is still there: adding one must not
// quietly replace the answer the sign-in screen depends on.
if _, ok := body["needs_setup"]; !ok {
t.Errorf("edition %q: needs_setup is gone from the answer", tc.edition)
}
}
}
// Search is mounted in both editions — notes and notebooks have to be findable —
// so it is the one door that could still open onto the half the router just shut.
//
// Not a theoretical case. An instance converted from a full one still has the
// tasks in its database, and ⌘K would be the only place they turned up.
func TestNotesEditionSearchFindsNoTasks(t *testing.T) {
for _, tc := range []struct {
edition string
want int
}{
{config.EditionFull, 1},
{config.EditionNotes, 0},
} {
ts := newTestServerWith(t, func(c *config.Config) { c.Edition = tc.edition })
ts.bootstrap(t)
_, me := ts.do(t, "GET", "/api/v1/auth/me", nil)
userID, _ := me["id"].(string)
if userID == "" {
t.Fatalf("no user: %v", me)
}
// Created through the store rather than the API: in the notes edition there
// is no route to create one, and the row this is about is the row that is
// already there.
inbox, err := ts.db.InboxID(context.Background(), userID)
if err != nil {
t.Fatal(err)
}
task := &store.Task{ProjectID: inbox, Content: "Kvartalsregnskabet", CreatedBy: userID}
if err := ts.db.CreateTask(context.Background(), task, nil); err != nil {
t.Fatal(err)
}
_, body := ts.do(t, "GET", "/api/v1/search?q=Kvartalsregnskabet", nil)
found, _ := body["tasks"].([]any)
if len(found) != tc.want {
t.Errorf("edition %q: search returned %d tasks, want %d", tc.edition, len(found), tc.want)
}
// The full edition's number is the control: a search that finds nothing at
// all would satisfy the notes case for the wrong reason.
if tc.edition == config.EditionFull && len(found) == 0 {
t.Errorf("the full edition found no tasks either — this proves nothing about the notes one")
}
}
}
+16 -7
View File
@@ -206,13 +206,22 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
return
}
tasks, err := s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
Search: query,
// Completed tasks are included: "what was that thing I did last week" is
// a search, and excluding them makes the box feel broken.
IncludeCompleted: true,
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
// Søgningen er monteret i begge udgaver, fordi man også skal kunne finde sine
// noter og notesbøger. Men opgaverne må ikke komme med i noter-udgaven, og det
// er ikke en teoretisk sag: en instans lavet om fra en fuld har opgaverne
// liggende i databasen endnu, og så ville ⌘K være den ene dør, der stod åben
// ind til den halvdel, resten af programmet lige har lukket.
var tasks []store.Task
var err error
if !s.cfg.NotesOnly() {
tasks, err = s.db.ListTasks(r.Context(), user.ID, store.TaskFilter{
Search: query,
// Completed tasks are included: "what was that thing I did last week" is
// a search, and excluding them makes the box feel broken.
IncludeCompleted: true,
Limit: parseLimit(r.URL.Query().Get("limit"), 40, 100),
})
}
if err != nil {
s.internal(w, r, "search tasks", err)
return
+120
View File
@@ -0,0 +1,120 @@
import { test, expect } from '@playwright/test';
/**
* Noter-udgaven, kørt som sit eget program.
*
* Egen server på sin egen port med `VERDANDE_EDITION=notes`, egen database og
* ingen lånt session: den her instans har aldrig set den konto, den fulde udgaves
* opsætning lavede, så den laver sin egen.
*
* Og det er hele grunden til at prøven findes som en separat server frem for som
* en indstilling, en prøve skruer på. En skakt, der kun er prøvet ved at kalde en
* funktion, er prøvet på det, nogen mente — det her måler programmet, som en
* bruger møder det.
*/
const KONTO = {
email: 'noter@example.dk',
name: 'Noteskriver',
password: 'et langt kodeord til test'
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Første konto, hvis den ikke er lavet endnu. Prøverne i filen deler instans,
// så den anden gang er det en almindelig login.
const opret = page.getByRole('button', { name: 'Opret konto' });
const login = page.getByRole('button', { name: 'Log ind' });
// Ventet på, at EN af de to er der, før der spørges hvilken. Spurgt før
// tegningen svarer begge nej, og så faldt prøven i den forkerte gren og ventede
// tredive sekunder på en knap, der aldrig kom.
await expect(opret.or(login)).toBeVisible();
if (await opret.isVisible()) {
await page.getByLabel(/Navn/).fill(KONTO.name);
await page.getByLabel(/E-mail/).fill(KONTO.email);
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
await opret.click();
// Beaconens besked står øverst første gang; den er ikke det her prøvens
// ærinde, men den dækker for det, der er.
const besked = page.getByRole('region', { name: /melder, at den findes/ });
if (await besked.isVisible().catch(() => false)) {
await besked.getByRole('button', { name: 'Behold den' }).click();
}
} else {
await page.getByLabel(/E-mail/).fill(KONTO.email);
await page.getByLabel(/Adgangskode/).fill(KONTO.password);
await page.getByRole('button', { name: 'Log ind' }).click();
}
await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible();
});
test('instansen siger selv, at den er noter-udgaven', async ({ page }) => {
// Fladen kan ikke lukke af for noget, den ikke får at vide, og den skal have
// det at vide, før nogen er logget ind.
const svar = await page.request.get('/api/v1/auth/setup');
expect((await svar.json()).edition).toBe('notes');
});
test('der er ingen vej til opgaverne i menuen', async ({ page }) => {
const menu = page.getByRole('navigation', { name: 'Hovedmenu' });
await expect(menu).toBeVisible();
// Udeladt, ikke gråtonet: ruterne bag dem er ikke monteret på serveren.
for (const navn of ['I dag', 'Kommende', 'Kalender', 'Uddelegeret']) {
await expect(menu.getByRole('link', { name: navn })).toHaveCount(0);
}
// Og det, udgaven ER til, står der. En menu uden opgaver OG uden noter ville
// også bestå prøven ovenfor, og den ville være en sletning frem for en skakt.
await expect(menu.getByRole('link', { name: 'Noter', exact: true })).toBeVisible();
});
test('forsiden er noterne, ikke en tom opgaveliste', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/noter$/);
});
test('en gemt adresse til en opgaveside fører til noterne i stedet for et blindt spor', async ({
page
}) => {
// Nogen, der har gemt et link fra en fuld instans, skal lande et sted, der
// giver mening. Der er ikke sket noget forkert — adressen hører bare til et
// andet program end det, der kører her.
for (const sti of ['/upcoming', '/kalender', '/uddelegeret', '/faerdige']) {
await page.goto(sti);
await expect(page).toHaveURL(/\/noter$/);
}
});
test('⌘K finder noter og ikke opgaver', async ({ page }) => {
await page.goto('/noter');
await page.getByRole('button', { name: 'Ny note' }).click();
const felt = page.getByRole('textbox', { name: 'Notens tekst' });
await expect(felt).toHaveText('');
await felt.click();
await page.keyboard.type('Kvartalsregnskabet');
await felt.blur();
await expect(page.locator('.notes button.row strong').filter({ hasText: /^Kvartalsregnskabet$/ })).toHaveCount(1);
await page.getByRole('button', { name: /Søg/ }).click();
const palette = page.getByRole('dialog');
await palette.getByRole('textbox').fill('Kvartalsregnskabet');
// Noten findes — ellers beviser det næste ingenting.
await expect(palette.locator('li button', { hasText: 'Kvartalsregnskabet' })).toBeVisible();
// Og søgningen på serveren svarer uden opgaver overhovedet.
const svar = await page.request.get('/api/v1/search?q=Kvartalsregnskabet');
expect((await svar.json()).tasks ?? []).toEqual([]);
});
test('opgaveruterne findes slet ikke på serveren', async ({ page }) => {
// Det her er forskellen på skjult og fraværende, målt udefra.
for (const sti of ['/api/v1/tasks', '/api/v1/labels', '/api/v1/upcoming', '/api/v1/filters']) {
const svar = await page.request.get(sti);
expect(svar.status(), `${sti} svarede ${svar.status()}`).toBe(404);
}
// Kontrollen: en rute, der ER monteret, svarer ikke 404 — ellers ville
// ovenstående bestå på en server, der var helt væk.
expect((await page.request.get('/api/v1/notes')).status()).toBe(200);
});
+83 -36
View File
@@ -20,6 +20,7 @@ import { dirname, join } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const repo = join(here, '..');
const dataDir = join(here, '.playwright-data');
const notesDataDir = join(here, '.playwright-data-notes');
// A clean instance every run: the first test creates the first account, which only
// works on an empty database.
@@ -31,9 +32,14 @@ const dataDir = join(here, '.playwright-data');
// connection fail, with SQLITE_CANTOPEN. It looks exactly like a flaky auth bug.
if (process.env.TEST_WORKER_INDEX === undefined) {
rmSync(dataDir, { recursive: true, force: true });
rmSync(notesDataDir, { recursive: true, force: true });
}
const PORT = 8097;
// Noter-udgaven kører som sin egen server ved siden af den fulde. Den kan ikke
// dele port, og den må ikke dele database: de to er to instanser, og en prøve der
// lod dem skrive i den samme ville måle en tilstand, ingen af dem ejer.
const NOTES_PORT = 8098;
// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party
// id — `SecurityError: This is an invalid domain` — and localhost is the exception
// every browser makes. The passkey test cannot run against a loopback address.
@@ -72,6 +78,10 @@ export default defineConfig({
{ name: 'setup', testMatch: /.*\.setup\.js/ },
{
name: 'chromium',
// Alt undtagen noter-udgaven, som har sin egen server på sin egen port.
// Uden den her linje kørte den prøve også her — mod den FULDE udgave, hvor
// den naturligvis fejler, og hvor fejlen læser som om skakten ikke virker.
testIgnore: /edition\.spec\.js/,
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json' },
dependencies: ['setup']
},
@@ -95,6 +105,21 @@ export default defineConfig({
*
* WebKit here is the same engine Safari is, which is the whole point.
*/
/**
* Noter-udgaven, som sit eget program.
*
* Egen server, egen port, egen database, og ingen `storageState`: den her
* instans har aldrig set den konto, den fulde udgaves opsætning lavede, og
* at låne dens cookie ville prøve to instanser som var de én.
*
* Den deler ikke `setup` af samme grund — den laver sin egen første konto.
*/
{
name: 'notes-edition',
testMatch: /edition\.spec\.js/,
use: { ...devices['Desktop Chrome'], baseURL: `http://127.0.0.1:${NOTES_PORT}` }
},
{
name: 'webkit-forms',
grep: /@forms/,
@@ -103,42 +128,64 @@ export default defineConfig({
}
],
webServer: {
// The whole chain, every run.
//
// The frontend is embedded in the binary at compile time, from a copy under
// cmd/verdande/webbuild — so a suite that merely started the server would
// test whatever frontend was copied there last, which during development is
// nearly always the wrong one. Testing a stale build is worse than not
// testing: it reports green on code that is not running.
command: [
'npm run build',
'rm -rf ../cmd/verdande/webbuild',
'cp -r build ../cmd/verdande/webbuild',
'cd .. && go run -tags embedweb ./cmd/verdande'
].join(' && '),
cwd: here,
url: `${baseURL}/healthz`,
reuseExistingServer: false,
// The first run compiles the binary and builds nothing else; a cold Go
// build on a CI runner is comfortably under two minutes.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
webServer: [
{
// The whole chain, every run.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
// The frontend is embedded in the binary at compile time, from a copy under
// cmd/verdande/webbuild — so a suite that merely started the server would
// test whatever frontend was copied there last, which during development is
// nearly always the wrong one. Testing a stale build is worse than not
// testing: it reports green on code that is not running.
command: [
'npm run build',
'rm -rf ../cmd/verdande/webbuild',
'cp -r build ../cmd/verdande/webbuild',
'cd .. && go run -tags embedweb ./cmd/verdande'
].join(' && '),
cwd: here,
url: `${baseURL}/healthz`,
reuseExistingServer: false,
// The first run compiles the binary and builds nothing else; a cold Go
// build on a CI runner is comfortably under two minutes.
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${PORT}`,
// `localhost`, while the suite itself drives `127.0.0.1`. These are two
// different things: the suite's address is where the browser goes, and
// this one is what the server calls itself — which is now also the
// WebAuthn relying party id, and WebAuthn refuses an IP address.
//
// The passkey test navigates to localhost explicitly so its origin
// matches. Everything else stays on the loopback address, where it has
// always been.
VERDANDE_BASE_URL: `http://localhost:${PORT}`,
VERDANDE_DATA_DIR: dataDir,
// Off: an update check would reach out to GitHub from a test run.
VERDANDE_UPDATE_CHECK: 'false'
}
},
// Den samme binær en gang til, med den anden udgave slået til. Bygningen er
// allerede lavet af serveren ovenfor — `go run` genbruger byggecachen — så
// det her koster en opstart og ikke en oversættelse.
{
command: 'cd .. && go run -tags embedweb ./cmd/verdande',
cwd: here,
url: `http://127.0.0.1:${NOTES_PORT}/healthz`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
VERDANDE_ADDR: `:${NOTES_PORT}`,
VERDANDE_BASE_URL: `http://localhost:${NOTES_PORT}`,
VERDANDE_DATA_DIR: notesDataDir,
VERDANDE_EDITION: 'notes',
VERDANDE_UPDATE_CHECK: 'false'
}
}
}
]
});
+5 -1
View File
@@ -85,7 +85,11 @@
...settings.map((s) => ({ kind: 'setting', id: s.href, label: `${s.tab} · ${s.title}` })),
...projects.map((p) => ({ kind: 'project', id: p.id, label: p.name })),
...notes.map((n) => ({ kind: 'note', id: n.id, label: n.title || n.body.slice(0, 60) })),
...tasks.map((t) => ({
// Serveren sender ingen opgaver i noter-udgaven, så listen er alligevel tom.
// Spurgt her også, fordi en tom liste af den rigtige grund og en tom liste
// ved et tilfælde ser ens ud — og den dag nogen mounter søgningen anderledes,
// skal ⌘K ikke være stedet, det viser sig.
...(app.notesOnly ? [] : tasks).map((t) => ({
kind: 'task',
id: t.id,
label: t.content,
+7 -4
View File
@@ -155,9 +155,12 @@
// entry is a question the sidebar keeps asking and answering "no".
let navItems = $derived(
[
{ key: 'today', href: '/', label: 'nav.today' },
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
app.projects.some((p) => p.shared)
// Opgavernes egne indgange findes ikke i noter-udgaven. Ikke gråtonet og
// ikke skjult med CSS — udeladt, fordi ruterne bag dem ikke er monteret
// på serveren. Et link til noget, der ikke findes, er ikke en skønhedsfejl.
app.notesOnly ? null : { key: 'today', href: '/', label: 'nav.today' },
app.notesOnly ? null : { key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
!app.notesOnly && app.projects.some((p) => p.shared)
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
: null,
// No href: the inbox is a project and is drawn by projectRow, which knows
@@ -185,7 +188,7 @@
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
// den et andet sted her, ser en ny konto én rækkefølge og alle
// eksisterende en anden.
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
app.notesOnly ? null : { key: 'calendar', href: '/kalender', label: 'nav.calendar' }
].filter(Boolean)
);
+36
View File
@@ -43,6 +43,27 @@ class AppState {
*/
unreachable = $state(false);
/**
* Hvilken udgave af programmet den her instans er.
*
* `full` er opgaver, projekter og noter sammen. `notes` er notehalvdelen, hvor
* opgaveruterne slet ikke er monteret på serveren — så fladen må ikke tilbyde
* en vej til dem. Et link til en rute, der ikke findes, er ikke en skønhedsfejl:
* det er en knap, der fører et sted hen, hvor der ikke er noget.
*
* Instansens form, ikke personens rettigheder. Derfor hentes den ved siden af
* kontoen frem for efter den: den skal også være kendt, når ingen er logget ind.
*
* `full` som udgangspunkt, fordi det er det, programmet altid har været — en
* instans, der ikke når at svare, skal ikke pludselig se ud som et andet
* program.
*/
edition = $state('full');
get notesOnly() {
return this.edition === 'notes';
}
/** Transient messages: a failed save, a rolled-back change. */
toasts = $state([]);
connected = $state(false);
@@ -108,6 +129,17 @@ class AppState {
async load() {
this.loading = true;
// Formen hentes for sig, og en fejl på den tier. En 401 fra `me` må ikke
// tage udgaven med sig — login-skærmen er også en flade i en udgave — og
// en instans, der slet ikke svarer, melder `me` ordentligt nedenfor.
const shape = api
.setupState()
.then((state) => {
if (state?.edition) this.edition = state.edition;
})
.catch(() => {});
try {
this.user = await api.me();
this.unreachable = false;
@@ -131,6 +163,10 @@ class AppState {
// arriving. A 401 is the server answering, and that is a real sign-out.
this.unreachable = e?.code === 'offline';
} finally {
// Afventet her, så fladen aldrig tegnes, før den ved hvilket program den
// er. Tegnet først og rettet bagefter ville vise opgaver i et sekund på
// en instans, der ikke har nogen.
await shape;
this.loading = false;
}
}
+33
View File
@@ -57,6 +57,39 @@
app.load();
});
/**
* Opgavernes sider findes ikke i noter-udgaven.
*
* Ét sted frem for en vagt i hver rute: listen ER beslutningen, og en vagt pr.
* fil er syv steder at glemme den ottende. Skrevet som præfikser, fordi
* `/opgave/<id>` og `/filter/<id>` er sider under dem.
*
* Sendt til noterne og ikke til en fejlside: der er ikke sket noget forkert.
* Adressen hører bare til et andet program end det, der kører her — og nogen,
* der har gemt et link fra en fuld instans, skal lande et sted, der giver
* mening frem for i en blindgyde.
*
* `replaceState`, så Tilbage ikke fører hen til den side, man lige blev sendt
* væk fra, og derfra tilbage igen.
*/
const TASK_PAGES = [
'/',
'/upcoming',
'/uddelegeret',
'/kalender',
'/faerdige',
'/opgave',
'/filter',
'/etiket'
];
$effect(() => {
if (!app.notesOnly) return;
const here = $page.url.pathname;
const taskPage = TASK_PAGES.some((p) => here === p || (p !== '/' && here.startsWith(p + '/')));
if (taskPage) goto('/noter', { replaceState: true });
});
/**
* Links ud af programmet, åbnet i den browser, maskinen er sat op til.
*