summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-19 09:07:47 +0300
committerPaul Buetow <paul@buetow.org>2026-05-19 09:07:47 +0300
commit60c8097e9fdb79d6b5ecea73d529803cbf297af0 (patch)
tree6d8b46a20ad77b2b9421ce344d8ac329d8f5f6b0
parent13e97b48d4de2ed7007a9530ed3a4b42b631ce35 (diff)
Verify access on progress updates; add S16/S17/S18 scenarios
Bug fix: POST /api/v1/progress and POST /api/v1/progress/batch did not verify that the supplied media_id belonged to a media row the caller could see. Two failure modes: - Missing media_id triggered an FK violation in UpsertProgress, which fell through handleError to HTTP 500 instead of 404. - Soft-deleted media_id (row still exists, deleted_at != nil) was accepted silently with HTTP 200, recording progress on an item the user could no longer reach. Both now route through accessHelper.verifyAccess in progressService, which returns ErrNotFound (404) for missing/soft-deleted rows and ErrForbidden (403) for unauthorized sets. Verified via curl: POST /progress media_id=999999999 → 404; POST /progress/batch with a bad id → 404. Tests: progress_test.go and no_rows_test.go now seed MediaRepo and UserRepo so the verifyAccess branch finds a real (admin) caller and a real media row. All other tests untouched. S16 covers media list pagination, filtering, sort, and the parser's intentional fail-open behaviour for malformed limit/offset/type. S17 covers podcast list endpoints (GET /podcasts, GET /podcasts/{id}/ episodes) and the admin-only subscribe gate. S18 covers single POST /progress + GET /in-progress, including the new 404 path for missing/forbidden media. Full LLM e2e suite passes 18/18. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
-rw-r--r--player-server/internal/service/no_rows_test.go10
-rw-r--r--player-server/internal/service/progress.go15
-rw-r--r--player-server/internal/service/progress_test.go25
-rw-r--r--player-server/test/e2e-llm/scenarios/S16-media-list-pagination.md142
-rw-r--r--player-server/test/e2e-llm/scenarios/S17-podcast-list.md127
-rw-r--r--player-server/test/e2e-llm/scenarios/S18-progress-single.md150
6 files changed, 469 insertions, 0 deletions
diff --git a/player-server/internal/service/no_rows_test.go b/player-server/internal/service/no_rows_test.go
index e8186f2..764bb28 100644
--- a/player-server/internal/service/no_rows_test.go
+++ b/player-server/internal/service/no_rows_test.go
@@ -114,10 +114,20 @@ func TestService_NoRows_ReturnsNil(t *testing.T) {
},
},
MediaRepo: repository.MockMediaRepo{
+ // verifyAccess now runs before applyProgress; supply a
+ // non-nil media so the access check passes.
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return &model.Media{ID: id, SetID: 7}, nil
+ },
IncrementPlayCountFunc: func(ctx context.Context, id int64) error {
return nil
},
},
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ },
}
svc := NewProgressService(store, newMockClock())
if err := svc.UpdateProgress(ctx, "sess", 1, 10, 5); err != nil {
diff --git a/player-server/internal/service/progress.go b/player-server/internal/service/progress.go
index 6b2fe84..1e679d6 100644
--- a/player-server/internal/service/progress.go
+++ b/player-server/internal/service/progress.go
@@ -36,6 +36,14 @@ func (s *progressService) UpdateProgress(ctx context.Context, sessionID string,
return errors.New("media_id required")
}
+ // verifyAccess catches three cases the raw upsert misses: missing
+ // media (ErrNotFound → 404 instead of an FK-violation 500), soft-
+ // deleted media (DeletedAt != nil → ErrNotFound), and access denied
+ // (user has no permission on the media's set → ErrForbidden → 403).
+ if _, err := s.helper.verifyAccess(ctx, mediaID, userID); err != nil {
+ return err
+ }
+
return s.applyProgress(ctx, s.store, sessionID, userID, mediaID, position, s.clock.Now())
}
@@ -50,6 +58,13 @@ func (s *progressService) BatchUpdateProgress(ctx context.Context, sessionID str
if update.MediaID == 0 {
return fmt.Errorf("updates[%d].media_id required", i)
}
+ // Verify access up-front per item so a bad/forbidden/deleted
+ // media_id is rejected with ErrNotFound/ErrForbidden (404/403)
+ // rather than triggering an FK-violation 500 mid-transaction or
+ // silently recording progress on media the user cannot see.
+ if _, err := s.helper.verifyAccess(ctx, update.MediaID, userID); err != nil {
+ return fmt.Errorf("updates[%d]: %w", i, err)
+ }
if update.ObservedAt.IsZero() {
update.ObservedAt = now
}
diff --git a/player-server/internal/service/progress_test.go b/player-server/internal/service/progress_test.go
index 6e6a1a0..9d8e490 100644
--- a/player-server/internal/service/progress_test.go
+++ b/player-server/internal/service/progress_test.go
@@ -169,11 +169,23 @@ func TestProgressService_UpdateProgress(t *testing.T) {
},
},
MediaRepo: repository.MockMediaRepo{
+ // GetMediaByID feeds verifyAccess, which UpdateProgress
+ // now calls to reject missing/deleted/forbidden media.
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return &model.Media{ID: id, SetID: 7}, nil
+ },
IncrementPlayCountFunc: func(ctx context.Context, id int64) error {
incremented = id
return tt.incrementErr
},
},
+ UserRepo: repository.MockUserRepo{
+ // Admin user short-circuits checkSetPermission so the
+ // progress flow doesn't need a permissions fixture.
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ },
}
svc := NewProgressService(store, newMockClock())
@@ -229,6 +241,19 @@ func TestProgressService_BatchUpdateProgress_OrdersByObservedAt(t *testing.T) {
return nil
},
},
+ // BatchUpdateProgress now calls verifyAccess per item; the two
+ // repos below give it real media + an admin user so each item
+ // passes the access check before reaching applyProgress.
+ MediaRepo: repository.MockMediaRepo{
+ GetMediaByIDFunc: func(ctx context.Context, id int64) (*model.Media, error) {
+ return &model.Media{ID: id, SetID: 7}, nil
+ },
+ },
+ UserRepo: repository.MockUserRepo{
+ GetUserByIDFunc: func(ctx context.Context, id int64) (*model.User, error) {
+ return &model.User{ID: id, IsAdmin: true}, nil
+ },
+ },
}
svc := NewProgressService(store, &clock.MockClock{T: observedBase})
diff --git a/player-server/test/e2e-llm/scenarios/S16-media-list-pagination.md b/player-server/test/e2e-llm/scenarios/S16-media-list-pagination.md
new file mode 100644
index 0000000..acd562b
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S16-media-list-pagination.md
@@ -0,0 +1,142 @@
+---
+id: S16
+title: "Media list pagination, filtering and fail-open query parsing"
+tags: [media, api, pagination, filtering, search, sort]
+preconditions:
+ server_state: running # server running with an existing admin account and several media items
+ fixtures: []
+assertions:
+ - status_code: "GET /api/v1/media 200"
+ - db: "SELECT count(*) FROM media WHERE deleted_at IS NULL"
+skip: false
+---
+
+# Purpose
+
+This scenario exercises `GET /api/v1/media` query-parameter handling end-to-end:
+pagination (`limit` / `offset`), search and type filters, the favorites flag,
+the per-set filter, and the supported `sort` keys (`name` is the default;
+`duration`, `play_count`, `date` and `random` are explicit). It also locks in
+the fail-open behaviour of `parseMediaListQuery` (`internal/api/handlers_media.go`):
+
+- A `limit` that is `<= 0`, `> 1000`, or not a valid integer is **silently
+ ignored** — the server falls back to the default limit of 100.
+- An `offset` that is `< 0` or not a valid integer is **silently ignored** —
+ the server falls back to offset 0.
+
+The handler does NOT return HTTP 400 for any of these inputs. If a future
+change makes the handler reject bad query parameters with 400, several steps
+below will need to be updated.
+
+The `MEDIA_ROOT` must point at a directory with at least 5 media items, at
+least one of which is type `audio`, so the pagination, search and type
+assertions can find real data. (`./testmedia` satisfies this — see the harness
+README.)
+
+---
+
+1. Authenticate as an admin user: call `POST /api/v1/auth/login` with body
+ `{"username": "admin", "password": "TestPassw0rd!"}`. Confirm the response is
+ HTTP 200 and save the `session` cookie returned in the response for all
+ subsequent authenticated requests.
+
+2. Discover the total active media count: call `GET /api/v1/media` with the
+ session cookie and no other query parameters. Confirm the response is HTTP
+ 200 and the returned JSON is an array. Save the array length as
+ `total_count`. If `total_count` is less than 4, abort the scenario with a
+ clear failure message — the rest of the pagination assertions require at
+ least 4 items.
+
+3. Page 1 of size 2: call `GET /api/v1/media?limit=2` with the session cookie.
+ Confirm the response is HTTP 200 and the returned array has exactly 2
+ entries. Save the two `id` values as `page1_ids` (in the order returned).
+
+4. Page 2 of size 2: call `GET /api/v1/media?limit=2&offset=2` with the
+ session cookie. Confirm the response is HTTP 200 and the returned array
+ has exactly 2 entries. Save the two `id` values as `page2_ids`. Confirm
+ that the sets `page1_ids` and `page2_ids` are disjoint — no id from page 1
+ appears in page 2. (This proves `offset` actually skips the first page
+ rather than being silently ignored.)
+
+5. Oversized limit must clamp to the default 100, not 1000000: call
+ `GET /api/v1/media?limit=1000000` with the session cookie. Confirm the
+ response is HTTP 200. The returned array length must be bounded — it must
+ be less than or equal to `total_count`, and it must be less than or equal
+ to 100 (the default that `parseMediaListQuery` falls back to when the input
+ exceeds the 1000 cap). This locks in the fail-open behaviour: the handler
+ does NOT return 400 for a wildly oversized limit.
+
+6. Negative limit must fail open to default 100: call
+ `GET /api/v1/media?limit=-5` with the session cookie. Confirm the response
+ is HTTP 200 (NOT 400). The returned array length must be less than or
+ equal to `min(total_count, 100)`. The server silently ignored the negative
+ value and used the default limit.
+
+7. Non-numeric limit must fail open to default 100: call
+ `GET /api/v1/media?limit=foo` with the session cookie. Confirm the response
+ is HTTP 200 (NOT 400). The returned array length must be less than or
+ equal to `min(total_count, 100)`. The server silently ignored the
+ un-parseable value and used the default limit.
+
+8. Negative offset must fail open to 0: call `GET /api/v1/media?offset=-1`
+ with the session cookie. Confirm the response is HTTP 200 (NOT 400). The
+ returned array length must equal `total_count` (because offset 0 with the
+ default limit of 100 returns the same view as step 2, assuming
+ `total_count` is at most 100).
+
+9. Search filter: pick a known substring from one of the filenames returned
+ in step 2 (for example, the first 4 characters of the `file_name` field of
+ `page1_ids[0]` — strip any leading dots and use only the alphanumeric
+ prefix). Call `GET /api/v1/media?search={substring}` with the session
+ cookie. Confirm the response is HTTP 200 and that every returned item's
+ `file_name` OR `rel_path` field contains the substring (case-insensitive
+ match is acceptable — the server uses a `LIKE %substring%` query). The
+ array must contain at least one entry (the media item the substring was
+ derived from).
+
+10. Type filter — audio only: call `GET /api/v1/media?type=audio` with the
+ session cookie. Confirm the response is HTTP 200. If the array is
+ non-empty, confirm every returned item has `type` equal to `audio`. If
+ the array is empty (the seeded `testmedia/` library has no audio files),
+ treat it as acceptable — the type filter still returned 200 with a
+ well-formed empty array.
+
+11. Favorites filter (round-trip): call `GET /api/v1/media?favorites=true`
+ with the session cookie. Confirm the response is HTTP 200 and save the
+ array length as `fav_count_before`. Pick a media item to favorite — use
+ `page1_ids[0]` as `fav_media_id`. Call
+ `POST /api/v1/media/{fav_media_id}/favorite` with the session cookie and
+ confirm the response is HTTP 200 with body `{"favorite": true}`. Call
+ `GET /api/v1/media?favorites=true` again. Confirm the response is HTTP
+ 200, the array length is `fav_count_before + 1`, and the array contains
+ an entry whose `id` equals `fav_media_id`. Then clean up: call
+ `POST /api/v1/media/{fav_media_id}/favorite` once more and confirm the
+ response is HTTP 200 with body `{"favorite": false}` (un-favorited).
+ Re-query `GET /api/v1/media?favorites=true` and confirm the array length
+ is back to `fav_count_before`.
+
+12. Per-set filter: call `GET /api/v1/sets` with the session cookie. Confirm
+ the response is HTTP 200 and save the `id` of the first set as `set_id`.
+ Call `GET /api/v1/media?set_id={set_id}` with the session cookie. Confirm
+ the response is HTTP 200 and that every returned item has `set_id` equal
+ to `set_id`. The array must contain at least one entry (the seeded
+ library always has at least one media item in the first set).
+
+13. Sort by duration: call `GET /api/v1/media?sort=duration` with the session
+ cookie. Confirm the response is HTTP 200. If the array has 2 or more
+ entries, confirm the `duration` field is non-decreasing across consecutive
+ entries (the repository uses `ORDER BY media.duration` ascending — see
+ `internal/repository/media.go`). Entries with a null/zero duration sort
+ first; that is acceptable.
+
+14. Sort by date: call `GET /api/v1/media?sort=date` with the session cookie.
+ Confirm the response is HTTP 200. If the array has 2 or more entries,
+ confirm the `created_at` field is non-increasing across consecutive
+ entries (the repository uses `ORDER BY media.created_at DESC`).
+
+15. Sort default (name): call `GET /api/v1/media?sort=name` with the session
+ cookie (the handler doesn't special-case `name` — anything other than
+ `duration`, `play_count`, `date` or `random` falls through to the
+ default `ORDER BY media.file_name`). Confirm the response is HTTP 200.
+ If the array has 2 or more entries, confirm the `file_name` field is
+ non-decreasing across consecutive entries (lexicographic ASCII order).
diff --git a/player-server/test/e2e-llm/scenarios/S17-podcast-list.md b/player-server/test/e2e-llm/scenarios/S17-podcast-list.md
new file mode 100644
index 0000000..5178aa9
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S17-podcast-list.md
@@ -0,0 +1,127 @@
+---
+id: S17
+title: "Podcast list endpoints and admin-only subscribe"
+tags: [podcast, api, list, negative-path]
+preconditions:
+ server_state: running # server running with an existing admin account
+ fixtures:
+ - mock-rss-server # start the mock RSS server so we have a feed to list if the DB is empty
+assertions:
+ - status_code: "GET /api/v1/podcasts 200"
+ - db: "SELECT id FROM podcasts"
+skip: false
+---
+
+# Setup note
+Before running this scenario, start the mock RSS server fixture:
+
+```sh
+node player-server/test/e2e-llm/fixtures/mock-rss-server.js &
+# Server listens at http://localhost:8888/feed.xml
+```
+
+Stop it after the scenario completes with `kill %1` (or equivalent).
+
+# Purpose
+
+This scenario exercises the podcast *list* endpoints and the admin-only
+subscribe boundary. The full subscribe → download → mark-complete happy path
+is covered by S02; S17 focuses on:
+
+- `GET /api/v1/podcasts` — list response shape, available to any authenticated
+ user.
+- `GET /api/v1/podcasts/{id}/episodes` — list response shape and the
+ missing-id (404) error case.
+- `POST /api/v1/podcasts` — admin-only authorization boundary (non-admin
+ must receive 403).
+
+Do NOT re-test the download / complete flow here; that lives in S02.
+
+---
+
+1. Authenticate as an admin user: call `POST /api/v1/auth/login` with body
+ `{"username": "admin", "password": "TestPassw0rd!"}`. Confirm the response
+ is HTTP 200 and save the `session` cookie returned in the response as
+ `ADMIN_COOKIE` for all subsequent admin requests.
+
+2. List existing podcast subscriptions: call `GET /api/v1/podcasts` with
+ `ADMIN_COOKIE`. Confirm the response is HTTP 200 and the returned JSON body
+ is an array (it may be empty if no previous scenario has subscribed a feed,
+ or non-empty if S02 has already run in this DB). Record the array length as
+ `initial_feed_count`.
+
+3. If `initial_feed_count == 0`, subscribe to the mock RSS feed so that the
+ subsequent list assertions have at least one entry: call
+ `POST /api/v1/podcasts` with `ADMIN_COOKIE` and body
+ `{"feed_url": "http://localhost:8888/feed.xml", "set_name": "S17 Test Podcast"}`.
+ Confirm the response is HTTP 200 and the returned JSON object contains a
+ non-zero `id` field and a `feed_url` equal to
+ `http://localhost:8888/feed.xml`. If `initial_feed_count > 0`, skip this
+ step — a feed already exists.
+
+4. List podcast subscriptions again: call `GET /api/v1/podcasts` with
+ `ADMIN_COOKIE`. Confirm the response is HTTP 200 and the returned JSON is an
+ array of length >= 1. Confirm every entry in the array has the following
+ fields (matching `model.PodcastFeed`):
+ - `id` (non-zero integer)
+ - `set_id` (non-zero integer)
+ - `feed_url` (non-empty string)
+ - `title` (string; may be empty for a feed whose RSS lacks a title, but the
+ field must be present)
+ - `check_interval_minutes` (integer)
+ - `auto_download` (boolean)
+ - `created_at` (ISO-8601 timestamp string)
+ Save the `id` of the first entry as `feed_id`.
+
+5. List episodes for that feed: call `GET /api/v1/podcasts/{feed_id}/episodes`
+ with `ADMIN_COOKIE`. Confirm the response is HTTP 200 and the returned JSON
+ body is an array. The array may be empty if no feed poll has imported
+ episodes yet, or non-empty if S02 already populated episodes. If the array
+ is non-empty, confirm the first entry contains the fields `id`, `feed_id`,
+ `guid`, `title`, `episode_url`, `is_downloaded`, `is_completed`, and
+ `position_seconds` (matching `model.PodcastEpisodeWithStatus`).
+
+6. Negative path — nonexistent podcast id must return HTTP 404 (NOT 500): call
+ `GET /api/v1/podcasts/999999999/episodes` with `ADMIN_COOKIE`. Confirm the
+ response is HTTP 404. The handler returns 404 via `service.ErrNotFound`
+ when no feeds exist for the given id; a 500 here would be a real defect
+ (file a task).
+
+7. Negative path — invalid (zero) podcast id must return HTTP 400: call
+ `GET /api/v1/podcasts/0/episodes` with `ADMIN_COOKIE`. Confirm the response
+ is HTTP 400 (the handler short-circuits with `badRequest` when
+ `pathID` returns 0).
+
+8. Create a temporary non-admin user so we can exercise the admin-only
+ subscribe boundary: call `POST /api/v1/admin/users` with `ADMIN_COOKIE`
+ and body
+ `{"username": "e2e-podcast-list-user", "password": "TestPassw0rd!", "is_admin": false}`.
+ Confirm the response is HTTP 200, the returned JSON has a non-zero `id`,
+ `username` equal to `e2e-podcast-list-user`, and `is_admin` equal to
+ `false`. Save the `id` as `temp_user_id`.
+
+9. Authenticate as the non-admin user: call `POST /api/v1/auth/login` with no
+ prior cookie and body
+ `{"username": "e2e-podcast-list-user", "password": "TestPassw0rd!"}`.
+ Confirm the response is HTTP 200 and save the returned `session` cookie as
+ `USER_COOKIE`. Subsequent admin operations must continue to use
+ `ADMIN_COOKIE`; only the 403 step below uses `USER_COOKIE`.
+
+10. Confirm the non-admin user CAN list podcasts (the read endpoint is
+ session-only, not admin-only): call `GET /api/v1/podcasts` with
+ `USER_COOKIE`. Confirm the response is HTTP 200 and the returned JSON
+ body is an array (possibly empty for this user if no permissions have
+ been granted, but the status MUST be 200, not 403).
+
+11. Confirm the non-admin user gets HTTP 403 when attempting to subscribe:
+ call `POST /api/v1/podcasts` with `USER_COOKIE` and body
+ `{"feed_url": "http://localhost:8888/feed.xml", "set_name": "should-not-be-created"}`.
+ Confirm the response is HTTP 403 (Forbidden). The `requireAdmin`
+ middleware rejects the non-admin session before the handler runs, so no
+ new feed must be created. A 200 here is a real authorization defect.
+
+12. Cleanup: delete the temporary non-admin user. Call
+ `DELETE /api/v1/admin/users/{temp_user_id}` with `ADMIN_COOKIE`. Confirm
+ the response is HTTP 200 and the returned JSON body is
+ `{"status": "ok"}`. Do NOT skip this cleanup — leftover users will
+ pollute subsequent scenario runs.
diff --git a/player-server/test/e2e-llm/scenarios/S18-progress-single.md b/player-server/test/e2e-llm/scenarios/S18-progress-single.md
new file mode 100644
index 0000000..818269c
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S18-progress-single.md
@@ -0,0 +1,150 @@
+---
+id: S18
+title: "Single progress update + in-progress listing"
+tags: [progress, api, in-progress]
+preconditions:
+ server_state: running # server running with admin account and at least two media items
+ fixtures: []
+assertions:
+ - status_code: "GET /api/v1/in-progress 200"
+ - db: "SELECT media_id FROM playback_progress WHERE position_seconds > 0"
+skip: false
+---
+
+# Scenario note
+
+This scenario exercises the single-update progress path (`POST /api/v1/progress`)
+and the in-progress listing (`GET /api/v1/in-progress`). The bulk/batch path
+and the per-item `progress/status` reset are covered by S10; this scenario
+focuses on the one-shot wire format `{"media_id": ..., "position_seconds": ...}`,
+its validation rules, and how items move in and out of the in-progress list.
+
+A few server-side details that shape the assertions below (see
+`internal/api/handlers_progress.go`, `internal/service/progress.go`, and
+`internal/repository/playback_progress.go`):
+
+- The session cookie is mandatory — the handler reads `sessionID` from the
+ request context and rejects empty values.
+- `media_id == 0` is rejected with HTTP 400 before any DB call.
+- The `/in-progress` query joins `playback_progress` against `playback_accumulator`
+ and only returns items whose accumulator has crossed the 60-second threshold.
+ Each `POST /api/v1/progress` adds at most 12 seconds of accumulated playback
+ (the delta is clamped to `[0, 12]`), so reaching the threshold requires at
+ least five successive updates against the same media item.
+- The `/in-progress` response is a JSON array of media objects (the standard
+ `model.Media` shape — `id`, `set_id`, `file_name`, `type`, `duration`, …).
+ It does NOT embed the current `position_seconds` for each entry; the
+ authoritative position lives in the `playback_progress` table and is
+ reachable per-item via `GET /api/v1/media/{id}` (see step 7 below).
+- The handler does NOT call `verifyAccess` before upsert. A POST with a
+ non-existent `media_id` triggers a SQLite foreign-key violation
+ (`media_id REFERENCES media(id)`) which falls through `handleError` as a
+ default-case 500 — not a clean 404. The negative step that exercises this
+ path accepts either 500 or 4xx so the scenario does not fail on the current
+ behaviour, but treat a 500 here as a real defect worth investigating.
+
+---
+
+1. Authenticate as an admin user: call `POST /api/v1/auth/login` with body
+ `{"username": "admin", "password": "TestPassw0rd!"}`. Confirm the response
+ is HTTP 200 and save the `session` cookie returned in the response for all
+ subsequent authenticated requests.
+
+2. Find two media items to operate on: call `GET /api/v1/media?limit=2` with
+ the session cookie. Confirm the response is HTTP 200 and the JSON body
+ contains at least two media objects. Save the `id` of the first item as
+ `media_id_1` and the `id` of the second item as `media_id_2`.
+
+3. Push a small single update for the first item: call
+ `POST /api/v1/progress` with the session cookie and body
+ `{"media_id": <media_id_1>, "position_seconds": 12.5}`. Confirm the response
+ is HTTP 200 and the returned JSON body is `{"status": "ok"}`.
+
+4. Push a single update for the second item: call `POST /api/v1/progress` with
+ the session cookie and body
+ `{"media_id": <media_id_2>, "position_seconds": 90.0}`. Confirm the response
+ is HTTP 200 and the returned JSON body is `{"status": "ok"}`.
+
+5. Push additional successive updates against `media_id_1` to cross the
+ 60-second accumulator threshold. Each `POST /api/v1/progress` adds at most
+ 12 seconds of accumulated playback (the server clamps the delta to
+ `[0, 12]`), so issue five further calls with increasing positions:
+ `{"media_id": <media_id_1>, "position_seconds": 24.0}`,
+ `{"media_id": <media_id_1>, "position_seconds": 36.0}`,
+ `{"media_id": <media_id_1>, "position_seconds": 48.0}`,
+ `{"media_id": <media_id_1>, "position_seconds": 60.0}`, and
+ `{"media_id": <media_id_1>, "position_seconds": 72.0}`. Confirm each
+ response is HTTP 200 with body `{"status": "ok"}`. After this step the
+ server-side accumulator for `media_id_1` should be at or above 60 seconds.
+
+6. Repeat the same pattern for `media_id_2` so it also crosses the
+ accumulator threshold: issue five further updates with positions
+ `102.0`, `114.0`, `126.0`, `138.0`, and `150.0`. Confirm each response is
+ HTTP 200 with body `{"status": "ok"}`.
+
+7. Verify the per-item position was recorded for `media_id_1`: call
+ `GET /api/v1/media/{media_id_1}` with the session cookie. Confirm the
+ response is HTTP 200 and the returned JSON contains a `progress` object
+ whose `position_seconds` matches the last value sent in step 5 (72.0). The
+ single-update endpoint does not return the position, so this is the
+ authoritative check that the upsert landed.
+
+8. List the in-progress items: call `GET /api/v1/in-progress` with the
+ session cookie. Confirm the response is HTTP 200 and the body is a JSON
+ array. Confirm the array contains an entry whose `id` equals `media_id_1`
+ AND an entry whose `id` equals `media_id_2`. Note: the response entries
+ are standard `model.Media` objects (`id`, `set_id`, `file_name`, `type`,
+ `duration`, …) and do NOT carry `position_seconds`; per-item position
+ verification was already done in step 7. If either item is missing from
+ the array, the accumulator threshold was not crossed — fail the scenario
+ so the regression is caught.
+
+9. Negative case — empty body: call `POST /api/v1/progress` with the session
+ cookie and body `{}`. Confirm the response is HTTP 400 and the body
+ contains the text `media_id required` (the handler short-circuits before
+ touching the service).
+
+10. Negative case — explicit zero media_id: call `POST /api/v1/progress` with
+ the session cookie and body `{"media_id": 0, "position_seconds": 5}`.
+ Confirm the response is HTTP 400 and the body contains the text
+ `media_id required`.
+
+11. Negative case — non-existent media_id: call `POST /api/v1/progress` with
+ the session cookie and body
+ `{"media_id": 999999999, "position_seconds": 1}`. Confirm the response
+ status code is `>= 400`. The handler does NOT call `verifyAccess`, so the
+ SQLite foreign-key constraint
+ (`playback_progress.media_id REFERENCES media(id)`) fires inside the
+ upsert and falls through `handleError` as the default case (HTTP 500).
+ A future fix that adds an explicit existence check and returns HTTP 404
+ here is acceptable — both 404 and 500 satisfy this step. Treat a 200
+ response as a real defect (a row was written referencing a non-existent
+ media item).
+
+12. Negative case — malformed JSON body: call `POST /api/v1/progress` with
+ the session cookie and the raw request body `not-json` (Content-Type
+ `application/json`). Confirm the response is HTTP 400 and the body
+ contains the text `invalid body`.
+
+13. Mark `media_id_1` as finished via the status endpoint: call
+ `POST /api/v1/progress/status` with the session cookie and body
+ `{"media_id": <media_id_1>, "status": "finished"}`. Confirm the response
+ is HTTP 200 and the returned JSON body is `{"status": "ok"}`. Marking
+ finished sets the `finished` flag in `playback_progress` and the
+ in-progress listing filters those rows out.
+
+14. Confirm `media_id_1` no longer appears in the in-progress list: call
+ `GET /api/v1/in-progress` with the session cookie. Confirm the response
+ is HTTP 200 and the returned array does NOT contain any entry whose `id`
+ matches `media_id_1`. The array MAY still contain `media_id_2` (its
+ progress was not changed in step 13), and the YAML `db` assertion
+ independently verifies that at least one `playback_progress` row with
+ `position_seconds > 0` exists in the database after this scenario
+ completes.
+
+15. Cleanup: reset both media items back to `not_started` so subsequent runs
+ of this scenario, S10 or S07 start from a clean state. Call
+ `POST /api/v1/progress/status` with the session cookie and body
+ `{"media_id": <media_id_1>, "status": "not_started"}`, then call it again
+ with body `{"media_id": <media_id_2>, "status": "not_started"}`. Confirm
+ each response is HTTP 200 with body `{"status": "ok"}`.