166 lines
5.7 KiB
Go
166 lines
5.7 KiB
Go
package dispatcher
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git.ipao.vip/rogee/go-sip/internal/configread"
|
|
"git.ipao.vip/rogee/go-sip/internal/store"
|
|
)
|
|
|
|
func currentConfigExample(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", name+".json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func TestCurrentBootstrapRequiresSIPLoadingAndControlDrain(t *testing.T) {
|
|
id := "c046b893-8628-4589-ae50-619d049248a6"
|
|
called := make([]string, 0)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = append(called, r.URL.Path)
|
|
var response []byte
|
|
switch r.URL.Path {
|
|
case "/internal/v1/dispatcher/sip":
|
|
response = currentConfigExample(t, "config-read-sip")
|
|
case "/internal/v1/dispatcher/tasks":
|
|
if r.URL.Query().Get("after") == "" {
|
|
response = currentConfigExample(t, "task-discovery-page")
|
|
} else {
|
|
response = currentConfigExample(t, "task-discovery-end")
|
|
}
|
|
case "/internal/v1/dispatcher/ai-providers":
|
|
response = currentConfigExample(t, "config-read-providers")
|
|
case "/internal/v1/dispatcher/task/task-asr":
|
|
response = currentConfigExample(t, "config-read-task-asr")
|
|
case "/internal/v1/dispatcher/tenant/1001/quota":
|
|
response = currentConfigExample(t, "config-read-quota")
|
|
default:
|
|
t.Errorf("unapproved config path %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(response)
|
|
}))
|
|
defer server.Close()
|
|
client, err := configread.NewClient(server.URL, id, "test-secret", server.Client())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := store.OpenCurrent(filepath.Join(t.TempDir(), "current.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
verifierCalled, drained := false, false
|
|
var cursor string
|
|
var approvedSIP configread.CurrentSIP
|
|
bootstrap := CurrentBootstrap{
|
|
Client: client, Store: db, DispatcherID: id, Cursor: &cursor, SIP: &approvedSIP,
|
|
VerifySIP: func(_ context.Context, sip configread.CurrentSIP) error {
|
|
verifierCalled = true
|
|
if sip.Revision != 8 {
|
|
t.Fatalf("verified wrong SIP revision %d", sip.Revision)
|
|
}
|
|
return nil
|
|
},
|
|
DrainControls: func(context.Context) error {
|
|
drained = true
|
|
if admitted, err := db.CanAdmit(id, 1001, "task-asr"); err != nil || admitted {
|
|
t.Fatalf("opened before draining control queue: admitted=%v, err=%v", admitted, err)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
if err := bootstrap.Run(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
admitted, err := db.CanAdmit(id, 1001, "task-asr")
|
|
if err != nil || !admitted || !verifierCalled || !drained || cursor != "opaque-end-token" || approvedSIP.Revision != 8 || len(called) < 6 || called[0] != "/internal/v1/dispatcher/sip" {
|
|
t.Fatalf("bootstrap: admitted=%v, err=%v, verified=%v, drained=%v, paths=%v", admitted, err, verifierCalled, drained, called)
|
|
}
|
|
if err := db.NoteSIPChange(id, 9); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := bootstrap.Run(context.Background()); !errors.Is(err, store.ErrCurrentSIPPending) {
|
|
t.Fatalf("startup lost durable newer SIP notification: %v", err)
|
|
}
|
|
if cursor != "opaque-end-token" || approvedSIP.Revision != 8 {
|
|
t.Fatalf("pending SIP startup lost verified snapshot/cursor: revision=%d cursor=%q", approvedSIP.Revision, cursor)
|
|
}
|
|
if admitted, err := db.CanAdmit(id, 1001, "task-asr"); err != nil || admitted {
|
|
t.Fatalf("old SIP opened during pending notification: %v %v", admitted, err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentBootstrapFailsClosedOnVerifierOrDrainError(t *testing.T) {
|
|
id := "c046b893-8628-4589-ae50-619d049248a6"
|
|
for _, failure := range []string{"verify", "drain"} {
|
|
t.Run(failure, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var response []byte
|
|
switch r.URL.Path {
|
|
case "/internal/v1/dispatcher/sip":
|
|
response = currentConfigExample(t, "config-read-sip")
|
|
case "/internal/v1/dispatcher/tasks":
|
|
if r.URL.Query().Get("after") == "" {
|
|
response = currentConfigExample(t, "task-discovery-page")
|
|
} else {
|
|
response = currentConfigExample(t, "task-discovery-end")
|
|
}
|
|
case "/internal/v1/dispatcher/ai-providers":
|
|
response = currentConfigExample(t, "config-read-providers")
|
|
case "/internal/v1/dispatcher/task/task-asr":
|
|
response = currentConfigExample(t, "config-read-task-asr")
|
|
case "/internal/v1/dispatcher/tenant/1001/quota":
|
|
response = currentConfigExample(t, "config-read-quota")
|
|
default:
|
|
t.Fatalf("unexpected path %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write(response)
|
|
}))
|
|
defer server.Close()
|
|
client, err := configread.NewClient(server.URL, id, "test-secret", server.Client())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
db, err := store.OpenCurrent(filepath.Join(t.TempDir(), "current.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
b := CurrentBootstrap{
|
|
Client: client, Store: db, DispatcherID: id,
|
|
VerifySIP: func(context.Context, configread.CurrentSIP) error {
|
|
if failure == "verify" {
|
|
return errors.New("applied SIP revision unavailable")
|
|
}
|
|
return nil
|
|
},
|
|
DrainControls: func(context.Context) error {
|
|
if failure == "drain" {
|
|
return errors.New("control queue not drained")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
if err := b.Run(context.Background()); err == nil {
|
|
t.Fatal("bootstrapped without required SIP/control gate")
|
|
}
|
|
if admitted, err := db.CanAdmit(id, 1001, "task-asr"); err != nil || admitted {
|
|
t.Fatalf("opened admission after failed bootstrap: admitted=%v err=%v", admitted, err)
|
|
}
|
|
})
|
|
}
|
|
}
|