Files
pikasTech-HWLAB/internal/workbenchruntime/realtime_sync_test.go
T

410 lines
14 KiB
Go

package workbenchruntime
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRealtimeSyncTxOptionsAreReadOnlyRepeatableRead(t *testing.T) {
options := realtimeSyncTxOptions()
if options.Isolation != sql.LevelRepeatableRead {
t.Fatalf("isolation=%v, want repeatable read", options.Isolation)
}
if !options.ReadOnly {
t.Fatal("realtime sync transaction must be read only")
}
}
func TestNormalizeRealtimeSyncQueryRequiresScopedActorAndBoundsLimit(t *testing.T) {
query, validation := normalizeRealtimeSyncQuery(realtimeSyncQuery{
SessionID: " ses_sync ",
Limit: 900,
Actor: sessionActor{ID: " usr_owner ", Role: " USER "},
})
if validation != nil {
t.Fatalf("unexpected validation: %#v", validation)
}
if query.SessionID != "ses_sync" || query.Actor.ID != "usr_owner" || query.Actor.Role != "user" {
t.Fatalf("query was not normalized: %#v", query)
}
if query.Limit != realtimeSyncMaxLimit {
t.Fatalf("limit=%d, want %d", query.Limit, realtimeSyncMaxLimit)
}
_, validation = normalizeRealtimeSyncQuery(realtimeSyncQuery{TraceID: "trc_sync", Actor: sessionActor{Role: "user"}})
if validation == nil || validation.Status != http.StatusForbidden || validation.Code != "workbench_sync_actor_required" {
t.Fatalf("ordinary user without owner id validation=%#v", validation)
}
admin, validation := normalizeRealtimeSyncQuery(realtimeSyncQuery{TraceID: "trc_sync", Actor: sessionActor{Role: "admin"}})
if validation != nil || admin.Actor.ID != "" {
t.Fatalf("admin should not require owner id: query=%#v validation=%#v", admin, validation)
}
_, validation = normalizeRealtimeSyncQuery(realtimeSyncQuery{Actor: sessionActor{ID: "usr_owner", Role: "user"}})
if validation == nil || validation.Code != "workbench_sync_scope_required" {
t.Fatalf("missing scope validation=%#v", validation)
}
_, validation = normalizeRealtimeSyncQuery(realtimeSyncQuery{SessionID: "ses_sync", AfterOutboxSeq: -1, Actor: sessionActor{ID: "usr_owner", Role: "user"}})
if validation == nil || validation.Code != "workbench_sync_cursor_invalid" {
t.Fatalf("negative cursor validation=%#v", validation)
}
_, validation = normalizeRealtimeSyncQuery(realtimeSyncQuery{SessionID: "ses_sync", SnapshotOnly: true, DeltaOnly: true, Actor: sessionActor{ID: "usr_owner", Role: "user"}})
if validation == nil || validation.Code != "workbench_sync_mode_invalid" {
t.Fatalf("conflicting sync mode validation=%#v", validation)
}
}
func TestRealtimeSyncRouteIsPostOnlyAndRejectsUnknownFields(t *testing.T) {
server := &Server{config: Config{}, mux: http.NewServeMux()}
server.routes()
methodRecorder := httptest.NewRecorder()
server.mux.ServeHTTP(methodRecorder, httptest.NewRequest(http.MethodGet, "/v1/workbench-runtime/sync", nil))
if methodRecorder.Code != http.StatusMethodNotAllowed {
t.Fatalf("GET status=%d, want %d", methodRecorder.Code, http.StatusMethodNotAllowed)
}
if methodRecorder.Header().Get("Allow") != "POST" {
t.Fatalf("Allow=%q, want POST", methodRecorder.Header().Get("Allow"))
}
unknownRecorder := httptest.NewRecorder()
unknownRequest := httptest.NewRequest(http.MethodPost, "/v1/workbench-runtime/sync", strings.NewReader(`{"sessionId":"ses_sync","actor":{"id":"usr_owner","role":"user"},"unknown":true}`))
server.mux.ServeHTTP(unknownRecorder, unknownRequest)
if unknownRecorder.Code != http.StatusBadRequest || !strings.Contains(unknownRecorder.Body.String(), "invalid_json") {
t.Fatalf("unknown field response=%d %s", unknownRecorder.Code, unknownRecorder.Body.String())
}
trailingRecorder := httptest.NewRecorder()
trailingRequest := httptest.NewRequest(http.MethodPost, "/v1/workbench-runtime/sync", strings.NewReader(`{"sessionId":"ses_sync","actor":{"id":"usr_owner","role":"user"}} {}`))
server.mux.ServeHTTP(trailingRecorder, trailingRequest)
if trailingRecorder.Code != http.StatusBadRequest || !strings.Contains(trailingRecorder.Body.String(), "invalid_json") {
t.Fatalf("trailing JSON response=%d %s", trailingRecorder.Code, trailingRecorder.Body.String())
}
}
func TestReadRealtimeSyncSnapshotUsesOwnerScopeAndSingleCutoff(t *testing.T) {
queryer := newRealtimeSyncFakeQueryer("usr_owner")
query := realtimeSyncQuery{
SessionID: "ses_sync",
TraceID: "trc_sync",
AfterOutboxSeq: 0,
Limit: 2,
Actor: sessionActor{ID: "usr_owner", Role: "user"},
}
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, query)
if err != nil {
t.Fatalf("read sync snapshot: %v", err)
}
if snapshot.CutoffOutboxSeq != 3 || snapshot.CursorOutboxSeq != 2 || !snapshot.HasMore {
t.Fatalf("unexpected cursor boundary: %#v", snapshot)
}
if len(snapshot.Events) != 2 {
t.Fatalf("events=%d, want 2", len(snapshot.Events))
}
first := snapshot.Events[0]
if first.OutboxEventID != "obe_1" || first.EntityFamily != "traceEvents" || first.EntityID != "evt_1" {
t.Fatalf("event identity fields were not returned: %#v", first)
}
if first.EventSeq != int64(101) || first.AggregateID != "trc_sync" || first.AggregateSeq != 1 || first.ProjectionRevision != 11 {
t.Fatalf("event aggregate fields were not returned: %#v", first)
}
if len(snapshot.Facts) != len(realtimeSyncFactSpecs) {
t.Fatalf("fact families=%d, want %d: %#v", len(snapshot.Facts), len(realtimeSyncFactSpecs), snapshot.Facts)
}
for _, spec := range realtimeSyncFactSpecs {
if len(snapshot.Facts[spec.Family]) != 1 {
t.Fatalf("facts[%s]=%#v, want one fact", spec.Family, snapshot.Facts[spec.Family])
}
}
if len(queryer.calls) != 3+len(realtimeSyncFactSpecs) {
t.Fatalf("query calls=%v", queryer.calls)
}
if queryer.calls[0].kind != "scope" || queryer.calls[1].kind != "cutoff" || queryer.calls[2].kind != "events" {
t.Fatalf("snapshot boundary query order=%v", queryer.calls)
}
if got := queryer.calls[0].args; len(got) != 4 || got[2] != false || got[3] != "usr_owner" {
t.Fatalf("owner scope args=%#v", got)
}
if got := queryer.calls[2].args; len(got) != 5 || got[3] != int64(3) || got[4] != 3 {
t.Fatalf("events must use cutoff and limit+1: %#v", got)
}
}
func TestReadRealtimeSyncSnapshotRejectsOtherOwnerBeforeReadingFacts(t *testing.T) {
if !strings.Contains(realtimeSyncResolveScopeSQL, "sessions.owner_user_id = $4") {
t.Fatalf("ordinary user scope must be constrained by workbench_sessions.owner_user_id: %s", realtimeSyncResolveScopeSQL)
}
queryer := newRealtimeSyncFakeQueryer("usr_owner")
_, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
SessionID: "ses_sync",
Limit: 100,
Actor: sessionActor{ID: "usr_other", Role: "user"},
})
if !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("error=%v, want sql.ErrNoRows", err)
}
if len(queryer.calls) != 1 || queryer.calls[0].kind != "scope" {
t.Fatalf("unauthorized scope must stop before outbox/facts reads: %v", queryer.calls)
}
}
func TestReadRealtimeSyncSnapshotOnlySkipsHistoryAndReadsBoundedCurrentFacts(t *testing.T) {
queryer := newRealtimeSyncFakeQueryer("usr_owner")
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
SessionID: "ses_sync",
Limit: 100,
SnapshotOnly: true,
Actor: sessionActor{ID: "usr_owner", Role: "user"},
})
if err != nil {
t.Fatalf("read snapshot-only sync: %v", err)
}
if len(snapshot.Events) != 0 || snapshot.HasMore || snapshot.CursorOutboxSeq != snapshot.CutoffOutboxSeq {
t.Fatalf("snapshot-only boundary=%#v", snapshot)
}
if len(snapshot.Facts) != len(realtimeSyncSnapshotFactSpecs) {
t.Fatalf("snapshot-only facts=%#v", snapshot.Facts)
}
for _, call := range queryer.calls {
if call.kind == "events" || call.kind == "facts.parts" || call.kind == "facts.traceEvents" || call.kind == "facts.checkpoints" {
t.Fatalf("snapshot-only performed unbounded read: %v", queryer.calls)
}
}
}
func TestReadRealtimeSyncDeltaOnlyReadsOutboxWithoutFacts(t *testing.T) {
queryer := newRealtimeSyncFakeQueryer("usr_owner")
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
SessionID: "ses_sync",
AfterOutboxSeq: 1,
Limit: 100,
DeltaOnly: true,
Actor: sessionActor{ID: "usr_owner", Role: "user"},
})
if err != nil {
t.Fatalf("read delta-only sync: %v", err)
}
if len(snapshot.Events) == 0 || len(snapshot.Facts) != 0 {
t.Fatalf("delta-only result=%#v", snapshot)
}
for _, call := range queryer.calls {
if strings.HasPrefix(call.kind, "facts.") {
t.Fatalf("delta-only read facts: %v", queryer.calls)
}
}
}
func TestReadRealtimeSyncSnapshotClampsForeignCursorToScopedCutoff(t *testing.T) {
queryer := newRealtimeSyncFakeQueryer("usr_owner")
queryer.events = [][]any{}
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
SessionID: "ses_sync",
AfterOutboxSeq: 999,
Limit: 100,
Actor: sessionActor{ID: "usr_owner", Role: "user"},
})
if err != nil {
t.Fatalf("read sync snapshot: %v", err)
}
if snapshot.CutoffOutboxSeq != 3 || snapshot.CursorOutboxSeq != 3 || snapshot.HasMore || len(snapshot.Events) != 0 {
t.Fatalf("foreign cursor was not clamped to scoped cutoff: %#v", snapshot)
}
}
func TestReadRealtimeSyncSnapshotAllowsAdminWithoutOwnerFilter(t *testing.T) {
queryer := newRealtimeSyncFakeQueryer("usr_owner")
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
TraceID: "trc_sync",
Limit: 3,
Actor: sessionActor{Role: "admin"},
})
if err != nil {
t.Fatalf("admin sync: %v", err)
}
if len(queryer.calls) == 0 || queryer.calls[0].args[2] != true || queryer.calls[0].args[3] != "" {
t.Fatalf("admin scope args=%#v", queryer.calls)
}
if snapshot.CursorOutboxSeq != snapshot.CutoffOutboxSeq || snapshot.HasMore {
t.Fatalf("admin complete page boundary=%#v", snapshot)
}
}
type realtimeSyncFakeCall struct {
kind string
query string
args []any
}
type realtimeSyncFakeQueryer struct {
ownerID string
sessionID string
cutoff int64
events [][]any
facts map[string][][]any
calls []realtimeSyncFakeCall
}
func newRealtimeSyncFakeQueryer(ownerID string) *realtimeSyncFakeQueryer {
facts := map[string][][]any{}
for _, spec := range realtimeSyncFactSpecs {
facts[spec.Table] = [][]any{{fmt.Sprintf(`{"family":%q,"sessionId":"ses_sync"}`, spec.Family)}}
}
return &realtimeSyncFakeQueryer{
ownerID: ownerID,
sessionID: "ses_sync",
cutoff: 3,
events: [][]any{realtimeSyncTestEventRow(1), realtimeSyncTestEventRow(2), realtimeSyncTestEventRow(3)},
facts: facts,
}
}
func (queryer *realtimeSyncFakeQueryer) QueryRowContext(_ context.Context, query string, args ...any) realtimeSyncRow {
if query == realtimeSyncResolveScopeSQL {
queryer.record("scope", query, args)
isAdmin, _ := args[2].(bool)
actorID, _ := args[3].(string)
if !isAdmin && actorID != queryer.ownerID {
return realtimeSyncFakeRow{err: sql.ErrNoRows}
}
return realtimeSyncFakeRow{values: []any{queryer.sessionID}}
}
if query == realtimeSyncCutoffSQL {
queryer.record("cutoff", query, args)
return realtimeSyncFakeRow{values: []any{queryer.cutoff}}
}
return realtimeSyncFakeRow{err: fmt.Errorf("unexpected QueryRowContext: %s", query)}
}
func (queryer *realtimeSyncFakeQueryer) QueryContext(_ context.Context, query string, args ...any) (realtimeSyncRows, error) {
if query == realtimeSyncEventsSQL {
queryer.record("events", query, args)
return &realtimeSyncFakeRows{values: queryer.events}, nil
}
for _, spec := range realtimeSyncFactSpecs {
if strings.Contains(query, "FROM "+spec.Table+" ") {
queryer.record("facts."+spec.Family, query, args)
return &realtimeSyncFakeRows{values: queryer.facts[spec.Table]}, nil
}
}
return nil, fmt.Errorf("unexpected QueryContext: %s", query)
}
func (queryer *realtimeSyncFakeQueryer) record(kind string, query string, args []any) {
cloned := append([]any(nil), args...)
queryer.calls = append(queryer.calls, realtimeSyncFakeCall{kind: kind, query: query, args: cloned})
}
type realtimeSyncFakeRow struct {
values []any
err error
}
func (row realtimeSyncFakeRow) Scan(destinations ...any) error {
if row.err != nil {
return row.err
}
return assignRealtimeSyncFakeValues(destinations, row.values)
}
type realtimeSyncFakeRows struct {
values [][]any
index int
err error
}
func (rows *realtimeSyncFakeRows) Close() error { return nil }
func (rows *realtimeSyncFakeRows) Err() error { return rows.err }
func (rows *realtimeSyncFakeRows) Next() bool { return rows.index < len(rows.values) }
func (rows *realtimeSyncFakeRows) Scan(destinations ...any) error {
if rows.index >= len(rows.values) {
return errors.New("realtime sync fake rows exhausted")
}
values := rows.values[rows.index]
rows.index++
return assignRealtimeSyncFakeValues(destinations, values)
}
func assignRealtimeSyncFakeValues(destinations []any, values []any) error {
if len(destinations) != len(values) {
return fmt.Errorf("scan destinations=%d values=%d", len(destinations), len(values))
}
for index, destination := range destinations {
value := values[index]
switch target := destination.(type) {
case *string:
if value == nil {
return fmt.Errorf("cannot scan nil into string")
}
*target = fmt.Sprint(value)
case *int64:
integer, ok := value.(int64)
if !ok {
return fmt.Errorf("cannot scan %T into int64", value)
}
*target = integer
case *sql.NullString:
if value == nil {
*target = sql.NullString{}
} else {
*target = sql.NullString{String: fmt.Sprint(value), Valid: true}
}
case *sql.NullInt64:
if value == nil {
*target = sql.NullInt64{}
} else if integer, ok := value.(int64); ok {
*target = sql.NullInt64{Int64: integer, Valid: true}
} else {
return fmt.Errorf("cannot scan %T into NullInt64", value)
}
case *sql.NullBool:
if value == nil {
*target = sql.NullBool{}
} else if boolean, ok := value.(bool); ok {
*target = sql.NullBool{Bool: boolean, Valid: true}
} else {
return fmt.Errorf("cannot scan %T into NullBool", value)
}
default:
return fmt.Errorf("unsupported scan destination %T", destination)
}
}
return nil
}
func realtimeSyncTestEventRow(sequence int64) []any {
return []any{
sequence,
fmt.Sprintf("obe_%d", sequence),
"traceEvents",
fmt.Sprintf("evt_%d", sequence),
int64(100) + sequence,
"trc_sync",
sequence,
int64(10) + sequence,
"trc_sync",
"ses_sync",
"turn_sync",
nil,
sequence,
sequence,
fmt.Sprintf("source_%d", sequence),
"event",
false,
false,
fmt.Sprintf(`{"sequence":%d}`, sequence),
"2026-07-10T12:00:00Z",
}
}