chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package webevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrCursorUnavailable = errors.New("web event cursor is unavailable")
|
||||
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
SubjectID string `json:"subject_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type Service struct{ pool *pgxpool.Pool }
|
||||
|
||||
func NewService(pool *pgxpool.Pool) *Service { return &Service{pool: pool} }
|
||||
|
||||
func (s *Service) StartingCursor(ctx context.Context, principal identity.Principal) (int64, error) {
|
||||
var cursor int64
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(max(we.id),0)
|
||||
FROM web_event we
|
||||
JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active'
|
||||
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active'
|
||||
WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND we.expires_at>now()`, principal.Issuer, principal.Subject,
|
||||
).Scan(&cursor)
|
||||
return cursor, err
|
||||
}
|
||||
|
||||
func (s *Service) ValidateCursor(ctx context.Context, principal identity.Principal, cursor int64) error {
|
||||
if cursor == 0 {
|
||||
return nil
|
||||
}
|
||||
var valid bool
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM web_event we
|
||||
JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active'
|
||||
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active'
|
||||
WHERE we.id=$1 AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND we.expires_at>now()
|
||||
)`, cursor, principal.Issuer, principal.Subject,
|
||||
).Scan(&valid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !valid {
|
||||
return ErrCursorUnavailable
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ListAfter(ctx context.Context, principal identity.Principal, cursor int64, limit int) ([]Event, error) {
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT we.id,we.event_type,we.resource_type,we.resource_public_id::text,
|
||||
COALESCE(sp.public_id::text,''),we.status,we.occurred_at
|
||||
FROM web_event we
|
||||
JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active'
|
||||
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active'
|
||||
LEFT JOIN subject_profile sp ON sp.id=we.subject_id AND sp.tenant_id=we.tenant_id
|
||||
WHERE we.id>$1 AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 AND we.expires_at>now()
|
||||
ORDER BY we.id LIMIT $4`, cursor, principal.Issuer, principal.Subject, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]Event, 0)
|
||||
for rows.Next() {
|
||||
var item Event
|
||||
if err := rows.Scan(&item.ID, &item.Type, &item.ResourceType, &item.ResourceID, &item.SubjectID, &item.Status, &item.OccurredAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) DeleteExpired(ctx context.Context, limit int) (int64, error) {
|
||||
if limit < 1 || limit > 10000 {
|
||||
limit = 1000
|
||||
}
|
||||
result, err := s.pool.Exec(ctx, `DELETE FROM web_event WHERE id IN (SELECT id FROM web_event WHERE expires_at<=now() ORDER BY id LIMIT $1)`, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *Service) ExpireDevicePresence(ctx context.Context, staleBefore time.Time, limit int) (int, error) {
|
||||
if limit < 1 || limit > 10000 {
|
||||
limit = 1000
|
||||
}
|
||||
var count int
|
||||
err := s.pool.QueryRow(ctx, `SELECT expire_device_presence($1,$2)`, staleBefore.UTC(), limit).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package webevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestServiceCursorIsolationCleanupAndPresenceExpiry(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
service := NewService(pool)
|
||||
owner := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
|
||||
outsider := identity.Principal{Issuer: owner.Issuer, Subject: "00000000-0000-0000-0000-000000000103"}
|
||||
resourceIDs := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
|
||||
testStarted := time.Now().UTC().Add(-time.Second)
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM device_presence_state WHERE tenant_id='10000000-0000-0000-0000-000000000001' AND device_id='10000000-0000-0000-0000-000000000501'`)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM web_event WHERE resource_public_id=ANY($1::uuid[]) OR (resource_public_id='10000000-0000-0000-0000-000000000511' AND occurred_at>=$2)`, resourceIDs, testStarted)
|
||||
}()
|
||||
ids := make([]int64, 3)
|
||||
for index, resourceID := range resourceIDs {
|
||||
expiresAt := time.Now().UTC().Add(10 * time.Minute)
|
||||
if index == 2 {
|
||||
expiresAt = time.Now().UTC().Add(-time.Minute)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO web_event(tenant_id,household_id,subject_id,event_type,resource_type,resource_public_id,status,expires_at)
|
||||
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201','10000000-0000-0000-0000-000000000401',
|
||||
'conversation.completed','conversation',$1::uuid,$2,$3) RETURNING id`, resourceID, "TEST_"+string(rune('A'+index)), expiresAt).Scan(&ids[index]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cursor, err := service.StartingCursor(ctx, owner)
|
||||
if err != nil || cursor != ids[1] {
|
||||
t.Fatalf("owner starting cursor=%d want=%d err=%v", cursor, ids[1], err)
|
||||
}
|
||||
outsideCursor, err := service.StartingCursor(ctx, outsider)
|
||||
if err != nil || outsideCursor != 0 {
|
||||
t.Fatalf("outsider starting cursor=%d err=%v", outsideCursor, err)
|
||||
}
|
||||
if err := service.ValidateCursor(ctx, owner, 0); err != nil {
|
||||
t.Fatalf("zero cursor: %v", err)
|
||||
}
|
||||
if err := service.ValidateCursor(ctx, owner, ids[0]); err != nil {
|
||||
t.Fatalf("valid cursor: %v", err)
|
||||
}
|
||||
if err := service.ValidateCursor(ctx, owner, ids[2]); !errors.Is(err, ErrCursorUnavailable) {
|
||||
t.Fatalf("expired cursor error=%v", err)
|
||||
}
|
||||
if err := service.ValidateCursor(ctx, outsider, ids[0]); !errors.Is(err, ErrCursorUnavailable) {
|
||||
t.Fatalf("cross-user cursor error=%v", err)
|
||||
}
|
||||
items, err := service.ListAfter(ctx, owner, ids[0], 1)
|
||||
if err != nil || len(items) != 1 || items[0].ID != ids[1] || items[0].SubjectID != "10000000-0000-0000-0000-000000000411" {
|
||||
t.Fatalf("events=%+v err=%v", items, err)
|
||||
}
|
||||
allItems, err := service.ListAfter(ctx, owner, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := 0
|
||||
for _, item := range allItems {
|
||||
if item.ID == ids[0] || item.ID == ids[1] {
|
||||
found++
|
||||
}
|
||||
}
|
||||
if found != 2 {
|
||||
t.Fatalf("active synthetic events found=%d", found)
|
||||
}
|
||||
outsideItems, err := service.ListAfter(ctx, outsider, 0, 100)
|
||||
if err != nil || len(outsideItems) != 0 {
|
||||
t.Fatalf("outsider events=%+v err=%v", outsideItems, err)
|
||||
}
|
||||
deleted, err := service.DeleteExpired(ctx, 1)
|
||||
if err != nil || deleted != 1 {
|
||||
t.Fatalf("deleted expired events=%d err=%v", deleted, err)
|
||||
}
|
||||
if err := service.ValidateCursor(ctx, owner, ids[2]); !errors.Is(err, ErrCursorUnavailable) {
|
||||
t.Fatalf("deleted cursor error=%v", err)
|
||||
}
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO device_presence_state(tenant_id,device_id,household_id,subject_id,online,last_seen_at,changed_at)
|
||||
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000501','10000000-0000-0000-0000-000000000201',
|
||||
'10000000-0000-0000-0000-000000000401',true,now()-interval '10 minutes',now()-interval '10 minutes')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expired, err := service.ExpireDevicePresence(ctx, time.Now().UTC().Add(-5*time.Minute), 0)
|
||||
if err != nil || expired != 1 {
|
||||
t.Fatalf("expired device presence=%d err=%v", expired, err)
|
||||
}
|
||||
var online bool
|
||||
if err := pool.QueryRow(ctx, `SELECT online FROM device_presence_state WHERE tenant_id='10000000-0000-0000-0000-000000000001' AND device_id='10000000-0000-0000-0000-000000000501'`).Scan(&online); err != nil || online {
|
||||
t.Fatalf("device presence online=%v err=%v", online, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user