package api import ( "bytes" "encoding/json" "testing" "time" rstore "gitea.hansenits.com.au/hits/ExploreDNS/internal/receiver/store" ) // These tests pin the webhook payload contract between this package (the // sender) and internal/receiver/store (the receiver): every field a sender // struct marshals must decode into the receiver struct and vice versa. // DisallowUnknownFields turns any renamed or missing field into a failure. // decodeStrict unmarshals data into v, failing on unknown fields. func decodeStrict(t *testing.T, data []byte, v any) { t.Helper() dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { t.Fatalf("decode %s into %T: %v", data, v, err) } } func TestWebhookStartEventReceiverCompat(t *testing.T) { sent := webhookStartEvent{ Event: webhookEventStart, ID: "job-1", Domain: "example.com", QueryType: "AAAA", AllRoots: true, ClientIP: "203.0.113.9", StartedAt: time.Date(2026, 7, 1, 10, 0, 0, 123456789, time.UTC), } raw, err := json.Marshal(sent) if err != nil { t.Fatalf("marshal sender: %v", err) } var got rstore.StartEvent decodeStrict(t, raw, &got) want := rstore.StartEvent{ Event: sent.Event, ID: sent.ID, Domain: sent.Domain, QueryType: sent.QueryType, AllRoots: sent.AllRoots, ClientIP: sent.ClientIP, StartedAt: sent.StartedAt, } if got != want { t.Errorf("receiver decoded %+v, want %+v", got, want) } // Round-trip back into the sender struct so receiver-only fields // would also fail. back, err := json.Marshal(got) if err != nil { t.Fatalf("marshal receiver: %v", err) } var sent2 webhookStartEvent decodeStrict(t, back, &sent2) if sent2 != sent { t.Errorf("sender round-trip %+v, want %+v", sent2, sent) } } func TestWebhookCompleteEventReceiverCompat(t *testing.T) { sent := webhookCompleteEvent{ Event: webhookEventComplete, ID: "job-1", Domain: "example.com", QueryType: "A", ClientIP: "203.0.113.9", StartedAt: time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC), DoneAt: time.Date(2026, 7, 1, 10, 0, 3, 500000000, time.UTC), DurationMS: 3500, Status: statusError, Error: "traversal timed out after 5m0s", ResultCount: 7, Summary: &Summary{ Answers: []SummaryAnswer{{Probability: 0.75, Records: []string{"example.com 300 IN A 192.0.2.1"}}}, ByStatus: []SummaryStatus{{Status: "servfail", Probability: 0.25}}, }, } raw, err := json.Marshal(sent) if err != nil { t.Fatalf("marshal sender: %v", err) } var got rstore.CompleteEvent decodeStrict(t, raw, &got) if got.Event != sent.Event || got.ID != sent.ID || got.Domain != sent.Domain || got.QueryType != sent.QueryType || got.ClientIP != sent.ClientIP || !got.StartedAt.Equal(sent.StartedAt) || !got.DoneAt.Equal(sent.DoneAt) || got.DurationMS != sent.DurationMS || got.Status != sent.Status || got.Error != sent.Error || got.ResultCount != sent.ResultCount { t.Errorf("receiver decoded %+v, want %+v", got, sent) } // The receiver keeps Summary as raw JSON; it must match the sender's // marshalled Summary byte for byte. wantSummary, err := json.Marshal(sent.Summary) if err != nil { t.Fatalf("marshal summary: %v", err) } if !bytes.Equal(got.Summary, wantSummary) { t.Errorf("receiver Summary = %s, want %s", got.Summary, wantSummary) } back, err := json.Marshal(got) if err != nil { t.Fatalf("marshal receiver: %v", err) } var sent2 webhookCompleteEvent decodeStrict(t, back, &sent2) back2, err := json.Marshal(sent2) if err != nil { t.Fatalf("re-marshal sender: %v", err) } if !bytes.Equal(back2, raw) { t.Errorf("sender round-trip = %s, want %s", back2, raw) } }