diff --git a/apps/server/db/queries/aggregates.sql b/apps/server/db/queries/aggregates.sql index 450aeb0..b0899c7 100644 --- a/apps/server/db/queries/aggregates.sql +++ b/apps/server/db/queries/aggregates.sql @@ -311,7 +311,10 @@ JOIN captures c ON c.step_id = s.id AND c.edition_id = $2 LEFT JOIN LATERAL ( SELECT ref.blob_hash FROM capture_references ref WHERE ref.case_id = s.case_id AND ref.step_id = s.id AND ref.variant_id = c.variant_id - AND ref.environment_id = c.provenance->>'environmentId' + -- A push without provenance lives in the empty environment: intake + -- keys its comparison on '' and the read must too — a NULL here + -- matched nothing and left measured captures blind (#230). + AND ref.environment_id = coalesce(c.provenance->>'environmentId', '') ORDER BY ref.approved_at DESC LIMIT 1 ) r ON true WHERE s.case_id = $1; diff --git a/apps/server/internal/adapters/postgres/movement_test.go b/apps/server/internal/adapters/postgres/movement_test.go index 09e7980..753f6a9 100644 --- a/apps/server/internal/adapters/postgres/movement_test.go +++ b/apps/server/internal/adapters/postgres/movement_test.go @@ -441,3 +441,46 @@ func TestAMissingRecordingIsNamedInTheFirstRefusal(t *testing.T) { t.Errorf("hashes = %v, want the capture and the recording together", missing.Hashes) } } + +// A pusher that never says where its captures come from still gets the moved +// mark (#230). Observed on production: repushed captures carried movedPixels +// in the hundreds of thousands and read accepted — intake compared with the +// empty environment while the read derivation compared with SQL NULL, which +// matches nothing. +func TestAPushWithoutProvenanceStillTurnsMoved(t *testing.T) { + ctx, repo, blobs, project, kase := freshnessFixture(t) + bare := func(hash string) error { + svc := intake.New(repo, blobs) + _, err := svc.Take(ctx, project.Slug, contract.Manifest{ + Cases: []contract.ManifestCase{{ + ID: kase.ID, + Steps: []contract.ManifestStep{{ + Name: "opens", + Captures: []contract.ManifestCapture{{ + Variant: map[string]string{"theme": "light"}, + Hash: hash, + }}, + }}, + }}, + }) + return err + } + + if err := bare(screen(t, ctx, repo, blobs, 10, 0)); err != nil { + t.Fatalf("first edition: %v", err) + } + validateOnly(t, ctx, repo, project.Slug, kase.ID) + + // The dev repushes moved pixels, still without provenance. + if err := bare(screen(t, ctx, repo, blobs, 10, 40)); err != nil { + t.Fatalf("second edition: %v", err) + } + + status, pixels := statusOfFirst(t, ctx, repo, project.Slug, kase.ID) + if pixels == nil { + t.Fatal("movedPixels = nil, want the intake measurement recorded") + } + if status != "moved" { + t.Errorf("status = %q with %d moved pixels, want moved", status, *pixels) + } +} diff --git a/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go b/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go index 25e35c2..bf2bb3e 100644 --- a/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go +++ b/apps/server/internal/adapters/postgres/sqlcgen/aggregates.sql.go @@ -80,7 +80,10 @@ JOIN captures c ON c.step_id = s.id AND c.edition_id = $2 LEFT JOIN LATERAL ( SELECT ref.blob_hash FROM capture_references ref WHERE ref.case_id = s.case_id AND ref.step_id = s.id AND ref.variant_id = c.variant_id - AND ref.environment_id = c.provenance->>'environmentId' + -- A push without provenance lives in the empty environment: intake + -- keys its comparison on '' and the read must too — a NULL here + -- matched nothing and left measured captures blind (#230). + AND ref.environment_id = coalesce(c.provenance->>'environmentId', '') ORDER BY ref.approved_at DESC LIMIT 1 ) r ON true WHERE s.case_id = $1