Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

530 lines
21 KiB
Go

//go:build pgtest
// This parity test proves that GetActivityReport returns an identical
// activity.Report from all three storage backends (SQLite, PostgreSQL,
// DuckDB) given the same underlying data. One SQLite fixture is built, then
// pushed to PostgreSQL and DuckDB through the production push paths
// (postgres.Sync / duckdb.Sync). All three stores are queried with the same
// filter and date and the resulting reports are deep-compared.
//
// It lives in internal/activity as an EXTERNAL test package
// (activity_test) rather than inside any backend package: postgres, duckdb,
// and db all import activity, so an internal activity test that imported
// them would form an import cycle. An external _test package is compiled
// separately and may import all three backends -- activity_test -> {db,
// postgres, duckdb} -> activity is acyclic. The pgtest build tag keeps it
// out of the package's default, backend-free test runs.
package activity_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/activity"
"go.kenn.io/agentsview/internal/db"
duckdbstore "go.kenn.io/agentsview/internal/duckdb"
postgresstore "go.kenn.io/agentsview/internal/postgres"
)
// parityDate is a calendar day safely in the past relative to any realistic
// test-run wall clock. A past full day makes activity.Aggregate treat the
// report as complete (partial=false, effective_end == day_end, as_of=nil),
// removing the only source of nondeterminism: each backend calls
// time.Now().UTC() independently, so a current-or-future day could yield
// microsecond-different as_of/effective_end values across the three reports.
const parityDate = "2026-06-14"
// paritySchema is dedicated to this test so it never collides with the shared
// "agentsview" schema that internal/postgres pgtests create and drop. Go runs
// package tests concurrently, so a shared-schema DROP here could wipe another
// package's active schema mid-run.
const paritySchema = "agentsview_daily_report_parity_test"
// parityFixtureSession describes one seeded session and its message stream for
// the parity fixture. Assistant messages carry token_usage JSON so cost is
// exercised through the message-source usage path (no usage_events needed).
type parityFixtureSession struct {
id string
project string
model string
// events is an ordered (role, RFC3339-timestamp) list. Assistant rows get
// token_usage with the given outputTokens so they contribute cost.
events []parityEvent
outputTokens int
// relationship overrides the session's relationship_type ("" means
// "root"); parent sets parent_session_id when non-empty. Subagent and
// fork sessions must count in every backend's report so the cost
// totals match daily usage.
relationship string
parent string
}
type parityEvent struct {
role string
ts string
// model and outputTokens override the session defaults for this one
// assistant message. They exist so the fixture can inject an
// ineligible (synthetic-model) usage row that every backend must
// exclude identically. Zero values mean "use the session defaults".
model string
outputTokens int
// claudeMessageID/claudeRequestID set the message's Claude dedup keys.
// When the same pair recurs across sessions the usage union dedups to a
// single first-seen-wins row, exercising the cross-backend ordering of
// duplicate usage rows.
claudeMessageID string
claudeRequestID string
}
// parityFixture returns the sessions seeded into every backend. Two sessions
// overlap on parityDate across two projects and two models (driving peak
// concurrency 2 and multi-key breakdowns); a third, non-overlapping session
// adds a second interval to the alpha/model-x rollups so the breakdowns are
// non-trivial. Three more untimed sessions exercise the usage edge cases the
// dedup/primary-model fixes touched: a resumed/forked pair (parity-d/parity-e)
// sharing one Claude dedup key in the same second (whole-second vs fractional),
// and a zero-cost known-model session (parity-f). All timestamps are well
// inside the day so the report is a full, non-partial day.
func parityFixture() []parityFixtureSession {
return []parityFixtureSession{
{
id: "parity-a", project: "alpha", model: "model-x",
outputTokens: 1200,
events: []parityEvent{
{role: "user", ts: parityDate + "T10:00:00Z"},
{role: "assistant", ts: parityDate + "T10:02:00Z"},
{role: "user", ts: parityDate + "T10:05:00Z"},
{role: "assistant", ts: parityDate + "T10:07:00Z"},
},
},
{
id: "parity-b", project: "beta", model: "model-y",
outputTokens: 800,
events: []parityEvent{
{role: "user", ts: parityDate + "T10:01:00Z"},
{role: "assistant", ts: parityDate + "T10:03:00Z"},
{role: "user", ts: parityDate + "T10:06:00Z"},
{role: "assistant", ts: parityDate + "T10:08:00Z"},
},
},
{
id: "parity-c", project: "alpha", model: "model-x",
outputTokens: 300,
events: []parityEvent{
{role: "user", ts: parityDate + "T14:00:00Z"},
{role: "assistant", ts: parityDate + "T14:04:00Z"},
// Ineligible usage row: a synthetic-model assistant message
// carrying real tokens. Every backend's usage union must drop
// model == '<synthetic>', so it contributes no cost or output
// tokens. If any backend (notably DuckDB, which inlines its own
// usage CTE) failed to exclude it, that backend's totals would
// diverge and the deep-compare below would fail.
{role: "assistant", ts: parityDate + "T14:06:00Z",
model: "<synthetic>", outputTokens: 9999},
},
},
{
// Resumed/forked dedup pair: parity-d and parity-e share one
// (claude_message_id, claude_request_id) in the same second, one
// whole-second instant and one fractional. First-seen-wins dedup
// keeps the earlier whole-second row (500 tokens) on every backend;
// the later fractional duplicate (9000) is dropped. A text sort of
// the timestamp would invert them ('.' < 'Z'), so this guards the
// parsed-instant ordering across backends.
id: "parity-d", project: "gamma", model: "model-x",
outputTokens: 500,
events: []parityEvent{
{role: "assistant", ts: parityDate + "T11:00:00Z",
claudeMessageID: "dup-m", claudeRequestID: "dup-r"},
},
},
{
id: "parity-e", project: "gamma", model: "model-x",
outputTokens: 9000,
events: []parityEvent{
{role: "assistant", ts: parityDate + "T11:00:00.123Z",
claudeMessageID: "dup-m", claudeRequestID: "dup-r"},
},
},
{
// Zero-cost usage-only session: a single known-model assistant
// message with zero tokens (zero cost). Every backend must still
// report model-x as the primary model rather than a blank one.
id: "parity-f", project: "delta", model: "model-x",
outputTokens: 0,
events: []parityEvent{
{role: "assistant", ts: parityDate + "T12:00:00Z"},
},
},
{
// Subagent of parity-a: a usage-only single message. Its tokens
// must count in every backend (the report includes subagent
// sessions so its cost matches daily usage) and its session row
// must appear in BySession.
id: "parity-sub", project: "alpha", model: "model-x",
outputTokens: 250, relationship: "subagent", parent: "parity-a",
events: []parityEvent{
{role: "assistant", ts: parityDate + "T10:03:30Z"},
},
},
{
// Fork of parity-a with a unique-keyed usage row: a rewound
// branch's messages are real spend that daily usage counts, so
// every backend must count its tokens and list its session row.
id: "parity-fork", project: "alpha", model: "model-x",
outputTokens: 9000, relationship: "fork", parent: "parity-a",
events: []parityEvent{
{role: "assistant", ts: parityDate + "T10:09:00Z"},
},
},
{
// Fork replaying parity-a's dedup key (same Claude pair as the
// parity-d/parity-e pair but scoped to this fork): its usage row
// must collapse in every backend's dedup, contributing a session
// row but no tokens or cost.
id: "parity-fork-replay", project: "gamma", model: "model-x",
outputTokens: 7777, relationship: "fork", parent: "parity-d",
events: []parityEvent{
{role: "assistant", ts: parityDate + "T11:00:02Z",
claudeMessageID: "dup-m", claudeRequestID: "dup-r"},
},
},
}
}
// seedParitySQLite builds the SQLite fixture: pricing rows for the two models
// plus the sessions and messages from parityFixture, all written through the
// public WriteSessionBatchAtomic path so the data matches what a real sync
// would push.
func seedParitySQLite(t *testing.T) *db.DB {
t.Helper()
local, err := db.Open(filepath.Join(t.TempDir(), "parity.sqlite"))
require.NoError(t, err, "opening sqlite fixture")
t.Cleanup(func() { require.NoError(t, local.Close()) })
// Explicit pricing for both models so all three backends price the same
// token amounts identically (the syncs copy model_pricing to PG/DuckDB).
require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{
{ModelPattern: "model-x", InputPerMTok: 3, OutputPerMTok: 15,
CacheCreationPerMTok: 3.75, CacheReadPerMTok: 0.3},
{ModelPattern: "model-y", InputPerMTok: 1, OutputPerMTok: 5,
CacheCreationPerMTok: 1.25, CacheReadPerMTok: 0.1},
}), "seeding pricing")
var writes []db.SessionBatchWrite
for _, fs := range parityFixture() {
writes = append(writes, paritySessionWrite(fs))
}
_, err = local.WriteSessionBatchAtomic(writes)
require.NoError(t, err, "writing fixture sessions")
return local
}
// paritySessionWrite turns one fixture session into a SessionBatchWrite. The
// session window spans its first and last event; assistant messages carry the
// model and token_usage so they feed the usage path.
func paritySessionWrite(fs parityFixtureSession) db.SessionBatchWrite {
first := fs.events[0].ts
last := fs.events[len(fs.events)-1].ts
firstMsg := "parity " + fs.id
relationship := fs.relationship
if relationship == "" {
relationship = "root"
}
sess := db.Session{
ID: fs.id,
Project: fs.project,
Machine: "local",
Agent: "claude",
FirstMessage: &firstMsg,
StartedAt: &first,
EndedAt: &last,
CreatedAt: first,
LocalModifiedAt: &first,
MessageCount: len(fs.events),
UserMessageCount: 1,
RelationshipType: relationship,
DataVersion: 1,
}
if fs.parent != "" {
parent := fs.parent
sess.ParentSessionID = &parent
}
msgs := make([]db.Message, 0, len(fs.events))
for i, ev := range fs.events {
m := db.Message{
SessionID: fs.id,
Ordinal: i,
Role: ev.role,
Content: ev.role + " " + fs.id,
Timestamp: ev.ts,
ContentLength: len(ev.role + " " + fs.id),
}
if ev.claudeMessageID != "" {
m.ClaudeMessageID = ev.claudeMessageID
}
if ev.claudeRequestID != "" {
m.ClaudeRequestID = ev.claudeRequestID
}
if ev.role == "assistant" {
model := fs.model
if ev.model != "" {
model = ev.model
}
outputTokens := fs.outputTokens
if ev.outputTokens != 0 {
outputTokens = ev.outputTokens
}
m.Model = model
usage, _ := json.Marshal(map[string]int{
"input_tokens": outputTokens / 2,
"output_tokens": outputTokens,
})
m.TokenUsage = usage
m.OutputTokens = outputTokens
m.HasOutputTokens = true
}
msgs = append(msgs, m)
}
return db.SessionBatchWrite{
Session: sess,
Messages: msgs,
DataVersion: 1,
ReplaceMessages: true,
}
}
// pushParityPostgres pushes the SQLite fixture to PostgreSQL via the production
// Sync and returns a read-only PG store over the same database. The schema is
// dropped before and after so the test is self-contained.
func pushParityPostgres(
t *testing.T, ctx context.Context, local *db.DB,
) *postgresstore.Store {
t.Helper()
pgURL := os.Getenv("TEST_PG_URL")
if pgURL == "" {
t.Skip("TEST_PG_URL not set; skipping cross-backend parity test")
}
dropParitySchema(t, pgURL)
t.Cleanup(func() { dropParitySchema(t, pgURL) })
ps, err := postgresstore.New(
pgURL, paritySchema, local, "parity-machine", true,
postgresstore.SyncOptions{},
)
require.NoError(t, err, "creating pg sync")
t.Cleanup(func() { require.NoError(t, ps.Close()) })
require.NoError(t, ps.EnsureSchema(ctx), "ensuring pg schema")
res, err := ps.Push(ctx, false, nil)
require.NoError(t, err, "pushing to pg")
require.Equal(t, len(parityFixture()), res.SessionsPushed,
"pg sessions pushed")
store, err := postgresstore.NewStore(pgURL, paritySchema, true)
require.NoError(t, err, "opening pg store")
t.Cleanup(func() { require.NoError(t, store.Close()) })
return store
}
// dropParitySchema removes the PG schema so each run starts clean.
func dropParitySchema(t *testing.T, pgURL string) {
t.Helper()
store, err := postgresstore.NewStore(pgURL, paritySchema, true)
require.NoError(t, err, "opening pg for schema drop")
defer func() { require.NoError(t, store.Close()) }()
_, _ = store.DB().Exec("DROP SCHEMA IF EXISTS " + paritySchema + " CASCADE")
}
// pushParityDuckDB pushes the SQLite fixture to a DuckDB mirror via the
// production Sync and returns a read-only DuckDB store over the same
// connection.
func pushParityDuckDB(
t *testing.T, ctx context.Context, local *db.DB,
) *duckdbstore.Store {
t.Helper()
target := filepath.Join(t.TempDir(), "parity.duckdb")
syncer, err := duckdbstore.New(
target, local, "parity-machine", duckdbstore.SyncOptions{})
require.NoError(t, err, "creating duckdb sync")
t.Cleanup(func() { require.NoError(t, syncer.Close()) })
res, err := syncer.Push(ctx, true, nil)
require.NoError(t, err, "pushing to duckdb")
require.Equal(t, len(parityFixture()), res.SessionsPushed,
"duckdb sessions pushed")
return duckdbstore.NewStoreFromDB(syncer.DB())
}
// canonicalizeReport sorts the report's order-unspecified slices by a stable
// key so the deep comparison is order-independent. ByProject/ByModel/ByAgent
// are already minutes-then-key sorted by the aggregator, but resorting purely
// by Key guards against any backend-introduced ordering difference among equal
// minutes. BySession is minutes-desc from the aggregator; we resort by
// SessionID so equal-minute ties cannot diverge across backends. Intervals
// needs no canonicalization: Aggregate already sorts it by (start, end,
// sessionID), a total order with no cross-backend ties to break.
func canonicalizeReport(r *activity.Report) {
sort.Slice(r.ByProject, func(i, j int) bool {
return r.ByProject[i].Key < r.ByProject[j].Key
})
sort.Slice(r.ByModel, func(i, j int) bool {
return r.ByModel[i].Key < r.ByModel[j].Key
})
sort.Slice(r.ByAgent, func(i, j int) bool {
return r.ByAgent[i].Key < r.ByAgent[j].Key
})
sort.Slice(r.BySession, func(i, j int) bool {
return r.BySession[i].SessionID < r.BySession[j].SessionID
})
}
// TestGetActivityReportParityAcrossBackends builds one fixture, loads it into
// all three backends through their production push paths, and asserts the three
// GetActivityReport results are byte-for-byte equal after canonicalizing the
// order-unspecified slices. It sweeps several ranges and bucket policies --
// minute, hourly, daily-calendar, a custom sub-day window, and a DST-spanning NY
// month -- so the parity guarantee covers every bucketing path, not just one
// day. Each case resolves against a fixed far-future now so the range is always
// complete (non-partial) and every backend's report is deterministic regardless
// of wall clock.
func TestGetActivityReportParityAcrossBackends(t *testing.T) {
ctx := context.Background()
local := seedParitySQLite(t)
// Materialize PG first: it skips the whole test when TEST_PG_URL is unset,
// so we avoid building the DuckDB mirror needlessly on a skip.
pgStore := pushParityPostgres(t, ctx, local)
duckStore := pushParityDuckDB(t, ctx, local)
fixedNow, err := time.Parse(time.RFC3339, "2030-01-01T00:00:00Z")
require.NoError(t, err, "parsing fixed now")
cases := []struct {
name string
input activity.QueryInput
}{
// A single past day -> minute (5m) buckets; carries the full fixture
// activity and the fixture-sanity assertions below.
{"day-minute", activity.QueryInput{
Preset: "day", Date: parityDate, Timezone: "UTC"}},
// A 3-day range -> hourly buckets.
{"three-day-hourly", activity.QueryInput{
Preset: "custom", Timezone: "UTC",
From: "2026-06-12T00:00:00Z", To: "2026-06-15T00:00:00Z"}},
// A 30-day range -> daily calendar buckets.
{"thirty-day-daily", activity.QueryInput{
Preset: "custom", Timezone: "UTC",
From: "2026-05-16T00:00:00Z", To: "2026-06-15T00:00:00Z"}},
// A custom sub-day window that slices into the fixture's morning.
{"custom-subday", activity.QueryInput{
Preset: "custom", Timezone: "UTC",
From: parityDate + "T09:30:00Z", To: parityDate + "T15:00:00Z"}},
// A NY month spanning the March 8 2026 DST transition. The fixture has
// no March activity, so this asserts every backend produces identical
// empty aggregation over identical DST-aware calendar bucket boundaries.
{"dst-month-ny", activity.QueryInput{
Preset: "month", Date: "2026-03-14", Timezone: "America/New_York"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
q, err := activity.ResolveQuery(tc.input, fixedNow)
require.NoError(t, err, "resolving query")
filter := db.AnalyticsFilter{Timezone: tc.input.Timezone}
sqliteReport := assertParityForCase(t, ctx, q, filter,
local, pgStore, duckStore)
if tc.name == "day-minute" {
assertDayMinuteFixtureSanity(t, sqliteReport)
}
})
}
}
// assertParityForCase queries all three backends with the resolved query and
// filter, asserts the range is complete, deep-compares the canonicalized
// reports (SQLite==PG and SQLite==DuckDB), and returns the canonicalized SQLite
// report so the caller can run case-specific fixture assertions on it.
func assertParityForCase(
t *testing.T, ctx context.Context, q activity.Query,
filter db.AnalyticsFilter,
local *db.DB, pgStore *postgresstore.Store, duckStore *duckdbstore.Store,
) activity.Report {
t.Helper()
sqliteReport, err := local.GetActivityReport(ctx, filter, q)
require.NoError(t, err, "sqlite GetActivityReport")
pgReport, err := pgStore.GetActivityReport(ctx, filter, q)
require.NoError(t, err, "pg GetActivityReport")
duckReport, err := duckStore.GetActivityReport(ctx, filter, q)
require.NoError(t, err, "duckdb GetActivityReport")
require.False(t, sqliteReport.Partial, "past range must be complete")
canonicalizeReport(&sqliteReport)
canonicalizeReport(&pgReport)
canonicalizeReport(&duckReport)
require.Equal(t, sqliteReport, pgReport,
"SQLite and PostgreSQL activity reports diverge")
require.Equal(t, sqliteReport, duckReport,
"SQLite and DuckDB activity reports diverge")
return sqliteReport
}
// assertDayMinuteFixtureSanity checks the day-minute report actually exercises
// the fixture: a full day with peak concurrency 2, nine sessions, non-zero
// cost, and exactly 14050 output tokens. The token total proves the
// synthetic-model usage row (9999 tokens) is excluded, the dedup pair
// collapses to its earlier 500-token row, the subagent's and unique fork's
// tokens count, and the replaying fork's do not -- not merely that the
// backends agree on a wrong number -- so the deep-compare above extends those
// guarantees, plus the zero-cost primary-model fallback, to PG and DuckDB.
func assertDayMinuteFixtureSanity(t *testing.T, r activity.Report) {
t.Helper()
require.False(t, r.Partial, "fixture day must be a full day")
require.Equal(t, 2, r.Peak.Agents, "fixture must reach peak concurrency 2")
require.Equal(t, 9, r.Totals.Sessions, "fixture session count")
require.Greater(t, r.Totals.Cost, 0.0, "fixture must exercise cost")
// 2400 (parity-a) + 1600 (parity-b) + 300 (parity-c; synthetic 9999 row
// excluded) + 500 (parity-d wins the dedup) + 0 (parity-e deduped away;
// parity-f zero-cost) + 250 (parity-sub) + 9000 (parity-fork, unique)
// + 0 (parity-fork-replay deduped away) = 14050.
require.Equal(t, 14050, r.Totals.OutputTokens,
"synthetic row excluded, dedup collapses, subagent and unique fork count")
bySession := map[string]activity.SessionRow{}
for _, s := range r.BySession {
bySession[s.SessionID] = s
}
require.Contains(t, bySession, "parity-sub",
"subagent session must appear in the report")
require.Contains(t, bySession, "parity-fork",
"fork session must appear in the report")
require.Equal(t, 9000, bySession["parity-fork"].OutputTokens,
"unique fork usage counts toward the report")
require.Contains(t, bySession, "parity-fork-replay",
"replaying fork still appears as a session row")
require.Equal(t, 0, bySession["parity-fork-replay"].OutputTokens,
"replayed fork usage dedups away")
require.Contains(t, bySession, "parity-d")
require.Contains(t, bySession, "parity-e")
require.Contains(t, bySession, "parity-f")
require.Equal(t, 500, bySession["parity-d"].OutputTokens,
"dedup keeps the earlier whole-second duplicate's tokens")
require.Equal(t, 0, bySession["parity-e"].OutputTokens,
"the later fractional duplicate is dropped")
require.Equal(t, "model-x", bySession["parity-f"].PrimaryModel,
"zero-cost usage still reports its known model as primary")
}