# Transfer-ownership integration tests (ows-playlist)

End-to-end check that the four ows-playlist endpoints made transfer-aware by
the PORT-5 work scope a product's playlist streams **correctly in time**
when that product has changed hands between accounts.

Mirrors `ows-analytics/tests/integration/transfer_ownership/`, but for the
four ows-playlist endpoints. Same shape, same profile pairing, narrower
scope (no `/account/<id>/*`, no per-product summary cross-checks).

---

## Goal

- **Scope** — Verify the four ows-playlist endpoints time-slice product
  `5244974`'s *playlist* streams when ownership has transferred between
  accounts.
- **Trigger** — Only meaningful with `insights_transfer_product_ownership`
  ON, which routes the DAILY endpoints through the v2 EXISTS-on-POA
  permissions filter and the ROLLUP endpoints through the
  `*_PRODUCT_TRANSFER` rollup tables.
- **Fixture** — Product `5244974` (*Travessia*, UPC `198000137366`),
  transferred 2026-04-30 from vendor `81790` (Alá Comunicação) to vendor
  `797716` (Sued Nunes Produções). One anchor ISRC (`BX69Y2100018`), one
  canonical placement (`mix` on Spotify, store 286).

### Four laws being asserted (where reachable)

1. **No forward leak** — a former owner never sees streams dated *after*
   their `access_until_date`.
2. **No fanout** — the v2 `EXISTS … POA` predicate must not multiply a
   viewer's totals by the number of past owners.
3. **Cumulative history preserved** — each owner sees `[beginning-of-time,
   their cutoff]`; windows are **nested**.
4. **Grain consistency** — the DAILY-backed endpoints (`placement/<isrc>/streams`,
   `total_vs_playlist_streams_by_store`) and the ROLLUP-backed endpoints
   (`top-placements`, `analytics-bulk`) agree on who sees what.

Laws 1–3 are exercised end-to-end by the DAILY precise probes. The ROLLUP
endpoints assert a **weaker** law — *metric-neutrality of the table swap*
— because their SQL has either no per-viewer filter at all (`analytics-bulk`)
or the legacy `permission_checks` macro for placement-row gating
(`top-placements`); the v2 permission migration there is a separate scope.

---

## 1. Endpoints covered

| Endpoint | Method | Grain | Verification mode |
|----------|--------|-------|-------------------|
| `/playlist/<sp>/placement/<isrc>/streams` | GET | DAILY (v2 macro) | precise time-slice |
| `/placements/total_vs_playlist_streams_by_store` | GET | DAILY (v2 macro, two CTEs) | precise time-slice |
| `/sound-recording/<isrc>/top-placements` | GET | ROLLUP (table swap) | FF-pair metric-neutral |
| `POST /playlist/analytics-bulk` | POST | ROLLUP (table swap) | FF-pair metric-neutral |

---

## 2. The feature flag

`insights_transfer_product_ownership` (`playlist/queries/constants.py` →
`INSIGHTS_TRANSFER_PRODUCT_OWNERSHIP`).

- **OFF** (production default): legacy filter — the DAILY endpoints gate via
  the `dim_track_clean_mv → dim_release` subquery; the ROLLUP endpoints
  read the legacy `STREAMS_BY_PLAYLIST*_ROLLUP` tables.
- **ON**: DAILY endpoints route through the v2 `EXISTS … POA` filter;
  ROLLUP endpoints swap to the `*_PRODUCT_TRANSFER` variants.

The 3 profiles in §4 each have an FF-ON and an FF-OFF id; the flag
assignment is global to InsightsProfile, so the same ids that work in
ows-analytics QA work in ows-playlist QA.

---

## 3. The transfer scenario

A single transfer of product `5244974` (*Travessia*), originally delivered
by Alá Comunicação and reassigned to Sued Nunes Produções on 2026-04-30.

| Field | Value |
|-------|-------|
| Product id | `5244974` |
| Product name | *Travessia* |
| UPC / `display_upc` | `198000137366` |
| `dim_release.artistid` | `3125999` |
| Global participant id | `611a71d6-b9a9-4598-a83f-6aed1bf73904` |
| ISRCs (13) | `BX69Y2100012` … `BX69Y2100024` |
| **Canonical ISRC** | `BX69Y2100018` — heaviest under product 5244974 (72,265 streams across 576 days on the canonical playlist) |
| **Canonical playlist** | `("mix", 286)` — Spotify algorithmic |

### Ownership timeline — `FACTS.QA.PRODUCT_OWNERSHIP_ACCESS`

```
 product_id | label_id | subaccount_id | access_until_date | is_current
------------+----------+---------------+-------------------+-----------
   5244974  |   81790  |     NULL      |    2026-04-30     |   FALSE
   5244974  |  797716  |     NULL      |      NULL         |   TRUE
```

```
   ... 2026-04-30 │ 2026-05-01 ... now
 ──────────────────────────────────────►
 │ W1             │ W2
 │ Alá Comunicação│ Sued Nunes Produções  (CURRENT)
 │ vendor 81790   │ vendor 797716
```

### Canonical playlist coverage (data probe, 2026-05-27)

