From 60c8097e9fdb79d6b5ecea73d529803cbf297af0 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 19 May 2026 09:07:47 +0300 Subject: Verify access on progress updates; add S16/S17/S18 scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- player-server/internal/service/no_rows_test.go | 10 ++ player-server/internal/service/progress.go | 15 +++ player-server/internal/service/progress_test.go | 25 ++++ .../e2e-llm/scenarios/S16-media-list-pagination.md | 142 +++++++++++++++++++ .../test/e2e-llm/scenarios/S17-podcast-list.md | 127 +++++++++++++++++ .../test/e2e-llm/scenarios/S18-progress-single.md | 150 +++++++++++++++++++++ 6 files changed, 469 insertions(+) create mode 100644 player-server/test/e2e-llm/scenarios/S16-media-list-pagination.md create mode 100644 player-server/test/e2e-llm/scenarios/S17-podcast-list.md create mode 100644 player-server/test/e2e-llm/scenarios/S18-progress-single.md 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": , "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": , "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": , "position_seconds": 24.0}`, + `{"media_id": , "position_seconds": 36.0}`, + `{"media_id": , "position_seconds": 48.0}`, + `{"media_id": , "position_seconds": 60.0}`, and + `{"media_id": , "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": , "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": , "status": "not_started"}`, then call it again + with body `{"media_id": , "status": "not_started"}`. Confirm + each response is HTTP 200 with body `{"status": "ok"}`. -- cgit v1.2.3