985e51d25e
Tests · Go / test (push) Successful in 1m58s
Turn the console landing page into an operational dashboard. - new internal/opsstatus: read-only Postgres projection via go-jet — ping + per-status COUNT/GROUP BY on runtime_records, mail_deliveries, notification_routes, and a malformed-intent count; degrades per-probe into Snapshot.Errors rather than failing the page - dashboard renders backend readiness, database health, the three status tables, the malformed count, and any collection errors; falls back to a "monitoring not wired" note when no reader is injected - AdminConsoleHandlers now takes an AdminConsoleDeps struct (Monitor + Ready added) so later stages add service refs without churning the signature Tests: opsstatus store test against a Postgres testcontainer (empty schema + one enqueued delivery); dashboard render tests with a fake reader (with and without monitoring). Docs: ARCHITECTURE 14.1 + FUNCTIONAL 10.2.1 (+ru) describe the dashboard. (Prometheus /metrics exporters were already enabled in dev-deploy in Stage 1.)
156 lines
4.3 KiB
Go
156 lines
4.3 KiB
Go
package opsstatus_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/url"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/backend/internal/mail"
|
|
"galaxy/backend/internal/opsstatus"
|
|
backendpg "galaxy/backend/internal/postgres"
|
|
pgshared "galaxy/postgres"
|
|
|
|
"github.com/google/uuid"
|
|
testcontainers "github.com/testcontainers/testcontainers-go"
|
|
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
)
|
|
|
|
const (
|
|
pgImage = "postgres:16-alpine"
|
|
pgUser = "galaxy"
|
|
pgPassword = "galaxy"
|
|
pgDatabase = "galaxy_backend"
|
|
pgSchema = "backend"
|
|
pgStartup = 90 * time.Second
|
|
pgOpTO = 10 * time.Second
|
|
)
|
|
|
|
// startPostgres mirrors the per-package scaffolding used by the other store
|
|
// tests: spin up Postgres, apply migrations, return *sql.DB.
|
|
func startPostgres(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
pgContainer, err := tcpostgres.Run(ctx, pgImage,
|
|
tcpostgres.WithDatabase(pgDatabase),
|
|
tcpostgres.WithUsername(pgUser),
|
|
tcpostgres.WithPassword(pgPassword),
|
|
testcontainers.WithWaitStrategy(
|
|
wait.ForLog("database system is ready to accept connections").
|
|
WithOccurrence(2).
|
|
WithStartupTimeout(pgStartup),
|
|
),
|
|
)
|
|
if err != nil {
|
|
t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
|
|
t.Errorf("terminate postgres container: %v", termErr)
|
|
}
|
|
})
|
|
|
|
baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
|
|
if err != nil {
|
|
t.Fatalf("connection string: %v", err)
|
|
}
|
|
scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
|
|
if err != nil {
|
|
t.Fatalf("scope dsn: %v", err)
|
|
}
|
|
|
|
cfg := pgshared.DefaultConfig()
|
|
cfg.PrimaryDSN = scopedDSN
|
|
cfg.OperationTimeout = pgOpTO
|
|
|
|
db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
|
|
if err != nil {
|
|
t.Fatalf("open primary: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
|
|
t.Fatalf("ping: %v", err)
|
|
}
|
|
if err := backendpg.ApplyMigrations(ctx, db); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func dsnWithSearchPath(baseDSN, schema string) (string, error) {
|
|
parsed, err := url.Parse(baseDSN)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
values := parsed.Query()
|
|
values.Set("search_path", schema)
|
|
if values.Get("sslmode") == "" {
|
|
values.Set("sslmode", "disable")
|
|
}
|
|
parsed.RawQuery = values.Encode()
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func TestStoreCollect(t *testing.T) {
|
|
db := startPostgres(t)
|
|
store := opsstatus.NewStore(db)
|
|
ctx := context.Background()
|
|
|
|
// Empty schema: queries must execute cleanly with zero counts.
|
|
empty := store.Collect(ctx)
|
|
if !empty.PostgresHealthy {
|
|
t.Fatal("PostgresHealthy must be true against a reachable database")
|
|
}
|
|
if len(empty.Errors) != 0 {
|
|
t.Fatalf("unexpected collection errors: %v", empty.Errors)
|
|
}
|
|
if got := totalCount(empty.MailDeliveries); got != 0 {
|
|
t.Fatalf("mail deliveries total = %d, want 0", got)
|
|
}
|
|
if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 {
|
|
t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes)
|
|
}
|
|
if empty.NotificationMalformed != 0 {
|
|
t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed)
|
|
}
|
|
|
|
// Enqueue one mail delivery and confirm the GROUP BY count reflects it.
|
|
mailStore := mail.NewStore(db)
|
|
inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{
|
|
DeliveryID: uuid.New(),
|
|
TemplateID: mail.TemplateLoginCode,
|
|
IdempotencyKey: uuid.NewString(),
|
|
Recipients: []string{"ops@example.test"},
|
|
ContentType: "text/plain",
|
|
Subject: "hello",
|
|
Body: []byte("hi"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("insert mail delivery: %v", err)
|
|
}
|
|
if !inserted {
|
|
t.Fatal("expected the delivery to be inserted")
|
|
}
|
|
|
|
after := store.Collect(ctx)
|
|
if len(after.Errors) != 0 {
|
|
t.Fatalf("unexpected collection errors after insert: %v", after.Errors)
|
|
}
|
|
if got := totalCount(after.MailDeliveries); got != 1 {
|
|
t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries)
|
|
}
|
|
}
|
|
|
|
func totalCount(counts []opsstatus.StatusCount) int64 {
|
|
var total int64
|
|
for _, c := range counts {
|
|
total += c.Count
|
|
}
|
|
return total
|
|
}
|