BX69Y2100018's streams on `mix` (store 286) across the probe windows:

| Probe | Window | Streams |
|-------|--------|---------|
| `early` | W1 (2024-10-01 → 2026-04-30) | 69,549 |
| `late` | W2 (2026-05-01 → 2026-05-26) | 2,716 |

All 13 product-5244974 ISRCs have at least some W1 coverage on (mix, 286),
so the W1 sweep is a true 13-element axis.

---

## 4. Profiles under test

Each profile is a **pair** of `InsightsProfile` ids with *identical* account
access — one with `insights_transfer_product_ownership` assigned in QA
Permissions (the v2 path), one without it (the legacy, production path):

| Key | FF-ON id | FF-OFF id | Account scope | Role in scenario |
|-----|----------|-----------|---------------|------------------|
| `employee` | `474215` | `1100` | full access | sees everything |
| `ala_comunicacao` | `9900038` | `9900039` | vendor `81790` | **former** owner (W1) |
| `sued_nunes` | `9900042` | `9900043` | vendor `797716` | **current** owner (W1+W2) |

`conftest.TRANSFER_PROFILES` is the FF-ON-only axis used by the DAILY
precise probes; `TRANSFER_PROFILE_PAIRS` is the paired axis used by the
ROLLUP metric-neutral tests.

---

## 5. How permission-scoping works

### DAILY — `permissions_filter.sql`

Used by `/playlist/<sp>/placement/<isrc>/streams` and
`/placements/total_vs_playlist_streams_by_store`. A former owner is gated to
dates through and including their cutoff:

```sql
poa.access_until_date IS NULL              -- current owner: every date
OR s.download_activity_date <= poa.access_until_date   -- former owner
```

### ROLLUP — `*_PRODUCT_TRANSFER` Pattern B drop-in

The four playlist-keyed `*_PRODUCT_TRANSFER` views (built by dbt) are
*Pattern B drop-in replacements*: they `INNER JOIN POA … is_current = TRUE`
upstream and re-aggregate to playlist grain. The output shape is identical
to the regular `_ROLLUP` tables — only the per-product time-slicing is
correct after a transfer.

Because the two tables share the same shape and (for stable upstream data)
the same totals, every endpoint that *only* swaps the table is **metric
neutral**: every viewer must get an equal response with the flag on and
off. That is the law the ROLLUP tests assert.

---

## 6. Visibility matrix (DAILY precise probes)

Each owner sees `[beginning-of-time, their cutoff]`. Windows are **nested**,
not disjoint. The matrix is *viewer-scoped* (ows-playlist has no
account-scoped `/account/<id>/*` analogue).

| Profile | `early` (W1) | `late` (W2) |
|---------|:---:|:---:|
| `employee` | data | data |
| `sued_nunes` (current) | data | data |
| `ala_comunicacao` (former) | data | **empty** |

---

## 7. Boundary handling — `access_until_date` is inclusive

`PRODUCT_OWNERSHIP_ACCESS.access_until_date` is the **last day** of a former
owner's tenure, inclusive. Both the DAILY v2 macro and the dbt
`transfer_time_slice` use `<=`:

```sql
download_activity_date <= access_until_date
```

A regression to a strict `<` would silently drop a former owner's last day
of streams. Boundary-day visibility is not probed integration-style here
(ows-playlist data on the boundary days is too thin to differentiate
inclusivity from exclusivity); the unit-test guard
`test_v2_macro_alias_qualifies_product_id` covers the macro-level shape
regressions.

---

## 8. Test files

| File | Covers |
|------|--------|
| `conftest.py` | Profiles + FF-OFF twins, `ProfilePair`/`PROFILE_PAIRS`, product/ISRC constants, canonical playlist, owner timeline, probes, visibility matrix, `assert_visibility` / `assert_ff_pair_equal` / `assert_ff_monotonic` / `assert_ff_noop` helpers |
| `test_placement_streams.py` | `/playlist/<sp>/placement/<isrc>/streams` — DAILY precise time-slice (canonical playlist) + W1 sweep across all 13 `PRODUCT_ISRCS` |
| `test_total_vs_playlist.py` | `/placements/total_vs_playlist_streams_by_store` — DAILY precise time-slice on the `isrc=` selector; asserts on `all_stores_aggregation.timeseries` and per-store sub-series |
| `test_top_placements.py` | `/sound-recording/<isrc>/top-placements` — ROLLUP FF-pair metric-neutral on the table swap |
| `test_analytics_bulk.py` | `POST /playlist/analytics-bulk` — ROLLUP FF-pair metric-neutral on the table swap for the canonical playlist |

---

## 9. Running the suite

Prerequisites:

- Both ids of each profile pair in §4 must exist in QA Permissions with the
  flag assignment matching the table.
- QA Snowflake (`FACTS.QA`) must hold `PRODUCT_OWNERSHIP_ACCESS` and the
  `*_PRODUCT_TRANSFER` rollup variants.

```bash
QA_BASE_URL=https://qa-ows-playlist.theorchard.io \
  tests/integration/env/bin/py.test tests/integration/transfer_ownership/ -v

# …or via the default Make target (auto-picks up the new directory).
make test_integration
```
