Files
bookhoard/cmd/server/tests/resume_rewind_test.go
T
John O'Keefe ef827a3a05
Release / build-and-push (push) Successful in 3m2s
test(api): pin the resume-rewind contract
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.
2026-11-01 19:38:08 -05:00

124 lines
4.6 KiB
Go

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())
})
}