# Manual Testing Plan — python-playlist-sync

Comprehensive plan for manual verification of all features. Uses the **E2E Docker stack** (`docker-compose.test.yml`) unless noted otherwise.

## Prerequisites

```sh
# Start the full E2E stack (MySQL pre-seeded, port 8001)
docker compose -f docker-compose.test.yml up -d --build

# Set base URL and API key for curl commands
export BASE=http://localhost:8001
export AUTH="FiltrAuthentication: <your-api-key>"
# If FILTR_API_KEY is unset/empty in the stack, omit -H "$AUTH" from all commands.
```

Wait for `GET $BASE/health` to return `200` before proceeding.

---

## 1 · Health Check

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 1.1 | Healthy response | `curl -s $BASE/health` | `200`, body `{"status":"healthy","database":"ok","redis":"ok"}` |
| 1.2 | No auth required | `curl -s -o /dev/null -w '%{http_code}' $BASE/health` (no FiltrAuthentication header) | `200` |
| 1.3 | Degraded — DB down | Stop the MySQL container, then `curl -s $BASE/health` | `503`, `"database":"error: ..."` |
| 1.4 | Degraded — Redis down | Stop the Redis container, then `curl -s $BASE/health` | `503`, `"redis":"error: ..."` |

---

## 2 · Authentication

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 2.1 | Valid key | `curl -s -H "$AUTH" $BASE/playlistsync/playlists` | `200` |
| 2.2 | Missing header | `curl -s -o /dev/null -w '%{http_code}' $BASE/playlistsync/playlists` (no header) | `401`, `WWW-Authenticate: ApiKey` |
| 2.3 | Wrong key | `curl -s -H "FiltrAuthentication: wrong" $BASE/playlistsync/playlists` | `401` |
| 2.4 | Auth disabled | Redeploy stack with `FILTR_API_KEY=""`, any request without header | `200` (no-op auth) |

---

## 3 · Middleware — Case-Insensitive Routing

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 3.1 | Lowercase path | `GET $BASE/playlistsync/playlists` | `200` |
| 3.2 | Mixed case path | `GET $BASE/PlaylistSync/Playlists` | `200` (same response) |
| 3.3 | Uppercase path | `GET $BASE/PLAYLISTSYNC/PLAYLISTS` | `200` (same response) |
| 3.4 | Health mixed case | `GET $BASE/HEALTH` | `200` |

---

## 4 · App Markets

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 4.1 | List all markets | `curl -s -H "$AUTH" $BASE/apollo-api/app-markets/` | `200`, array of `AppMarketResponse` objects |
| 4.2 | Response shape | Inspect first item | Fields: `id`, `name`, `cultureInfo`, `spotifyRegionCode`, `active`, `gaCountryName`, `defaultService`, `services` (array), `workoutMarket` (bool), `includeOtherPlaylists` (bool) |
| 4.3 | Services field parsing | Find a market with `strServiceList` populated | `services` is an array (e.g., `["Spotify","Deezer"]`), not a raw string |
| 4.4 | Null services | Find a market with `strServiceList` = NULL | `services` is `[]` (empty array) |
| 4.5 | BIT(1) booleans | Check `workoutMarket` and `includeOtherPlaylists` | Proper `true`/`false`, not `1`/`0` or `"b'\\x01'"` |

---

## 5 · Playlist Sync — Read Operations

### 5.1 List All Syncs

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 5.1.1 | List all | `GET $BASE/playlistsync/playlists` | `200`, array of `PlaylistSyncResponse` |
| 5.1.2 | Includes inactive | Check for items with `"active": false` | Both active and inactive returned |
| 5.1.3 | Enriched with log data | Inspect item with at least one sync run | `time`, `madeChange`, `addedTracks`, `deletedTracks`, `deletedDuplicates`, `syncedTrackCount`, `triggeredManually` present |
| 5.1.4 | Log fields null before first sync | Find a sync that has never been executed | Log fields are `null` |
| 5.1.5 | `synchronizedTrackCount` default | Check items with NULL DB value | Should be `0`, not `null` |
| 5.1.6 | Datetime format | Check `createdAt` and `lastUpdated` | ISO 8601 with `Z` suffix (e.g., `"2015-09-08T00:00:00Z"`) |
| 5.1.7 | `toServiceType` populated | Check the field | Populated from service account (0–4) |
| 5.1.8 | `insertMedia` present | Check items with insert media rows | Array of `{"mediaId": "...", "insertPosition": N}` |

