package configread import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "reflect" "strings" "testing" ) const ( mockDispatcherID = "c046b893-8628-4589-ae50-619d049248a6" mockTaskID = "task-mock" mockTenantID = "tenant-id-mock" mockTenantKey = "tenant-mock" mockTestSecret = "test-secret" ) func TestClientReadTaskRequestsAndValidatesThreeConfigEndpoints(t *testing.T) { fixtures := validConfigFixtures(t) var got []string mux := http.NewServeMux() for path, body := range fixtures { path, body := path, body mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { got = append(got, r.Method+" "+r.URL.RequestURI()) if r.Method != http.MethodGet { t.Errorf("method = %q, want GET", r.Method) } if r.Header.Get(dispatcherIDHeader) != mockDispatcherID { t.Errorf("dispatcher ID header = %q", r.Header.Get(dispatcherIDHeader)) } if r.Header.Get(dispatcherSecretHeader) != mockTestSecret { t.Errorf("dispatcher secret header = %q", r.Header.Get(dispatcherSecretHeader)) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) }) } server := httptest.NewServer(mux) defer server.Close() client := newMockClient(t, server) snapshot, err := client.ReadTask(context.Background(), mockTaskID, mockTenantID) if err != nil { t.Fatal(err) } if len(snapshot.SIP) == 0 || len(snapshot.Task) == 0 || len(snapshot.TenantQuota) == 0 { t.Fatalf("incomplete config snapshot: %+v", snapshot) } if snapshot.TaskID != mockTaskID || snapshot.TenantID != mockTenantID || snapshot.TenantKey != mockTenantKey { t.Fatalf("snapshot identity = %q/%q/%q", snapshot.TaskID, snapshot.TenantID, snapshot.TenantKey) } want := []string{ "GET /internal/v1/dispatcher/sip", "GET /internal/v1/dispatcher/task/" + mockTaskID, "GET /internal/v1/dispatcher/tenant/" + mockTenantID + "/quota", } if !reflect.DeepEqual(got, want) { t.Fatalf("requests = %#v, want %#v", got, want) } } func TestClientReadTaskRejectsRemovedEgressPoolField(t *testing.T) { fixtures := validConfigFixtures(t) var sip map[string]any if err := json.Unmarshal(fixtures[configReadPath+"/sip"], &sip); err != nil { t.Fatal(err) } sip["trunks"].([]any)[0].(map[string]any)["egress_pool_id"] = "egress-mock" fixtures[configReadPath+"/sip"] = marshalDiscoveryResponse(t, sip) server := configFixtureServer(t, fixtures) defer server.Close() if _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID); err == nil { t.Fatal("removed egress pool field accepted by SIP config") } } func TestClientReadTaskRejectsDuplicateSIPTrunksAndCallers(t *testing.T) { for _, tc := range []struct { name string mutate func(map[string]any) }{ {"duplicate trunk", func(sip map[string]any) { trunks := sip["trunks"].([]any) sip["trunks"] = append(trunks, trunks[0]) }}, {"duplicate caller", func(sip map[string]any) { trunk := sip["trunks"].([]any)[0].(map[string]any) profiles := trunk["caller_profiles"].([]any) trunk["caller_profiles"] = append(profiles, profiles[0]) }}, } { t.Run(tc.name, func(t *testing.T) { fixtures := validConfigFixtures(t) var sip map[string]any if err := json.Unmarshal(fixtures[configReadPath+"/sip"], &sip); err != nil { t.Fatal(err) } tc.mutate(sip) fixtures[configReadPath+"/sip"] = marshalDiscoveryResponse(t, sip) server := configFixtureServer(t, fixtures) defer server.Close() if _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID); err == nil { t.Fatal("duplicate SIP configuration identity accepted") } }) } } func TestClientReadTaskRejectsSchemaInvalidResponse(t *testing.T) { fixtures := validConfigFixtures(t) fixtures[configReadPath+"/sip"] = readConfigFixture(t, "config-read-invalid-extra-property-v0.1.json") server := configFixtureServer(t, fixtures) defer server.Close() if _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID); err == nil { t.Fatal("ReadTask accepted a response rejected by the frozen Schema") } } func TestClientReadTaskRejectsIdentityMismatch(t *testing.T) { cases := []struct { name string path string old string new string }{ {"task id", configReadPath + "/task/" + mockTaskID, `"task_id": "task-mock"`, `"task_id": "task-other"`}, {"quota tenant key", configReadPath + "/tenant/" + mockTenantID + "/quota", `"tenant_key": "tenant-mock"`, `"tenant_key": "tenant-other"`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { fixtures := validConfigFixtures(t) fixtures[tc.path] = replaceFixtureText(t, fixtures[tc.path], tc.old, tc.new) server := configFixtureServer(t, fixtures) defer server.Close() if _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID); err == nil { t.Fatal("ReadTask accepted an identity mismatch") } }) } } func TestClientReadTaskReturnsStructuredHTTPFailure(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusServiceUnavailable) _, _ = fmt.Fprint(w, `{"schema_version":"config-read.v0.1","resource":"error","error":{"code":"service_unavailable","message":"temporarily unavailable"}}`) })) defer server.Close() _, err := newMockClient(t, server).ReadTask(context.Background(), mockTaskID, mockTenantID) var statusErr *HTTPError if !errors.As(err, &statusErr) { t.Fatalf("error = %v, want *HTTPError", err) } if statusErr.StatusCode != http.StatusServiceUnavailable || statusErr.Code != "service_unavailable" { t.Fatalf("HTTP error = %+v", statusErr) } } func marshalDiscoveryResponse(t *testing.T, response map[string]any) []byte { t.Helper() body, err := json.Marshal(response) if err != nil { t.Fatal(err) } return body } func newMockClient(t *testing.T, server *httptest.Server) *Client { t.Helper() client, err := NewClient(server.URL, mockDispatcherID, mockTestSecret, server.Client()) if err != nil { t.Fatal(err) } return client } func validConfigFixtures(t *testing.T) map[string][]byte { t.Helper() return map[string][]byte{ configReadPath + "/sip": readConfigFixture(t, "config-read-sip-v0.3.json"), configReadPath + "/tasks": readConfigFixture(t, "task-discovery-snapshot-v0.2.json"), configReadPath + "/task/" + mockTaskID: readConfigFixture(t, "config-read-task-v0.1.json"), configReadPath + "/tenant/" + mockTenantID + "/quota": readConfigFixture(t, "config-read-tenant-quota-v0.1.json"), } } func configFixtureServer(t *testing.T, fixtures map[string][]byte) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, ok := fixtures[r.URL.Path] if !ok { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) })) } func readConfigFixture(t *testing.T, name string) []byte { t.Helper() body, err := os.ReadFile(filepath.Join("..", "..", "docs", "contracts", "examples", name)) if err != nil { t.Fatal(err) } return body } func replaceFixtureText(t *testing.T, body []byte, old, replacement string) []byte { t.Helper() if !strings.Contains(string(body), old) { t.Fatalf("fixture does not contain %q", old) } return []byte(strings.Replace(string(body), old, replacement, 1)) }