summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-18 23:55:34 +0300
committerPaul Buetow <paul@buetow.org>2026-05-18 23:55:34 +0300
commitcbee65a3a694a3a0103d7593f8f493308f32869c (patch)
tree2a9d810dcda2bf30e9ca05c11b50fbe2add0e437
parent70a287a6b10f6071254b4dfa6b282788483355ec (diff)
Add e2e-llm scenarios S06–S13
Covers: set browse + cover (S06), favorites/tags/notes (S07), media streaming/download/thumbnail (S08), soft-delete/trash/restore (S09), progress batch sync (S10), shares management (S11), admin user management (S12), and admin permissions (S13). Each scenario follows the S01–S05 YAML front-matter + numbered Markdown step format. Routes and response shapes were verified against player-server/internal/api/* handlers. Full suite passes 13/13; Playwright e2e-web suite passes 12/12. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
-rw-r--r--player-server/test/e2e-llm/scenarios/S06-set-browse-cover.md70
-rw-r--r--player-server/test/e2e-llm/scenarios/S07-favorites-tags-notes.md73
-rw-r--r--player-server/test/e2e-llm/scenarios/S08-media-streaming.md63
-rw-r--r--player-server/test/e2e-llm/scenarios/S09-delete-restore.md76
-rw-r--r--player-server/test/e2e-llm/scenarios/S10-progress-batch.md77
-rw-r--r--player-server/test/e2e-llm/scenarios/S11-shares-management.md54
-rw-r--r--player-server/test/e2e-llm/scenarios/S12-admin-users.md57
-rw-r--r--player-server/test/e2e-llm/scenarios/S13-admin-permissions.md61
8 files changed, 531 insertions, 0 deletions
diff --git a/player-server/test/e2e-llm/scenarios/S06-set-browse-cover.md b/player-server/test/e2e-llm/scenarios/S06-set-browse-cover.md
new file mode 100644
index 0000000..9e54f2e
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S06-set-browse-cover.md
@@ -0,0 +1,70 @@
+---
+id: S06
+title: "Set browse + cover regeneration + config"
+tags: [api, sets, browse, cover, config]
+preconditions:
+ server_state: running # server running with admin account and at least one media item
+ fixtures: []
+assertions:
+ - status_code: "GET /api/v1/config 200"
+ - status_code: "GET /api/v1/sets 200"
+skip: false
+---
+
+# Note on the cover endpoint
+`POST /api/v1/sets/{id}/cover` does **not** ingest an uploaded image. It
+triggers a server-side regeneration that picks a candidate file (artwork,
+video frame, image, or existing thumbnail) from the set and writes
+`.cover.jpg` into the set's directory. The PNG sent as multipart/form-data
+in step 5 is a benign payload — the server ignores it but accepts the
+request. The endpoint returns HTTP 200 only when the set contains at least
+one media file the server can derive a cover from; the chosen set in
+step 3 must therefore be a non-empty set such as `musicvideos` or any
+seeded testmedia set.
+
+---
+
+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. Fetch the authenticated client configuration: call `GET /api/v1/config`
+ with the session cookie. Confirm the response is HTTP 200 and the returned
+ JSON object contains a numeric `media_page_size` field (this field is
+ sourced from the server's `MediaPageSize` config and defaults to
+ `internal.DefaultMediaPageSize` when unset).
+
+3. List the available sets: call `GET /api/v1/sets` with the session cookie.
+ Confirm the response is HTTP 200 and the returned array contains at least
+ one set. Prefer a non-podcast set seeded from `testmedia/` (for example
+ `musicvideos`). Save the `id` of the chosen set as `set_id`.
+
+4. Browse the contents of the chosen set: call
+ `GET /api/v1/sets/{set_id}/browse` with the session cookie. Confirm the
+ response is HTTP 200. The returned JSON is a `BrowseResult` object with
+ the following fields: `current_path` (string), `folders` (array of
+ `{name, has_cover}` objects), and `media` (array of media objects).
+ Confirm the `folders` and `media` fields are present (either may be an
+ empty array, but both keys must exist).
+
+5. Fetch the current set cover: call `GET /api/v1/sets/{set_id}/cover` with
+ the session cookie. Confirm the response is HTTP 200 (a cover already
+ exists for the set) **or** HTTP 404 (no cover has been generated yet for
+ this set). Either outcome is acceptable at this stage.
+
+6. Trigger a cover regeneration: call `POST /api/v1/sets/{set_id}/cover`
+ with the session cookie as a `multipart/form-data` request containing a
+ single form field `file` whose value is a minimal valid PNG (for example
+ the 67-byte 1x1 transparent PNG produced by
+ `printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB\x60\x82' > /tmp/s06-pixel.png`).
+ The server does not read the file body — it picks a candidate file
+ already inside the set's directory and writes `.cover.jpg` — but the
+ request must still be a well-formed multipart request. Confirm the
+ response is HTTP 200 and the returned JSON is `{"status":"ok"}`.
+
+7. Fetch the set cover again: call `GET /api/v1/sets/{set_id}/cover` with
+ the session cookie. Confirm the response is HTTP 200 and the `Content-Type`
+ header indicates an image (typically `image/jpeg` since the regenerated
+ cover is `.cover.jpg`). The cover is now guaranteed to exist after the
+ successful regeneration in step 6.
diff --git a/player-server/test/e2e-llm/scenarios/S07-favorites-tags-notes.md b/player-server/test/e2e-llm/scenarios/S07-favorites-tags-notes.md
new file mode 100644
index 0000000..1550c0a
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S07-favorites-tags-notes.md
@@ -0,0 +1,73 @@
+---
+id: S07
+title: "Favorites, tags, and notes lifecycle"
+tags: [favorites, tags, notes, api]
+preconditions:
+ server_state: running # server running with admin account and at least one media item
+ fixtures: []
+assertions:
+ - db: "SELECT id FROM tags WHERE name='e2e-test'"
+ - status_code: "GET /api/v1/tags 200"
+skip: false
+---
+
+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 a media item to operate on: call `GET /api/v1/media?limit=1` with the
+ session cookie. Confirm the response is HTTP 200 and contains at least one
+ media object. Save the `id` of the first item as `media_id`.
+
+3. Toggle the favorite flag on the media item for the first time: call
+ `POST /api/v1/media/{media_id}/favorite` with the session cookie. Confirm
+ the response is HTTP 200 and the returned JSON body is `{"favorite": true}`
+ (the item is now marked as a favorite).
+
+4. Toggle the favorite flag a second time on the same item: call
+ `POST /api/v1/media/{media_id}/favorite` again with the session cookie.
+ Confirm the response is HTTP 200 and the returned JSON body is
+ `{"favorite": false}` (the favorite has been cleared).
+
+5. List existing tags for the user: call `GET /api/v1/tags` with the session
+ cookie. Confirm the response is HTTP 200. The body is a JSON array which
+ may be empty. Save the current tag count as `initial_tag_count`.
+
+6. Add the tag `e2e-test` to the media item: call
+ `POST /api/v1/media/{media_id}/tags` with the session cookie and body
+ `{"tag": "e2e-test"}`. Confirm the response is HTTP 200 and the returned
+ JSON body is `{"status": "ok"}`.
+
+7. List tags again: call `GET /api/v1/tags` with the session cookie. Confirm
+ the response is HTTP 200 and the returned array now contains an entry whose
+ `name` field equals `e2e-test`.
+
+8. Remove the tag from the media item: call
+ `DELETE /api/v1/media/{media_id}/tags/e2e-test` with the session cookie.
+ Confirm the response is HTTP 200 and the returned JSON body is
+ `{"status": "ok"}`.
+
+9. Retrieve the note for the media item before any note exists: call
+ `GET /api/v1/media/{media_id}/notes` with the session cookie. Confirm the
+ response status code is either HTTP 200 (with an empty or null body) or
+ HTTP 204 (No Content) — both indicate that no note is currently stored.
+
+10. Create the note: call `POST /api/v1/media/{media_id}/notes` with the session
+ cookie and body `{"content": "e2e note"}`. Confirm the response is HTTP 200
+ and the returned JSON object has a `content` field equal to `e2e note` and
+ a `media_id` field equal to `media_id`.
+
+11. Retrieve the note again: call `GET /api/v1/media/{media_id}/notes` with the
+ session cookie. Confirm the response is HTTP 200 and the returned JSON
+ object has a `content` field equal to `e2e note`.
+
+12. Delete the note: call `DELETE /api/v1/media/{media_id}/notes` with the
+ session cookie. Confirm the response is HTTP 200 and the returned JSON
+ body is `{"status": "ok"}`.
+
+13. Retrieve the note one more time after deletion: call
+ `GET /api/v1/media/{media_id}/notes` with the session cookie. Confirm the
+ response is either HTTP 404 (Not Found) or HTTP 204 (No Content) or HTTP
+ 200 with an empty/null body — any of these indicates that the note has
+ been removed.
diff --git a/player-server/test/e2e-llm/scenarios/S08-media-streaming.md b/player-server/test/e2e-llm/scenarios/S08-media-streaming.md
new file mode 100644
index 0000000..079d707
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S08-media-streaming.md
@@ -0,0 +1,63 @@
+---
+id: S08
+title: "Media streaming, download, thumbnail regen and playback hints"
+tags: [media, api, streaming, thumbnail, playback]
+preconditions:
+ server_state: running # server running with admin account and at least one media item
+ fixtures: []
+assertions:
+ - status_code: "GET /api/v1/media 200"
+skip: false
+---
+
+# Notes
+This scenario exercises the HTTP-level media endpoints only: thumbnail fetch
+and regeneration, the playback-hints endpoint, a ranged byte-range stream
+request, and the download endpoint. No actual browser playback is performed —
+each step inspects HTTP status codes, response headers and JSON bodies.
+
+The `MEDIA_ROOT` must point at a directory containing at least one media
+item (see the harness README — `./testmedia` is the default for the LLM e2e
+suite). If no media items exist the scenario will fail at step 2.
+
+---
+
+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 a media item to exercise: call `GET /api/v1/media?limit=1` with the
+ session cookie. Confirm the response is HTTP 200 and the JSON body contains
+ at least one media object. Save the `id` of the first item as `media_id`.
+ Save the `type` field (expected to be `audio`, `video` or `image`) for use
+ in step 6's Content-Type check.
+
+3. Fetch the thumbnail for the media item: call
+ `GET /api/v1/media/{media_id}/thumbnail` with the session cookie. Confirm
+ the response is HTTP 200 and the `Content-Type` response header starts with
+ `image/` (e.g. `image/jpeg`, `image/png` or `image/webp`).
+
+4. Trigger a thumbnail regeneration: call
+ `POST /api/v1/media/{media_id}/thumbnail` with the session cookie and no
+ request body. Confirm the response is HTTP 200 or HTTP 202 (the server
+ returns HTTP 200 with `{"status": "ok"}` for synchronous regeneration; a
+ future async implementation may return 202 Accepted — either is acceptable).
+
+5. Fetch playback hints for the media item: call
+ `GET /api/v1/media/{media_id}/playback` with the session cookie. Confirm
+ the response is HTTP 200 and the returned JSON object contains a
+ `needs_transcode` field whose value is a boolean (either `true` or `false`).
+
+6. Request a byte range of the stream: call
+ `GET /api/v1/media/{media_id}/stream` with the session cookie and an
+ additional `Range: bytes=0-1023` request header. Confirm the response is
+ HTTP 206 (Partial Content) — or HTTP 200 if the server chose to ignore the
+ Range header — and that the `Content-Type` response header starts with
+ `audio/` or `video/` depending on the media `type` saved in step 2.
+
+7. Download the media item: call
+ `GET /api/v1/media/{media_id}/download` with the session cookie. Confirm
+ the response is HTTP 200 and the `Content-Disposition` response header is
+ present and contains the substring `attachment` (e.g.
+ `attachment; filename="track.mp3"`).
diff --git a/player-server/test/e2e-llm/scenarios/S09-delete-restore.md b/player-server/test/e2e-llm/scenarios/S09-delete-restore.md
new file mode 100644
index 0000000..684b32b
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S09-delete-restore.md
@@ -0,0 +1,76 @@
+---
+id: S09
+title: "Soft-delete → admin trash → restore round-trip"
+tags: [media, api, admin, trash, restore]
+preconditions:
+ server_state: running # server running with an existing admin account
+ fixtures: []
+assertions:
+ - db: "SELECT id FROM media WHERE is_deleted=0"
+ - status_code: "GET /api/v1/admin/trash 200"
+skip: false
+---
+
+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. Create an API token to authenticate the upload step (Bearer auth): call
+ `POST /api/v1/auth/tokens` with the session cookie and body
+ `{"name": "e2e-delete-restore", "expires_in_days": 1}`. Confirm the response
+ is HTTP 200 and save the `token` plaintext from the response as
+ `BEARER_TOKEN`.
+
+3. List the available sets: call `GET /api/v1/sets` with the session cookie.
+ Confirm the response is HTTP 200. Save the `id` of the first non-podcast set
+ (e.g. `musicvideos`) as `set_id`.
+
+4. Prepare a small disposable test audio file. Create a minimal 1-second silent
+ MP3 named `test-delete-restore.mp3` using
+ `ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -q:a 9 -acodec libmp3lame /tmp/test-delete-restore.mp3`
+ (requires ffmpeg on PATH). Confirm the file exists at
+ `/tmp/test-delete-restore.mp3`.
+
+5. Upload the file to the set: call
+ `POST /api/v1/sets/{set_id}/upload` as a `multipart/form-data` request with:
+ - `Authorization: Bearer {BEARER_TOKEN}` header
+ - form field `file` containing the contents of
+ `/tmp/test-delete-restore.mp3` with filename `test-delete-restore.mp3`.
+ Confirm the response is HTTP 200 and the returned JSON contains a non-zero
+ `id` field. Save `media_id` from the response.
+
+6. Confirm the freshly uploaded item appears in the active media list: call
+ `GET /api/v1/media` with the session cookie. Confirm the response is HTTP 200
+ and the returned list contains an entry whose `id` matches `media_id`.
+
+7. Soft-delete the media item: call `DELETE /api/v1/media/{media_id}` with the
+ session cookie. Confirm the response is HTTP 200.
+
+8. Confirm the item no longer appears in the active media list: call
+ `GET /api/v1/media` with the session cookie. Confirm the response is HTTP 200
+ and the returned list does NOT contain any entry whose `id` matches
+ `media_id` (soft-deleted items are filtered out of the active listing).
+
+9. Confirm the item appears in the admin trash list: call
+ `GET /api/v1/admin/trash` with the session cookie. Confirm the response is
+ HTTP 200 and the returned list contains an entry whose `id` matches
+ `media_id`.
+
+10. Restore the soft-deleted media item: call
+ `POST /api/v1/media/{media_id}/restore` with the session cookie. Confirm the
+ response is HTTP 200.
+
+11. Confirm the item reappears in the active media list: call
+ `GET /api/v1/media` with the session cookie. Confirm the response is HTTP
+ 200 and the returned list contains an entry whose `id` matches `media_id`.
+
+12. Confirm the item is no longer in the admin trash list: call
+ `GET /api/v1/admin/trash` with the session cookie. Confirm the response is
+ HTTP 200 and the returned list does NOT contain any entry whose `id`
+ matches `media_id`.
+
+13. Clean up: delete the test media item by calling
+ `DELETE /api/v1/media/{media_id}` with the session cookie. Confirm the
+ response is HTTP 200. Also revoke the API token created in step 2 by calling
+ `DELETE /api/v1/auth/tokens/{token_id}` with the session cookie.
diff --git a/player-server/test/e2e-llm/scenarios/S10-progress-batch.md b/player-server/test/e2e-llm/scenarios/S10-progress-batch.md
new file mode 100644
index 0000000..ad6ead9
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S10-progress-batch.md
@@ -0,0 +1,77 @@
+---
+id: S10
+title: "Bulk progress sync → batch update + status query"
+tags: [progress, api, sync, mobile]
+preconditions:
+ server_state: running # server running with an existing admin account and at least two media items
+ fixtures: []
+assertions:
+ - db: "SELECT id FROM progress WHERE position_seconds=30"
+ - status_code: "POST /api/v1/progress/batch 200"
+skip: false
+---
+
+# Scenario note
+This scenario exercises the mobile offline-sync use case: a client that
+accumulated playback progress for several media items while offline pushes
+the batch to the server in a single request, then queries the per-item
+status to reconcile. The real wire format for `/api/v1/progress/batch`
+wraps the items in `{"updates": [...]}` (see `handlers_progress.go`), and
+`/api/v1/progress/status` accepts a single `{"media_id": ..., "status":
+"finished"|"not_started"}` payload — so the status step is repeated per
+media ID to cover both items.
+
+---
+
+1. Authenticate as an admin user: call `POST /api/v1/auth/login` with body
+ `{"username": "admin", "password": "TestPassw0rd!"}`. Save the `session`
+ cookie returned in the response for all subsequent authenticated requests.
+
+2. Retrieve at least two media items: call `GET /api/v1/media?limit=2` with the
+ session cookie. Confirm the response is HTTP 200 and 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 batch of two progress updates: call `POST /api/v1/progress/batch`
+ with the session cookie and body
+ `{"updates": [{"media_id": <media_id_1>, "position_seconds": 30.0}, {"media_id": <media_id_2>, "position_seconds": 60.0}]}`.
+ Confirm the response is HTTP 200 and the returned JSON contains
+ `{"status": "ok"}`.
+
+4. Confirm both progress rows landed in the database: in the same step or via
+ a follow-up sanity check, the harness's YAML `db` assertion verifies that
+ at least one row exists with `position_seconds=30` after this scenario
+ completes (see the front-matter assertions block).
+
+5. Query the recorded progress for the first media item: 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` is `30` (or `30.0`) — matching what was pushed in
+ step 3.
+
+6. Query the recorded progress for the second media item: call
+ `GET /api/v1/media/{media_id_2}` with the session cookie. Confirm the
+ response is HTTP 200 and the returned JSON contains a `progress` object
+ whose `position_seconds` is `60` (or `60.0`) — matching what was pushed in
+ step 3.
+
+7. Mark the first media item 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 contains `{"status": "ok"}`.
+
+8. Reset the first media item back to `not_started` via the same endpoint:
+ call `POST /api/v1/progress/status` with the session cookie and body
+ `{"media_id": <media_id_1>, "status": "not_started"}`. Confirm the response
+ is HTTP 200. This proves the status endpoint accepts both transitions for
+ the IDs that were just batch-updated.
+
+9. Verify both items still appear in the in-progress list (the batch update
+ recorded real progress for each): call `GET /api/v1/in-progress` with the
+ session cookie. Confirm the response is HTTP 200 and the returned array
+ contains entries whose `id` matches `media_id_1` and `media_id_2`
+ respectively. Note: the in-progress listing requires accumulated playback
+ time on the server side; if either media item is missing because the
+ accumulator threshold has not been crossed, treat its absence as
+ acceptable — the authoritative check is the DB assertion in the YAML
+ front-matter.
diff --git a/player-server/test/e2e-llm/scenarios/S11-shares-management.md b/player-server/test/e2e-llm/scenarios/S11-shares-management.md
new file mode 100644
index 0000000..a12fb67
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S11-shares-management.md
@@ -0,0 +1,54 @@
+---
+id: S11
+title: "Shares management — list, public thumbnail/download, revoke"
+tags: [share, api, public]
+preconditions:
+ server_state: running # server running with admin account and at least one media item
+ fixtures: []
+assertions:
+ - db: "SELECT token FROM shares"
+ - status_code: "GET /api/v1/shares 200"
+skip: false
+---
+
+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 for subsequent authenticated requests.
+
+2. Find a media item to share: call `GET /api/v1/media?limit=1` with the
+ session cookie. Confirm the response is HTTP 200 and contains at least one
+ media object. Save the `id` of the first item as `media_id`.
+
+3. Create a share link for that media item: call
+ `POST /api/v1/media/{media_id}/shares` with the session cookie. Confirm the
+ response is HTTP 200. Save the `token` field from the response as
+ `share_token`. The share URL is `{PLAYER_URL}/s/{share_token}`.
+
+4. List shares owned by the current user: call `GET /api/v1/shares` with the
+ session cookie. Confirm the response is HTTP 200 and the returned array
+ contains an entry whose `token` matches `share_token`.
+
+5. Fetch the public share thumbnail without any authentication: call
+ `GET {PLAYER_URL}/s/{share_token}/thumbnail` with no session cookie and no
+ Authorization header. Confirm the response is HTTP 200 and the
+ `Content-Type` response header starts with `image/`.
+
+6. Fetch the public share download without any authentication: call
+ `GET {PLAYER_URL}/s/{share_token}/download` with no session cookie and no
+ Authorization header. Confirm the response is HTTP 200 and the
+ `Content-Disposition` response header starts with `attachment` (the server
+ sets `attachment; filename="<original-file-name>"`).
+
+7. Revoke the share link: from the authenticated context (using the admin
+ session cookie), call `DELETE /api/v1/shares/{share_token}`. Confirm the
+ response is HTTP 200.
+
+8. Confirm the share no longer appears in the owner's list: call
+ `GET /api/v1/shares` with the session cookie. Confirm the response is
+ HTTP 200 and the returned array does NOT contain an entry whose `token`
+ matches `share_token`.
+
+9. Confirm the public thumbnail endpoint is no longer reachable: call
+ `GET {PLAYER_URL}/s/{share_token}/thumbnail` with no session cookie and no
+ Authorization header. Confirm the response is HTTP 404 (the token has been
+ revoked and the share is no longer resolvable).
diff --git a/player-server/test/e2e-llm/scenarios/S12-admin-users.md b/player-server/test/e2e-llm/scenarios/S12-admin-users.md
new file mode 100644
index 0000000..98e5583
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S12-admin-users.md
@@ -0,0 +1,57 @@
+---
+id: S12
+title: "Admin user management — create and delete"
+tags: [admin, auth, api, users]
+preconditions:
+ server_state: running # server running with an existing admin account
+ fixtures: []
+assertions:
+ - db: "SELECT count(*) FROM users"
+ - status_code: "GET /api/v1/admin/users 200"
+skip: false
+---
+
+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. List the existing users: call `GET /api/v1/admin/users` with the session
+ cookie. Confirm the response is HTTP 200 and the returned JSON is an array
+ of user objects. Record the current number of users as `initial_user_count`.
+
+3. Create a new non-admin user: call `POST /api/v1/admin/users` with the
+ session cookie and body
+ `{"username": "e2e-temp-user", "password": "TempPassw0rd!", "is_admin": false}`.
+ Confirm the response is HTTP 200 and the returned JSON contains a non-zero
+ `id` field, a `username` field equal to `e2e-temp-user`, and an `is_admin`
+ field that is `false` (or `0` if represented as a SQLite-style integer).
+ Save the `id` as `new_user_id`.
+
+4. Confirm the new user appears in the list: call `GET /api/v1/admin/users`
+ with the session cookie. Confirm the response is HTTP 200, the returned
+ array length is exactly `initial_user_count + 1`, and the array contains an
+ entry whose `id` matches `new_user_id` and whose `username` is
+ `e2e-temp-user`.
+
+5. Confirm the new user can authenticate: call `POST /api/v1/auth/login` with
+ body `{"username": "e2e-temp-user", "password": "TempPassw0rd!"}` and no
+ session cookie. Confirm the response is HTTP 200 and the response includes a
+ `Set-Cookie` header that sets a non-empty `session` cookie. Discard this
+ session cookie — subsequent admin operations must continue to use the
+ original admin session cookie from step 1.
+
+6. Delete the new user: call `DELETE /api/v1/admin/users/{new_user_id}` with
+ the admin session cookie from step 1. Confirm the response is HTTP 200 and
+ the returned JSON contains `{"status": "ok"}`.
+
+7. Confirm the deleted user no longer appears in the list: call
+ `GET /api/v1/admin/users` with the admin session cookie. Confirm the
+ response is HTTP 200, the returned array length is back to
+ `initial_user_count`, and the array does NOT contain any entry whose `id`
+ matches `new_user_id` or whose `username` is `e2e-temp-user`.
+
+8. Confirm the deleted user can no longer login: call `POST /api/v1/auth/login`
+ with body `{"username": "e2e-temp-user", "password": "TempPassw0rd!"}` and
+ no session cookie. Confirm the response is HTTP 401 (Unauthorized) — the
+ user no longer exists, so authentication must fail.
diff --git a/player-server/test/e2e-llm/scenarios/S13-admin-permissions.md b/player-server/test/e2e-llm/scenarios/S13-admin-permissions.md
new file mode 100644
index 0000000..4738f33
--- /dev/null
+++ b/player-server/test/e2e-llm/scenarios/S13-admin-permissions.md
@@ -0,0 +1,61 @@
+---
+id: S13
+title: "Admin permissions: grant and revoke set access"
+tags: [admin, permissions, api]
+preconditions:
+ server_state: running # server running with admin account and at least one set
+ fixtures: []
+assertions:
+ - db: "SELECT count(*) FROM set_permissions"
+ - status_code: "GET /api/v1/admin/permissions 200"
+skip: false
+---
+
+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. Create a non-admin user that will receive the permission grant: call
+ `POST /api/v1/admin/users` with the session cookie and body
+ `{"username": "e2e-perm-user", "password": "TestPassw0rd!", "is_admin": false}`.
+ Confirm the response is HTTP 200 and the returned JSON object has a non-zero
+ `id` field, a `username` field equal to `e2e-perm-user`, and an `is_admin`
+ field equal to `false`. Save the `id` as `user_id`.
+
+3. List the current permissions matrix: call `GET /api/v1/admin/permissions`
+ with the session cookie. Confirm the response is HTTP 200 and the returned
+ JSON object has three array fields: `sets`, `users`, and `permissions`.
+ Confirm `users` contains an entry whose `id` matches `user_id`, and that
+ `sets` contains at least one entry. Save the `id` of the first set in `sets`
+ as `set_id`, and remember the existing entries in `permissions` (the count
+ may be zero or greater) as `initial_perm_count`.
+
+4. Grant the new user the `viewer` role on the chosen set: call
+ `POST /api/v1/admin/permissions` with the session cookie and body
+ `{"user_id": <user_id>, "set_id": <set_id>, "role": "viewer"}`. Confirm the
+ response is HTTP 200 and the returned JSON body is `{"status": "ok"}`. Note
+ that the request body also accepts `"owner"` as the role; this scenario uses
+ `"viewer"`.
+
+5. Re-fetch the permissions matrix: call `GET /api/v1/admin/permissions` with
+ the session cookie. Confirm the response is HTTP 200 and the `permissions`
+ array now contains an entry whose `user_id` equals `user_id`, `set_id`
+ equals `set_id`, and `role` equals `viewer`. The array length must be
+ greater than `initial_perm_count`.
+
+6. Revoke the permission: call `DELETE /api/v1/admin/permissions` with the
+ session cookie and body `{"user_id": <user_id>, "set_id": <set_id>}`.
+ Confirm the response is HTTP 200 and the returned JSON body is
+ `{"status": "ok"}`. Note that the revoke handler ignores the `role` field
+ and removes any role the user has on that set.
+
+7. Re-fetch the permissions matrix one more time: call
+ `GET /api/v1/admin/permissions` with the session cookie. Confirm the
+ response is HTTP 200 and the `permissions` array no longer contains any
+ entry whose `user_id` equals `user_id` and `set_id` equals `set_id`. The
+ array length must equal `initial_perm_count` again.
+
+8. Cleanup: delete the temporary user by calling
+ `DELETE /api/v1/admin/users/{user_id}` with the session cookie. Confirm the
+ response is HTTP 200 and the returned JSON body is `{"status": "ok"}`.