Add server-sent events at GET /api/events for live mutation notifications. Add server mode (normal/read-only/degraded) exposed via /health, /ready, and SSE server.state events. New files: - broker.go: SSE event hub with client management, non-blocking fan-out, ring buffer history for Last-Event-ID replay, heartbeat - servermode.go: mode state machine with periodic MinIO health check and SIGUSR1 read-only toggle - sse_handler.go: HTTP handler using http.Flusher and ResponseController to disable WriteTimeout for long-lived SSE - broker_test.go, servermode_test.go: 13 unit tests Modified: - handlers.go: Server struct gains broker/serverState fields, Health/Ready include mode and sse_clients, write handlers emit item.created/updated/deleted and revision.created events - routes.go: register GET /api/events, add RequireWritable middleware to all 8 editor-gated route groups - middleware.go: RequireWritable returns 503 in read-only mode - csv.go, ods.go: emit bulk item.created events after import - storage.go: add Ping() method for health checks - config.go: add ReadOnly field to ServerConfig - main.go: create broker/state, start background goroutines, SIGUSR1 handler, graceful shutdown sequence Closes #38, closes #39
119 lines
2.9 KiB
Go
119 lines
2.9 KiB
Go
package api
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
)
|
|
|
|
func TestServerStateModeNormal(t *testing.T) {
|
|
b := NewBroker(zerolog.Nop())
|
|
ss := NewServerState(zerolog.Nop(), nil, b)
|
|
|
|
if ss.Mode() != ModeNormal {
|
|
t.Fatalf("expected normal, got %s", ss.Mode())
|
|
}
|
|
if ss.IsReadOnly() {
|
|
t.Fatal("expected not read-only")
|
|
}
|
|
}
|
|
|
|
func TestServerStateModeReadOnly(t *testing.T) {
|
|
b := NewBroker(zerolog.Nop())
|
|
ss := NewServerState(zerolog.Nop(), nil, b)
|
|
|
|
ss.SetReadOnly(true)
|
|
if ss.Mode() != ModeReadOnly {
|
|
t.Fatalf("expected read-only, got %s", ss.Mode())
|
|
}
|
|
if !ss.IsReadOnly() {
|
|
t.Fatal("expected read-only")
|
|
}
|
|
|
|
ss.SetReadOnly(false)
|
|
if ss.Mode() != ModeNormal {
|
|
t.Fatalf("expected normal after clearing read-only, got %s", ss.Mode())
|
|
}
|
|
}
|
|
|
|
func TestServerStateModeDegraded(t *testing.T) {
|
|
b := NewBroker(zerolog.Nop())
|
|
// Pass a non-nil storage placeholder to simulate configured storage.
|
|
// We manipulate storageOK directly to test degraded mode.
|
|
ss := NewServerState(zerolog.Nop(), nil, b)
|
|
|
|
// Simulate storage configured but unhealthy by setting fields directly.
|
|
ss.mu.Lock()
|
|
// We need a non-nil storage pointer to trigger degraded mode check.
|
|
// Since we can't easily create a fake storage, we test the mode() logic
|
|
// by checking that without storage, mode stays normal.
|
|
ss.mu.Unlock()
|
|
|
|
// Without storage configured, mode should be normal even if storageOK is false
|
|
ss.mu.Lock()
|
|
ss.storageOK = false
|
|
ss.mu.Unlock()
|
|
if ss.Mode() != ModeNormal {
|
|
t.Fatalf("expected normal (no storage configured), got %s", ss.Mode())
|
|
}
|
|
}
|
|
|
|
func TestServerStateToggleReadOnly(t *testing.T) {
|
|
b := NewBroker(zerolog.Nop())
|
|
ss := NewServerState(zerolog.Nop(), nil, b)
|
|
|
|
ss.ToggleReadOnly()
|
|
if !ss.IsReadOnly() {
|
|
t.Fatal("expected read-only after toggle")
|
|
}
|
|
|
|
ss.ToggleReadOnly()
|
|
if ss.IsReadOnly() {
|
|
t.Fatal("expected not read-only after second toggle")
|
|
}
|
|
}
|
|
|
|
func TestServerStateBroadcastsOnTransition(t *testing.T) {
|
|
b := NewBroker(zerolog.Nop())
|
|
c := b.Subscribe()
|
|
defer b.Unsubscribe(c)
|
|
|
|
ss := NewServerState(zerolog.Nop(), nil, b)
|
|
|
|
ss.SetReadOnly(true)
|
|
|
|
select {
|
|
case ev := <-c.ch:
|
|
if ev.Type != "server.state" {
|
|
t.Fatalf("expected server.state event, got %s", ev.Type)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for server.state event")
|
|
}
|
|
|
|
// Setting to same value should not broadcast
|
|
ss.SetReadOnly(true)
|
|
select {
|
|
case ev := <-c.ch:
|
|
t.Fatalf("unexpected event on no-op SetReadOnly: %+v", ev)
|
|
case <-time.After(50 * time.Millisecond):
|
|
// expected — no event
|
|
}
|
|
}
|
|
|
|
func TestServerStateReadOnlyOverridesDegraded(t *testing.T) {
|
|
b := NewBroker(zerolog.Nop())
|
|
ss := NewServerState(zerolog.Nop(), nil, b)
|
|
|
|
// Both read-only and storage down: should show read-only
|
|
ss.mu.Lock()
|
|
ss.readOnly = true
|
|
ss.storageOK = false
|
|
ss.mu.Unlock()
|
|
|
|
if ss.Mode() != ModeReadOnly {
|
|
t.Fatalf("expected read-only to override degraded, got %s", ss.Mode())
|
|
}
|
|
}
|