From ef827a3a058d446c4a16a42ef0fb8b1e30ae107f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Nov 2026 19:38:08 -0500 Subject: [PATCH] test(api): pin the resume-rewind contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestAudioResumeRewind drives the whole surface against the shared dev DB with the fresh per-test regular user (the live admin is never touched; the fixture book lives in a test-named library that cascades away): default 0, profile PUT "5" rides the chapters payload, non-allow-listed values 400 without side effects, explicit "0" resets everywhere, and an omitted JSON key leaves the column untouched. Changelog entry in docs/contract_pins.md (additive chapters key + the profile field's string-shaped PUT, keep_screen_awake precedent) and the get_profile.md example gains the preference keys. Debug note for the next agent: the first runs here 401'd with 'Ghost session rejected' — NOT a harness or auth bug. The tests never execute schema.sql; the shared dev DB gets its schema from the app container's boot. The rebuilt tests image ran the new GetUser (selecting audio_resume_rewind_seconds) against a DB whose schema came from the old app image — every GetUser errored, and the ghost-session guard turned that into 401s. Applying the schema.sql ALTER to the dev DB (what the next app rebuild does anyway) turned the suite green. Until make rebuild-app runs, the running app container serves the old binary. --- cmd/server/tests/resume_rewind_test.go | 123 ++++++++++++++++++++++++ docs/contract_pins.md | 17 ++++ docs/developer/api/users/get_profile.md | 2 + 3 files changed, 142 insertions(+) create mode 100644 cmd/server/tests/resume_rewind_test.go diff --git a/cmd/server/tests/resume_rewind_test.go b/cmd/server/tests/resume_rewind_test.go new file mode 100644 index 00000000..6f9fa9b7 --- /dev/null +++ b/cmd/server/tests/resume_rewind_test.go @@ -0,0 +1,123 @@ +package main + +// Resume rewind (per-user server setting): seconds before the saved +// position the audiobook/podcast player starts (0 = exact resume). +// Contract: the profile endpoint owns reads/writes with a strict +// 0/5/10 allow-list (the request shape is the keep_screen_awake string +// precedent — the form select must be able to send an explicit Off), +// and the chapters payload carries the value so every client (web + +// Android, incl. the Auto service) gets it with its existing fetch. +// All writes ride the fresh per-test regular user — the shared admin +// account is never touched (AGENTS doctrine). Fixture book lives in a +// test-named library that cascades away with the library delete. + +import ( + "encoding/json" + "fmt" + "net/http" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestAudioResumeRewind(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Resume Rewind %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + // 664 Hz tone: not the shared helper's 440 or the speed test's 552, + // so the scanner's content dedup never skips this book. + writeTestTrackTone(t, filepath.Join(folder, "Rewind Book", "01 one.mp3"), 664) + scanTestFolder(t, setup, admin, folder) + + var bookPath string + for _, item := range listLibraryAudioItems(t, setup, libID) { + if strings.Contains(filepath.ToSlash(item.FilePath), "Rewind Book") && bookPath == "" { + bookPath = item.FilePath + } + } + require.NotEmpty(t, bookPath, "Rewind Book item must exist") + book, found := findAudioItemByPath(t, setup, libID, bookPath) + require.True(t, found) + bookID := uuid.UUID(book.ID.Bytes) + + client := &http.Client{} + token := setup.RegularToken + authed := func(method, url, body string) (*http.Response, func()) { + var req *http.Request + if body == "" { + req, _ = http.NewRequest(method, url, nil) + } else { + req, _ = http.NewRequest(method, url, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+token) + resp, err := client.Do(req) + require.NoError(t, err) + return resp, func() { _ = resp.Body.Close() } + } + getPayload := func(itemID uuid.UUID) (rewind int) { + resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "") + defer closer() + require.Equal(t, http.StatusOK, resp.StatusCode) + var data struct { + ResumeRewindSeconds int `json:"resume_rewind_seconds"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&data)) + return data.ResumeRewindSeconds + } + getProfileRewind := func() int { + resp, closer := authed("GET", setup.Server.URL+"/api/auth/profile", "") + defer closer() + require.Equal(t, http.StatusOK, resp.StatusCode) + var profile struct { + AudioResumeRewind int `json:"audio_resume_rewind_seconds"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&profile)) + return profile.AudioResumeRewind + } + + t.Run("defaults to off (exact resume)", func(t *testing.T) { + require.Equal(t, 0, getProfileRewind()) + require.Equal(t, 0, getPayload(bookID)) + }) + + t.Run("profile PUT selects 5 and rides the chapters payload", func(t *testing.T) { + resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"5"}`) + defer closer() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 5, getProfileRewind()) + require.Equal(t, 5, getPayload(bookID)) + }) + + t.Run("non-allow-listed values are rejected and change nothing", func(t *testing.T) { + for _, body := range []string{`{"audio_resume_rewind_seconds":"7"}`, `{"audio_resume_rewind_seconds":"-5"}`, `{"audio_resume_rewind_seconds":"abc"}`} { + resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", body) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + closer() + } + require.Equal(t, 5, getProfileRewind()) + }) + + t.Run("explicit off resets to 0 everywhere", func(t *testing.T) { + resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"audio_resume_rewind_seconds":"0"}`) + defer closer() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 0, getProfileRewind()) + require.Equal(t, 0, getPayload(bookID)) + }) + + t.Run("JSON clients that omit the key leave it untouched", func(t *testing.T) { + resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"timezone":"UTC"}`) + defer closer() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 0, getProfileRewind()) + }) +} diff --git a/docs/contract_pins.md b/docs/contract_pins.md index a24eeaff..b329f5b5 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -720,3 +720,20 @@ the briefing acceptance now starts from the page's FIRST card (feed position 5 — the born-reversed default's book order) and chains into feed position 1. The acceptance meaning is unchanged: play from what you see, chained back-to-back within the live briefing set. + +## 2026-11-01 — audio resume rewind setting + +- `GET /api/media-items/:id/chapters` gained one ADDITIVE response key: + `resume_rewind_seconds` (int, the requesting user's setting; 0 for + anonymous). Existing keys unchanged. The web player and the Android + app both gate the value through the 0/5/10 allow-list, so servers + without the key (absent → 0) degrade to exact resume. +- `GET /api/auth/profile` now always carries + `audio_resume_rewind_seconds` (int, never omitted — 0 is a real + value, exact resume). `PUT /api/auth/profile` accepts the key as the + keep_screen_awake-style STRING ("0"/"5"/"10") so the profile form's + select can send an explicit Off; JSON clients that omit it leave the + column untouched, and anything outside {0,5,10} is 400. + Pinned (TestAudioResumeRewind): default 0, PUT "5" rides the + chapters payload, non-allow-listed rejections change nothing, + explicit "0" resets everywhere, omitted key leaves it untouched. diff --git a/docs/developer/api/users/get_profile.md b/docs/developer/api/users/get_profile.md index 344f0a4b..e1d2eb2a 100644 --- a/docs/developer/api/users/get_profile.md +++ b/docs/developer/api/users/get_profile.md @@ -36,6 +36,8 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... "first_name": "John", "last_name": "Doe", "role": "user", + "default_audio_speed": 1.5, + "audio_resume_rewind_seconds": 5, "keep_screen_awake": true } ```