Files
pad/internal/store/dialect_test.go
T
xarmian a1d09c90df feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630) (#638)
* 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.
2026-05-29 08:24:05 -04:00

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)
}
}