mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
a1d09c90df
* feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630)
GET /workspaces/{ws}/report?window=week&collections=tasks,bugs returns a
time-bucketed report: created-vs-completed throughput, net flow,
completed-by-collection, and a current status-distribution snapshot.
- Dialect.DateBucket(column, granularity) — day/hour bucketing via fixed-width
substring on the UTC RFC3339 TEXT (identical + exact on SQLite + Postgres;
avoids SQLite 'Z'-parsing fragility). Routes all report date math through it.
- store.GetReport: resolves per-collection done field + positive terminals
(terminal options minus rejected/cancelled/etc.), counts completions from
status_transitions and created from items.created_at, zero-fills buckets.
- HTTP handler + route; web ReportData type + api.report.get client.
- Tests: throughput/totals, negative-terminal exclusion, status distribution,
collection filter, non-status done-field, out-of-window exclusion, hourly
day-window, DateBucket per granularity. Dual-dialect via testStore.
Fixes the response contract that TASK-1632/1633/1635 consume (noted on them).
Parent: PLAN-1628.
* fix(report): scope report to caller's visible collections per Codex review (round 1)
The endpoint sits under RequireWorkspaceAccess (members, restricted members,
guests), but GetReport resolved ALL workspace collections — letting a caller
with access to one collection infer hidden collections' slugs, created/
completed counts, and status distribution. Mirror the dashboard: the handler
computes visibleCollectionIDs() and GetReport restricts to that set
(ScopeToVisible). Empty visible set → empty report. Aggregate reports are a
full-collection-visibility feature; item-level grants aren't surfaced in
workspace-wide counts.
* fix(report): correct visibility scoping for all-access + item-grant callers per Codex review (round 2)
Round 1's scoping had two bugs in how it read visibleCollectionIDs:
1. nil means "all-access" (admin / collection_access=all), but the handler
treated nil as an empty visible set → all-access users got an EMPTY report.
Now nil → ScopeToVisible stays false (full workspace report).
2. For guests, visibleCollectionIDs includes collections visible only via
item-level grants; passing those to the aggregate report leaked the whole
collection's counts. Now mirror the dashboard: when item-level grants are
present, scope to fullCollIDs (full-access collections only).
Adds report handler tests (owner full report + default window) alongside the
store-level scoping test.
* fix(report): bearer-aware admin visibility scoping per Codex review (round 3)
visibleCollectionIDs grants ANY platform admin an unrestricted (nil) view, but
RequireWorkspaceAccess suppresses the platform-admin bypass for bearer auth and
falls through to membership (BUG-1616/1617). So a bearer admin (PAT/CLI/OAuth)
who is only a restricted workspace member could read the full workspace report.
Extract reportVisibleCollections(): gate the admin bypass on cookie auth; for
everyone else resolve actual member/guest visibility, and when item-level
grants exist scope to the full-access collection set only. Adds a cookie-vs-
bearer scoping test (cookie admin unrestricted, bearer restricted-member scoped
to the granted collection, end-to-end through GetReport).
* fix(report): exclude soft-deleted items from completion counts per Codex review (round 4)
status_transitions rows survive a soft delete (only a HARD delete cascades
them), so a completed-then-soft-deleted item still counted toward completed /
completed_by_collection while created and status_distribution (which filter
deleted_at IS NULL) excluded it — inconsistent totals. Join live items in both
completed queries. Adds a regression test.
87 lines
2.8 KiB
Go
87 lines
2.8 KiB
Go
package store
|
|
|
|
import "testing"
|
|
|
|
func TestRebindQuery(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"no params", "SELECT 1", "SELECT 1"},
|
|
{"single param", "SELECT * FROM t WHERE id = ?", "SELECT * FROM t WHERE id = $1"},
|
|
{"multiple params", "INSERT INTO t (a, b, c) VALUES (?, ?, ?)", "INSERT INTO t (a, b, c) VALUES ($1, $2, $3)"},
|
|
{"string literal preserved", "SELECT * FROM t WHERE name = 'what?' AND id = ?", "SELECT * FROM t WHERE name = 'what?' AND id = $1"},
|
|
{"mixed", "SELECT * FROM t WHERE a = ? AND b = 'foo?' AND c = ?", "SELECT * FROM t WHERE a = $1 AND b = 'foo?' AND c = $2"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := rebindQuery(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("rebindQuery(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDateBucket(t *testing.T) {
|
|
// Day/hour bucketing is fixed-width substring on the UTC RFC3339 TEXT, so
|
|
// both dialects emit the same expression. Verify each granularity.
|
|
for _, d := range []Dialect{&sqliteDialect{}, &postgresDialect{}} {
|
|
if got := d.DateBucket("created_at", "day"); got != "SUBSTR(created_at, 1, 10)" {
|
|
t.Errorf("%T day bucket = %q", d, got)
|
|
}
|
|
if got := d.DateBucket("created_at", "hour"); got != "SUBSTR(created_at, 1, 13)" {
|
|
t.Errorf("%T hour bucket = %q", d, got)
|
|
}
|
|
// Unknown granularity falls back to day.
|
|
if got := d.DateBucket("created_at", "week"); got != "SUBSTR(created_at, 1, 10)" {
|
|
t.Errorf("%T fallback bucket = %q", d, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSQLiteDialect(t *testing.T) {
|
|
d := &sqliteDialect{}
|
|
|
|
if d.Driver() != DriverSQLite {
|
|
t.Errorf("expected DriverSQLite, got %v", d.Driver())
|
|
}
|
|
if got := d.JSONExtractText("i.fields", "status"); got != "json_extract(i.fields, '$.status')" {
|
|
t.Errorf("JSONExtractText = %q", got)
|
|
}
|
|
if got := d.Now(); got != "datetime('now')" {
|
|
t.Errorf("Now = %q", got)
|
|
}
|
|
if got := d.FTSMatch("items_fts", "search_vector"); got != "items_fts MATCH ?" {
|
|
t.Errorf("FTSMatch = %q", got)
|
|
}
|
|
if got := d.GroupConcat("u.name", true); got != "GROUP_CONCAT(DISTINCT u.name)" {
|
|
t.Errorf("GroupConcat = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestPostgresDialect(t *testing.T) {
|
|
d := &postgresDialect{}
|
|
|
|
if d.Driver() != DriverPostgres {
|
|
t.Errorf("expected DriverPostgres, got %v", d.Driver())
|
|
}
|
|
if got := d.Placeholder(3); got != "$3" {
|
|
t.Errorf("Placeholder(3) = %q", got)
|
|
}
|
|
if got := d.JSONExtractText("i.fields", "status"); got != "i.fields->>'status'" {
|
|
t.Errorf("JSONExtractText = %q", got)
|
|
}
|
|
if got := d.JSONRemove("fields", "phase"); got != "(fields::jsonb - 'phase')" {
|
|
t.Errorf("JSONRemove = %q", got)
|
|
}
|
|
if got := d.GroupConcat("u.name", true); got != "STRING_AGG(DISTINCT u.name, ',')" {
|
|
t.Errorf("GroupConcat = %q", got)
|
|
}
|
|
if got := d.ILike(); got != "ILIKE" {
|
|
t.Errorf("ILike = %q", got)
|
|
}
|
|
}
|