package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "evobgp/internal/jobs" "evobgp/internal/store" ) func TestJobReportDurableProgressAndTenantScope(t *testing.T) { ctx := context.Background() m := store.NewMemory() m.SeedDemo() tenant, _, _, revision, speaker := m.DemoIDs() j, _, err := m.EnqueueJob(ctx, store.JobEnqueue{TenantID: tenant, Kind: jobs.KindDeployApply}) if err != nil { t.Fatal(err) } claimed, err := m.ClaimJobs(ctx, "worker", 1, time.Minute) if err != nil || len(claimed) != 1 { t.Fatalf("claim: %v %v", claimed, err) } f := store.JobFence{JobID: j.ID, TenantID: tenant, OwnerID: "worker", Token: claimed[0].FencingToken} d, err := m.BeginDeploymentSpeaker(ctx, f, speaker, revision) if err != nil { t.Fatal(err) } if err := m.RecordDeploymentSpeaker(ctx, f, d, "unknown", "unconfirmed", "response lost"); err != nil { t.Fatal(err) } // A fresh registry has no in-process job or progress metadata. r := jobs.NewRegistry(nil) r.UseDurableQueue(m) s := &Server{store: m, jobs: r} request := func(tenantID, jobID string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, "/v1/jobs/"+jobID+"/report", nil) req.SetPathValue("job_id", jobID) req = req.WithContext(context.WithValue(req.Context(), authCtxKey, Auth{TenantID: tenantID, Role: "operator"})) w := httptest.NewRecorder() s.handleGetJobReport(w, req) return w } check := func(id string) { t.Helper() w := request(tenant, id) if w.Code != http.StatusOK { t.Fatalf("report: %d %s", w.Code, w.Body.String()) } var body struct { Progress []struct { State, Confirmation string JobID string `json:"job_id"` } `json:"deployment_progress"` } if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatal(err) } if len(body.Progress) != 1 || body.Progress[0].JobID != j.ID || body.Progress[0].State != "unknown" || body.Progress[0].Confirmation != "unconfirmed" { t.Fatalf("lost persisted progress: %s", w.Body.String()) } } check(j.ID) recovery, _, err := r.EnqueueContext(ctx, tenant, jobs.KindDeployApply, nil, nil, map[string]any{"source_deployment_job_id": j.ID}) if err != nil { t.Fatal(err) } check(recovery.ID) // A later explicit completion releases the delivery reservation. Once recovery // has its own persisted row, its report must not show the parent's old state. if err := m.RecordDeploymentSpeaker(ctx, f, d, "unconfirmed", "unconfirmed", ""); err != nil { t.Fatal(err) } recoveryRows, err := m.ClaimJobs(ctx, "recovery-worker", 1, time.Minute) if err != nil || len(recoveryRows) != 1 || recoveryRows[0].ID != recovery.ID { t.Fatalf("recovery claim: %v %v", recoveryRows, err) } rf := store.JobFence{JobID: recovery.ID, TenantID: tenant, OwnerID: "recovery-worker", Token: recoveryRows[0].FencingToken} rd, err := m.BeginDeploymentSpeaker(ctx, rf, speaker, revision) if err != nil { t.Fatal(err) } if err := m.RecordDeploymentSpeaker(ctx, rf, rd, "applied", "agent_reported", ""); err != nil { t.Fatal(err) } w := request(tenant, recovery.ID) var report struct { Progress []struct { State string JobID string `json:"job_id"` } `json:"deployment_progress"` } if err := json.Unmarshal(w.Body.Bytes(), &report); err != nil { t.Fatal(err) } if w.Code != http.StatusOK || len(report.Progress) != 1 || report.Progress[0].JobID != recovery.ID || report.Progress[0].State != "applied" { t.Fatalf("recovery own progress hidden: %d %s", w.Code, w.Body.String()) } if w := request("other-tenant", j.ID); w.Code != http.StatusNotFound { t.Fatalf("cross-tenant report: %d", w.Code) } foreign, _, err := r.EnqueueContext(ctx, "other-tenant", jobs.KindDeployApply, nil, nil, map[string]any{"source_deployment_job_id": j.ID}) if err != nil { t.Fatal(err) } if w := request("other-tenant", foreign.ID); w.Code != http.StatusNotFound { t.Fatalf("cross-tenant source: %d %s", w.Code, w.Body.String()) } }