### 5.2 List Syncs by Market

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 5.2.1 | Valid country | `GET $BASE/playlistsync/us/playlists` (use a seeded country) | `200`, filtered list |
| 5.2.2 | Unknown country | `GET $BASE/playlistsync/zz/playlists` | `400` |
| 5.2.3 | Case insensitive | `GET $BASE/playlistsync/US/playlists` | Same result as lowercase (via middleware) |

### 5.3 Get Single Sync

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 5.3.1 | Valid sync | `GET $BASE/playlistsync/{country}/playlists/{id}` | `200`, single `PlaylistSyncResponse` |
| 5.3.2 | Not found | `GET $BASE/playlistsync/{country}/playlists/999999` | `404` |
| 5.3.3 | Wrong market | Use a valid `sync_id` but wrong `country_code` | `400` (sync doesn't belong to application) |

---

## 6 · Playlist Sync — Write Operations

### 6.1 Create Sync

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 6.1.1 | Successful create | `POST $BASE/playlistsync/{country}/playlists` with valid body | `201`, new sync returned |
| 6.1.2 | Auto-set fields | Inspect response | `fromServiceType` = 0, `fromMusicServiceId` = 1, `active` = true, `createdAt` ≈ now, `applicationId` resolved from country |
| 6.1.3 | Duplicate `toPlaylistId` | Create with same `to_playlist_id` as existing active sync | `409 Conflict` |
| 6.1.4 | Missing required field | Omit `from_playlist_id` | `422 Validation Error` |
| 6.1.5 | Unknown country | POST to `/playlistsync/zz/playlists` | `400` |
| 6.1.6 | `title_copy_mode=0` (NoUpdate) | Create with `title_copy_mode: 0` and any `title` value | After sync, target playlist title is left unchanged |
| 6.1.7 | `title_copy_mode=1` (UseSetting) | Create with `title_copy_mode: 1, "title": "My Custom Title"` | After sync, target title set to `"My Custom Title"` |
| 6.1.8 | `title_copy_mode=2` (CopySource) | Create with `title_copy_mode: 2` | After sync, target title copied from Spotify source playlist |
| 6.1.9 | `description_copy_mode=1` (UseSetting) | Create with `description_copy_mode: 1, "description": "My desc"` | After sync, target description set to `"My desc"` |
| 6.1.10 | `description_copy_mode=2` (CopySource) | Create with `description_copy_mode: 2` | After sync, target description copied from source |

**Sample request body:**
```sh
curl -s -X POST "$BASE/playlistsync/us/playlists" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{
    "from_playlist_id": "spotify:playlist:37i9dQZF1DXcBWIGoYBM5M",
    "to_playlist_id": "spotify:playlist:TARGET_PLAYLIST_ID",
    "to_service_account_id": 1,
    "title": "Test Sync",
    "description": "Manual test",
    "title_copy_mode": 1,
    "description_copy_mode": 0,
    "append_track_list": false
  }'
```

**`title_copy_mode` / `description_copy_mode` values:**
- `0` — `NoUpdate`: leave the target playlist title/description unchanged
- `1` — `UseSetting`: use the `title`/`description` value stored on the sync task
- `2` — `CopySource`: always copy from the source Spotify playlist

### 6.2 Update Sync

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 6.2.1 | Update title | PUT with `{"title": "Updated Title"}` | `200`, title changed |
| 6.2.2 | Disable sync | PUT with `{"active": false}` | `200`, `active` = false; sync no longer runs in sweep |
| 6.2.3 | Re-enable sync | PUT with `{"active": true}` on disabled sync | `200`, `active` = true; sync included in sweep again |
| 6.2.4 | Switch title copy mode | PUT with `{"title_copy_mode": 2}` | `200`, next sync run copies title from source |
| 6.2.5 | Duplicate `toPlaylistId` | Update to a `to_playlist_id` used by another active sync | `409` |
| 6.2.6 | Non-existent sync | PUT to `/{country}/playlists/999999` | `404` |
| 6.2.7 | Updatable fields only | Attempt to set `from_service_type` in body | Ignored (not in updatable set) |
| 6.2.8 | `lastUpdated` set | After update, check `lastUpdated` | Updated to ≈ now |

**Disable sync example:**
```sh
curl -s -X PUT "$BASE/playlistsync/us/playlists/{id}" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"active": false}'
```

### 6.3 Delete Sync

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 6.3.1 | Delete existing | `DELETE $BASE/playlistsync/{country}/playlists/{id}` | `204 No Content` |
| 6.3.2 | Verify deleted | GET the same sync | `404` |
| 6.3.3 | Delete non-existent | DELETE `/{country}/playlists/999999` | `404` |

---

## 7 · Execute Sync (Celery Dispatch)

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 7.1 | Trigger execution | `POST $BASE/playlistsync/{country}/playlists/{id}/execute` | `200`, `{"message": "Execution of sync {id} triggered for market {country}"}` |
| 7.2 | Celery task dispatched | Check Celery Flower UI at `http://localhost:5555` or worker logs | Task `sync_task.execute_single` appears |
| 7.3 | `triggeredManually = true` | After sync completes, check latest log entry | `triggeredManually` = true |
| 7.4 | Non-existent sync | Execute with invalid `sync_id` | `404` |
| 7.5 | Inactive sync | Execute a sync with `active=false` | Worker skips (check logs) |
| 7.6 | Sync run created a log | After execution, `GET .../log` | New log entry with `time` ≈ now |
| 7.7 | No-op second run | Execute same sync again without source changes | Second log entry: `madeChange=false`, zero add/delete counts |

**Trigger and verify flow:**
```sh
# Trigger
curl -s -X POST -H "$AUTH" "$BASE/playlistsync/us/playlists/{id}/execute"

# Wait for worker to complete, then check log
curl -s -H "$AUTH" "$BASE/playlistsync/us/playlists/{id}/log?limit=1"

# Check sync row for error flag and track counts
curl -s -H "$AUTH" "$BASE/playlistsync/us/playlists/{id}"
```

---

## 8 · Sync Logs

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 8.1 | Get logs | `GET $BASE/playlistsync/{country}/playlists/{id}/log` | `200`, array of log entries |
| 8.2 | Default pagination | No `limit`/`offset` params | Returns up to 100 entries |
| 8.3 | Custom limit | `?limit=5` | At most 5 entries |
| 8.4 | Offset | `?limit=5&offset=5` | Skips first 5 entries |
| 8.5 | Limit bounds | `?limit=0` or `?limit=1001` | `422` (validation: 1–1000) |
| 8.6 | Negative offset | `?offset=-1` | `422` (validation: ≥ 0) |
| 8.7 | Log entry fields | Inspect an entry | `sync_id`, `time`, `made_changes`, `added_tracks`, `deleted_tracks`, `deleted_duplicates`, `source_tracks`, `target_track_count`, `error_message`, `triggered_manually`, `error` |

---

## 9 · Service Accounts

### 9.1 List All

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 9.1.1 | List all | `GET $BASE/serviceaccounts` | `200`, array of `ServiceAccountResponse` |
| 9.1.2 | No tokens in response | Inspect response | No `accessToken`, `refreshToken`, or `accessTokenExpiry` fields |
| 9.1.3 | camelCase fields | Inspect JSON keys | `serviceType`, `musicServiceId`, `displayName`, `updatedDate`, `applicationId` |
| 9.1.4 | Datetime format | Check `updatedDate` | ISO 8601 + `Z` (e.g., `"2015-09-07T16:31:08Z"`) |

### 9.2 List by Market

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 9.2.1 | Valid country | `GET $BASE/serviceaccounts/{country}` | `200`, filtered list |
| 9.2.2 | Unknown country | `GET $BASE/serviceaccounts/zz` | `400` |
| 9.2.3 | Without tokens | No `include_tokens` param | Tokens excluded |
| 9.2.4 | With tokens | `?include_tokens=true` | Response includes `accessToken`, `refreshToken`, `accessTokenExpiry` |

### 9.3 Create

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 9.3.1 | Create Spotify account | POST with `"service_type": 0` | `201`, `musicServiceId` auto-set to 1, `serviceType` = 0 |
| 9.3.2 | Create Deezer account | POST with `"service_type": 1` | `201`, `musicServiceId` auto-set to 2, `serviceType` = 1 |
| 9.3.3 | Create with music_service_id | POST with `"music_service_id": 2` | `201`, `serviceType` auto-set to 1 |
| 9.3.4 | Duplicate user | Same `(music_service_id, user_identifier)` | `409 Conflict` |
| 9.3.5 | Missing both type fields | Omit `service_type` and `music_service_id` | `400` (one required) |

**Create Spotify account (service_type=0, musicServiceId auto-set to 1):**
```sh
curl -s -X POST "$BASE/serviceaccounts/us" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{
    "service_type": 0,
    "display_name": "Sony Spotify US",
    "user_identifier": "spotify_user_id",
    "access_token": "BQtest...",
    "refresh_token": "AQtest...",
    "access_token_expiry": 3600
  }'
```

**Create Deezer account (service_type=1, musicServiceId auto-set to 2):**
```sh
curl -s -X POST "$BASE/serviceaccounts/us" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{
    "service_type": 1,
    "display_name": "Sony Deezer US",
    "user_identifier": "deezer_user_id",
    "access_token": "deezer_access_token..."
  }'
```

### 9.4 Update

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 9.4.1 | Update Spotify display name | PUT with `{"display_name": "New Name"}` | `200`, name updated |
| 9.4.2 | Update Spotify tokens | PUT with new `access_token` / `refresh_token` | `200`, tokens updated; use `?include_tokens=true` to verify |
| 9.4.3 | Update Deezer display name | PUT to Deezer account with `{"display_name": "New Deezer Name"}` | `200`, name updated |
| 9.4.4 | Update Deezer access token | PUT with new `access_token` | `200`, token updated |
| 9.4.5 | Immutable fields | Attempt to change `service_type` | Ignored (immutable) |
| 9.4.6 | Non-existent account | PUT to `/serviceaccounts/{country}/999999` | `404` |

**Update Spotify account tokens:**
```sh
# First get the account id from list
curl -s -H "$AUTH" "$BASE/serviceaccounts/us?include_tokens=true"

# Then update
curl -s -X PUT "$BASE/serviceaccounts/us/{id}" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{
    "display_name": "Sony Spotify US (updated)",
    "access_token": "BQnewtoken...",
    "refresh_token": "AQnewrefresh...",
    "access_token_expiry": 3600
  }'
```

**Update Deezer account token:**
```sh
curl -s -X PUT "$BASE/serviceaccounts/us/{id}" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{
    "access_token": "new_deezer_token..."
  }'
```

### 9.5 Delete

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 9.5.1 | Delete Spotify account | `DELETE $BASE/serviceaccounts/{id}` | `204` |
| 9.5.2 | Delete Deezer account | `DELETE $BASE/serviceaccounts/{id}` | `204` |
| 9.5.3 | Verify deleted | List accounts, confirm removed | Not in list |
| 9.5.4 | Delete non-existent | `DELETE $BASE/serviceaccounts/999999` | `404` |

```sh
# Delete by id (id is returned in create/list responses)
curl -s -X DELETE -H "$AUTH" "$BASE/serviceaccounts/{id}"
# Verify
curl -s -H "$AUTH" "$BASE/serviceaccounts/us"
```

---

## 10 · Synchronizers (Requires Platform Credentials)

> **Prerequisites:** Set `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`, and platform-specific credentials. Create valid service accounts with real tokens.

### 10.1 Spotify → Spotify

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 10.1.1 | Full sync | Create sync (Spotify→Spotify), execute | Log shows tracks added, target matches source |
| 10.1.2 | Deduplication | Source has duplicates | Target has no duplicates; `deletedDuplicates` > 0 in log |
| 10.1.3 | Track removal | Remove track from source, re-sync | `deletedTracks` > 0 in log |
| 10.1.4 | Track ordering | Source reordered, re-sync | Target order matches source |
| 10.1.5 | Local track removal | Target has local tracks | Local tracks removed from target |
| 10.1.6 | Title/description copy | Set `titleCopyMode=2` (CopySource) | Target title matches source title |
| 10.1.7 | No changes | Run sync twice without changes | Second run: `madeChange=false`, zero counts |
| 10.1.8 | Invalid target playlist | Set `to_playlist_id` to non-existent | Log: `errorType=NoTargetPlaylist` |
| 10.1.9 | Token refresh | Use expired token | Token refreshed, sync succeeds, new token persisted to DB |

### 10.2 Spotify → Deezer

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 10.2.1 | ISRC resolution | Create sync, execute | Tracks resolved via ISRC, added to Deezer playlist |
| 10.2.2 | ISRC cache | Run same sync twice | Second run uses cache (faster, fewer Deezer API calls) |
| 10.2.3 | Cache expiry (7 days) | Manually backdate cache rows, re-sync | Re-lookups happen for expired entries |
| 10.2.4 | Batch operations | Source has >100 tracks | Tracks added/removed in batches of 100 |
| 10.2.5 | Big static playlist bypass | Use account in `_BIG_STATIC_PLAYLIST_USERS` | Cache entries never expire |

### 10.3 Spotify → YouTube

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 10.3.1 | Video search + scoring | Execute sync | Videos found via ISRC/search, official channels preferred |
| 10.3.2 | Blacklisted channel | Add channel to `tblPlaylistSynchronizationFlaggedChannel` (type=1) | Videos from that channel skipped |
| 10.3.3 | Whitelisted channel | Add channel (type=0) | Videos from that channel preferred |
| 10.3.4 | Title filtering | Source track has "karaoke" match | Karaoke video filtered out |
| 10.3.5 | Deduplication | Target has duplicate videos | Duplicates removed |
| 10.3.6 | Reordering | Target order differs from source | Reordered to match |

### 10.4 Spotify → SoundCloud

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 10.4.1 | ISRC search | Execute sync | Tracks found by ISRC |
| 10.4.2 | Title fallback | Track without ISRC match | Falls back to title search |
| 10.4.3 | Title truncation | Source title > 100 chars | Target title truncated to 100 |
| 10.4.4 | Description truncation | Source description > 4000 chars | Truncated to 4000 |
| 10.4.5 | Playlist replace | Full track list replaced | Target matches source (replacement, not diff) |

---

## 11 · Celery Worker Tasks

### 11.1 execute_single_sync_task

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 11.1.1 | Successful sync | Trigger via `/execute` | Status `success`, log entry created, sync row updated |
| 11.1.2 | Missing service account | Delete service account, trigger sync | Error log: `NoServiceAccount` |
| 11.1.3 | Application mismatch | Account `application_id` ≠ sync's | Error: `ApplicationMismatch` |
| 11.1.4 | Inactive sync | Set `active=false`, trigger | Task returns `skipped` |
| 11.1.5 | Retry on failure | Simulate transient error | Task retries (up to 3x, 60s delay) |
| 11.1.6 | Error flag set | Sync fails | `error=true` on sync row |
| 11.1.7 | Error flag cleared | Sync succeeds after prior failure | `error=false` |
| 11.1.8 | Sentry tagging | Set `SENTRY_DSN`, trigger error | Sentry event with `sync_id` and `triggered_manually` tags |

### 11.2 periodic_sync_sweep

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 11.2.1 | Sweep dispatches tasks | Set `SYNC_SWEEP_INTERVAL_SECONDS=60`, wait | Individual tasks dispatched for all active syncs |
| 11.2.2 | Account filter file | Create filter file with specific IDs | Only matching account syncs dispatched |
| 11.2.3 | Second pass | New sync created during first pass | Caught in second pass |
| 11.2.4 | Inactive syncs skipped | Some syncs inactive | Only active syncs dispatched |

---

## 12 · Error Handling & Edge Cases

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 12.1 | SQL injection in path | `GET $BASE/playlistsync/'; DROP TABLE--/playlists` | `400` or `404`, no SQL executed |
| 12.2 | XSS in body | Create sync with `<script>` in title | Stored as-is (no execution), returned safely |
| 12.3 | Very long strings | 10,000+ char `from_playlist_id` | Handled gracefully (DB constraint or validation) |
| 12.4 | Integer overflow | `sync_id` = `99999999999999` | `422` or `404` |
| 12.5 | Empty body POST | POST to create with `{}` | `422` (missing required fields) |
| 12.6 | Concurrent duplicate create | Two simultaneous POSTs with same `to_playlist_id` | One succeeds (201), one fails (409) |
| 12.7 | Unicode in fields | Title with emoji/CJK characters | Stored and returned correctly |

---

## 13 · Docker & Infrastructure

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 13.1 | Dev stack starts | `docker compose up --build` | All services healthy |
| 13.2 | Test stack starts | `docker compose -f docker-compose.test.yml up -d` | All services healthy on test ports |
| 13.3 | DB pre-seeded | Connect to test MySQL, check tables | Seeded data from `database/data/` present |
| 13.4 | Worker connected | Check worker logs | Connected to broker, consuming tasks |
| 13.5 | Beat scheduled | Check beat logs | `periodic_sync_sweep` registered |
| 13.6 | Flower accessible | `curl http://localhost:5555` | Flower dashboard responds |
| 13.7 | Health check waits for deps | Start only app (no MySQL/Redis) | App waits / health returns degraded |

---

## 14 · Configuration Validation

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 14.1 | Default SQLite | No `DATABASE_URL` set | Uses `sqlite+aiosqlite:///./test.db` |
| 14.2 | MySQL connection | Set `DATABASE_URL=mysql+aiomysql://...` | Connects to MySQL |
| 14.3 | Redis connection | Set `REDIS_URL`, check health | Redis check passes |
| 14.4 | Sentry disabled | `SENTRY_DSN=""` | No Sentry init (no errors in logs) |
| 14.5 | Sentry enabled | Set `SENTRY_DSN` to valid DSN | Events appear in Sentry dashboard |
| 14.6 | API key empty | `FILTR_API_KEY=""` | All routes accessible without auth |
| 14.7 | Sweep disabled | `SYNC_SWEEP_INTERVAL_SECONDS=0` | No periodic sweep tasks |
| 14.8 | Sweep enabled | `SYNC_SWEEP_INTERVAL_SECONDS=300` | Beat schedules sweep every 5 min |

---

## 15 · Response Shape Verification

| # | Test Case | Steps | Expected |
|---|-----------|-------|----------|
| 15.1 | PlaylistSyncResponse keys | GET any sync | All camelCase keys: `applicationId`, `fromPlaylistId`, `toServiceAccountId`, `titleCopyMode`, `createdAt`, `lastUpdated`, `insertMedia`, etc. |
| 15.2 | ServiceAccountResponse keys | GET service accounts | camelCase: `serviceType`, `musicServiceId`, `displayName`, `updatedDate`, `applicationId` |
| 15.3 | AppMarketResponse keys | GET app markets | `id`, `name`, `cultureInfo`, `spotifyRegionCode`, `active`, `gaCountryName`, `defaultService`, `services`, `workoutMarket`, `includeOtherPlaylists` |
| 15.4 | Null vs absent fields | Check nullable fields | Present as `null`, not omitted |
| 15.5 | Boolean types | `active`, `error`, `madeChange`, etc. | JSON `true`/`false` (not 1/0 or strings) |

---

## Execution Order (Recommended)

1. **Infrastructure** (§13) — Verify Docker stacks start correctly
2. **Health Check** (§1) — Confirm baseline connectivity
3. **Authentication** (§2) — Verify auth gate before testing protected routes
4. **Middleware** (§3) — Quick case-insensitivity check
5. **Read Operations** (§4, §5, §9.1–9.2) — Verify seeded data accessible
6. **Write Operations** (§6, §9.3–9.5) — CRUD lifecycle
7. **Execute & Logs** (§7, §8) — Trigger sync, verify logs
8. **Synchronizers** (§10) — Platform-specific: Spotify, Deezer, YouTube, SoundCloud (requires credentials)
9. **Worker Tasks** (§11) — Celery behavior
10. **Error Handling** (§12) — Edge cases and security
11. **Configuration** (§14) — Env var behavior
12. **Response Shapes** (§15) — JSON contract verification

---

## Test Environment Teardown

```sh
docker compose -f docker-compose.test.yml down -v
```
