mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
ed559deb4d
* wip(web): catch-up card on the workspace home, Mark caught up, and See everything into Activity (TASK-3615, PR B) The card renders GET /catch-up above Active Work, fenced on identity and keyed on (user, workspace). Mark caught up PUTs /me/seen and refetches. See everything opens Activity in Audit with ?since= set to the window's start, so the activity endpoint's since now also accepts an RFC3339 instant (shape item 5, moved from PR A to PR B by the lead's ruling). Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * test(e2e): catch-up card flow against the real binary (TASK-3615, PR B) Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * test(e2e): register the catch-up teammate with the admin session, not its token (TASK-3615, PR B) Claude-Session: https://claude.ai/code/session_011qEFJW3TjuR5tnGGG1cv2G * fix(web): catch-up r1 fixes: since validation matches Go, loads are ordered, a failed refetch keeps the card, a mounted Activity page applies a linked view (TASK-3615, PR B) - sinceFromUrl range-checks the fields Go's time.Parse checks instead of trusting Date.parse, which rolls 2026-02-30 over and accepts T24:00:00 (and refused offsets Go takes, up to +-24:60). The table's verdicts are pasted from go1.26. - CatchUpCard numbers its loads, so A->B->A cannot let the first A answer paint over the second. - A mark that lands but whose refetch fails keeps the card and says the mark worked, instead of clearing it. - The Activity page applies a changed ?view= on a mounted page, as it already did for ?since=, still without persisting it. - The e2e waits for a new second before Mark, since the window floor is inclusive at second precision. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): clearing the since chip or Clear filters drops since from the URL (TASK-3615, PR B) Clearing only filterSince left ?since= in the URL, so a reload re-applied a cleared filter, and re-following the same link changed no URL value, so the URL effect never re-ran. Both clear paths now navigate (replacing history) to the URL without since. The shallow replaceState would leave page.url, and so the derived since, stale. The route has no load function, so the navigation re-fetches nothing. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): a clear whose navigation rejects still drops since from the address bar; the e2e reload leg asserts on the request (TASK-3615, PR B) r3 findings. clearSince falls back to the shallow replaceState when its goto rejects, so a reload stays cleared. A superseded goto needs nothing, since the newer navigation decides the URL. The e2e reload leg now checks the reloaded feed request carries no since, instead of a chip absence that passes before a late effect could add one. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(web): choosing Live or Audit drops view from the URL, so a reload keeps the choice (TASK-3615, PR B) r4 Medium, the same class as the since fix: a Live or Audit click cleared viewFromUrl but left ?view= in the URL, so a reload or a revisit re-applied the linked view over the user's choice. The page reads two URL values, since and view, and every path that clears or overrides either now drops it from the URL through one helper. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * fix(server): refuse an activity since whose UTC instant passes year 9999 (TASK-3615, PR B) r5 finding. An offset can carry 9999-12-31 into year 10000, and the store compares the cutoff as RFC3339 text, so "10000-..." sorted below every row and opened the window to everything. The handler now refuses it with 400 invalid_since, and sinceFromUrl mirrors the bound so such a link loads the unfiltered feed instead of an error. The new rows' verdicts come from a Go probe of the handler's condition. docs/api.md says so. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW * test(web): census entry for CatchUpCard's keyed sections each (TASK-3615, PR B) eachKeyCensus.test.ts requires every keyed each to name why its keys cannot repeat. CatchUpCard's sections come from a four-entry literal with distinct ids, only filtered. Claude-Session: https://claude.ai/code/session_01DYmGZ1nCgxXCX4jPsQu5MW
98 lines
3.4 KiB
Go
98 lines
3.4 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// TASK-3615 PR B: the activity feed's `since` accepts an RFC3339 instant as
|
|
// well as a date, so the catch-up card's "See everything" can open its exact
|
|
// window. Three rows are pinned 30 seconds apart. Every leg names the rows it
|
|
// expects, and a control leg one second later shows the filter actually bites.
|
|
func TestTASK3615_ActivitySinceAcceptsRFC3339(t *testing.T) {
|
|
srv := testServer(t)
|
|
f := newTask3615Fixture(t, srv)
|
|
|
|
a := f.create(t, f.aliceTok, "tasks", "since A")
|
|
b := f.create(t, f.aliceTok, "tasks", "since B")
|
|
c := f.create(t, f.aliceTok, "tasks", "since C")
|
|
for it, ts := range map[string]string{
|
|
a.ID: "2026-10-01T10:00:00Z",
|
|
b.ID: "2026-10-01T10:00:30Z",
|
|
c.ID: "2026-10-01T10:01:00Z",
|
|
} {
|
|
if _, err := srv.store.DB().Exec(fmt.Sprintf("UPDATE activities SET created_at = '%s' WHERE document_id = '%s'", ts, it)); err != nil {
|
|
t.Fatalf("pin activity: %v", err)
|
|
}
|
|
}
|
|
name := map[string]string{a.ID: "A", b.ID: "B", c.ID: "C"}
|
|
|
|
feed := func(t *testing.T, since string) (int, string) {
|
|
t.Helper()
|
|
rr := doRequestWithCookie(srv, "GET", "/api/v1/workspaces/"+f.ws.Slug+"/activity?since="+url.QueryEscape(since), nil, f.aliceTok)
|
|
if rr.Code != http.StatusOK {
|
|
return rr.Code, rr.Body.String()
|
|
}
|
|
var acts []models.Activity
|
|
parseJSON(t, rr, &acts)
|
|
seen := map[string]bool{}
|
|
for _, act := range acts {
|
|
if n, ok := name[act.DocumentID]; ok {
|
|
seen[n] = true
|
|
}
|
|
}
|
|
var got []string
|
|
for n := range seen {
|
|
got = append(got, n)
|
|
}
|
|
sort.Strings(got)
|
|
return rr.Code, strings.Join(got, ",")
|
|
}
|
|
|
|
for _, tc := range []struct{ leg, since, want string }{
|
|
{"date form unchanged", "2026-10-01", "A,B,C"},
|
|
{"Z instant", "2026-10-01T10:00:30Z", "B,C"},
|
|
// The same instant as the Z leg. Read as a UTC wall time, 12:00:30
|
|
// would be after every row and the answer would be empty.
|
|
{"offset instant is converted, not read as UTC", "2026-10-01T12:00:30+02:00", "B,C"},
|
|
// A fraction is dropped and the floor rounds DOWN, so B, stamped in
|
|
// that same second, is re-shown and never hidden.
|
|
{"fraction rounds the floor down", "2026-10-01T10:00:30.900Z", "B,C"},
|
|
{"control: one second later drops B", "2026-10-01T10:00:31Z", "C"},
|
|
} {
|
|
t.Run(tc.leg, func(t *testing.T) {
|
|
code, got := feed(t, tc.since)
|
|
if code != http.StatusOK || got != tc.want {
|
|
t.Fatalf("since=%s: code %d, got %q, want %q", tc.since, code, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("garbage is refused 400 invalid_since", func(t *testing.T) {
|
|
code, body := feed(t, "yesterday")
|
|
if code != http.StatusBadRequest || !strings.Contains(body, "invalid_since") {
|
|
t.Fatalf("code %d body %s", code, body)
|
|
}
|
|
})
|
|
|
|
// An offset can carry the UTC instant into year 10000, whose cutoff text
|
|
// "10000-…" sorts below every row, so the window would open to everything
|
|
// (PR B r5). The control is the same wall time one hour earlier, which
|
|
// stays in 9999 and is accepted.
|
|
t.Run("a since whose UTC instant passes year 9999 is refused 400 invalid_since", func(t *testing.T) {
|
|
code, body := feed(t, "9999-12-31T23:59:59-01:00")
|
|
if code != http.StatusBadRequest || !strings.Contains(body, "invalid_since") {
|
|
t.Fatalf("code %d body %s", code, body)
|
|
}
|
|
if code, got := feed(t, "9999-12-31T22:59:59-01:00"); code != http.StatusOK || got != "" {
|
|
t.Fatalf("control: code %d, got %q, want 200 with no rows", code, got)
|
|
}
|
|
})
|
|
}
|