diff --git a/server/app/goauto/device/android_client_report_contract_test.go b/server/app/goauto/device/android_client_report_contract_test.go new file mode 100644 index 0000000..2902adc --- /dev/null +++ b/server/app/goauto/device/android_client_report_contract_test.go @@ -0,0 +1,89 @@ +package device + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Feed reports captured by Android's real HTTP client into the real Server +// handler. This optional cross-build check uses synthetic test data only. +func TestAndroidGeneratedHeartbeatReports(t *testing.T) { + path := os.Getenv("GOAUTO_378_ANDROID_REPORTS") + if path == "" { + t.Skip("set GOAUTO_378_ANDROID_REPORTS to the Android test-generated JSON array") + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var reports []json.RawMessage + if err := json.Unmarshal(data, &reports); err != nil { + t.Fatal("Android report fixture is not a JSON array") + } + if len(reports) < 3 { + t.Fatal("expected zero-failure, failed and interrupted reports") + } + db := openTestDatabase(t) + service := newTestService(t, db) + _, registered := registerHeartbeatDevice(t, service) + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/heartbeat", Handler{DB: db}.Heartbeat) + var sawZero, sawFailure, sawInterrupted bool + for i, raw := range reports { + t.Run(fmt.Sprintf("android_report_%d", i), func(t *testing.T) { + canonical, status := validateHeartbeatClientReport(raw) + if status != "accepted" || canonical == nil { + t.Fatalf("Android-generated report rejected: classification=%s", status) + } + var report heartbeatClientReport + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatal("report decode failed") + } + sawZero = sawZero || report.FailedRounds == 0 + sawFailure = sawFailure || report.FailedRounds > 0 + sawInterrupted = sawInterrupted || report.HasInterruptedRound + id := uuid.NewString() + body := fmt.Sprintf(`{"requestId":%q,"currentTaskId":null,"clientReport":%s}`, id, raw) + for replay := 0; replay < 2; replay++ { + req := httptest.NewRequest(http.MethodPost, "/heartbeat", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+testDeviceToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + if res.Code != http.StatusOK { + t.Fatalf("heartbeat status=%d", res.Code) + } + var response struct { + Data struct { + AcceptsClientReport bool `json:"acceptsClientReport"` + } `json:"data"` + } + if json.Unmarshal(res.Body.Bytes(), &response) != nil || !response.Data.AcceptsClientReport { + t.Fatal("successful response did not advertise report capability") + } + } + var rows []struct { + DeviceID uint64 + ClientReportStatus string + ClientReportJSON *string + } + if err := db.Table("agent_heartbeat_log").Where("request_id = ?", id).Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].DeviceID != registered.DeviceID || rows[0].ClientReportStatus != "accepted" || rows[0].ClientReportJSON == nil || *rows[0].ClientReportJSON != *canonical { + t.Fatal("accepted Android report was not stored once with the correct device and canonical payload") + } + }) + } + if !sawZero || !sawFailure || !sawInterrupted { + t.Fatal("fixture must cover zero-failure, failed and interrupted reports") + } +}