feat(settings): add playback.intro_skip_mode and deprecate auto_skip_intro
Skipping intros stops being a switch and becomes a three-way choice —
never / ask / always — matching what Jellyfin offers and giving viewers a
way to turn the prompt off, which the boolean could not express.
Contract revision 6 → 7: adds playback.intro_skip_mode (enum, default
"ask", profile + profile_device scopes) and marks playback.auto_skip_intro
deprecated without removing it. Every shipped client still reads the
boolean, so for one release the server keeps the pair in step at write
time: canonical PUT/DELETE, the legacy /profiles route, and the legacy
runtime /settings/{key} route all land both rows, and a profile-scope enum
write refreshes user_profiles.auto_skip_intro so GET /profiles stays
truthful. Existing rows are carried onto the new key by a Goose migration
(Postgres) and an InitSchema twin (per-user SQLite); the settings-migrate
planner emits the companion for installs whose backfill runs later.
The spec in docs/design/2026-08-16-intro-skip-mode.md also defines the
prompt state machine every client (web, Android, Apple; browser, tablet,
mobile, TV) implements against this key. It builds on the Android TV
Skip Intro work in silo-android#210 — wall-clock timer, rebuffer-vs-pause
debounce, root-level key handling.
Co-authored-by: evulhotdog <365456+evulhotdog@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
evulhotdog
Claude Fable 5
parent
edd919c5f7
commit
af974edfe9
@@ -124,10 +124,21 @@ func planProfileSettingsSync(
|
||||
if field.raw == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, profileSettingSync{
|
||||
key: field.key,
|
||||
value: json.RawMessage(strconv.FormatBool(*field.raw)),
|
||||
})
|
||||
value := json.RawMessage(strconv.FormatBool(*field.raw))
|
||||
out = append(out, profileSettingSync{key: field.key, value: value})
|
||||
|
||||
// A key that has a replacement carries it along, so the legacy profile
|
||||
// route lands the same pair of rows the canonical route does. Without
|
||||
// this, a client that still sets auto_skip_intro through PUT /profiles
|
||||
// would leave intro_skip_mode resolving to the contract default and a
|
||||
// new client would read a preference nobody chose.
|
||||
mirror, ok, err := settingscontract.MirrorWrite(field.key, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", field.key, err)
|
||||
}
|
||||
if ok {
|
||||
out = append(out, profileSettingSync{key: mirror.Key, value: mirror.Value})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -170,6 +170,10 @@ func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
|
||||
settingskeys.PlaybackAutoSkipCredits: `true`,
|
||||
settingskeys.PlaybackAutoSkipRecap: `true`,
|
||||
settingskeys.PlaybackAutoPlayNextPreview: `false`,
|
||||
// auto_skip_intro carries its revision-7 replacement with it, so a
|
||||
// client still using the legacy route does not leave a current client
|
||||
// resolving the contract default.
|
||||
settingskeys.PlaybackIntroSkipMode: `"always"`,
|
||||
} {
|
||||
value := storedProfileSetting(t, store, key, "profile-1")
|
||||
if value == nil {
|
||||
@@ -196,6 +200,40 @@ func TestUpdateProfileSyncsSkipPreferences(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdateProfileSyncsIntroSkipMode covers both directions of the legacy
|
||||
// switch, including the one an untouched-looking false has to produce: "ask" is
|
||||
// the mode that reproduces what auto_skip_intro=false always did, and a profile
|
||||
// route that wrote only the boolean would leave the enum saying something else.
|
||||
func TestUpdateProfileSyncsIntroSkipMode(t *testing.T) {
|
||||
for _, tc := range []struct{ body, wantMode, wantBool string }{
|
||||
{`{"auto_skip_intro":true}`, `"always"`, `true`},
|
||||
{`{"auto_skip_intro":false}`, `"ask"`, `false`},
|
||||
} {
|
||||
t.Run(tc.body, func(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewProfileHandler(testUserStoreProvider{store: store})
|
||||
|
||||
if rr := updateProfileVia(t, handler, "profile-1", tc.body); rr.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
for key, want := range map[string]string{
|
||||
settingskeys.PlaybackIntroSkipMode: tc.wantMode,
|
||||
settingskeys.PlaybackAutoSkipIntro: tc.wantBool,
|
||||
} {
|
||||
value := storedProfileSetting(t, store, key, "profile-1")
|
||||
if value == nil {
|
||||
t.Errorf("no canonical %s row after the profile update", key)
|
||||
continue
|
||||
}
|
||||
if string(value.Value) != want {
|
||||
t.Errorf("canonical %s = %s, want %s", key, value.Value, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateProfileRejectsInvalidLanguageBeforeWriting: a value the canonical
|
||||
// endpoint would refuse must fail the request as a no-op instead of leaving
|
||||
// the column and the canonical store disagreeing.
|
||||
|
||||
@@ -1070,3 +1070,66 @@ func withRouteParams(req *http.Request, params map[string]string) *http.Request
|
||||
}
|
||||
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
}
|
||||
|
||||
// TestLegacyDeviceSettingCarriesIntroSkipMode closes the third write path onto
|
||||
// the intro-skip pair. The shipped apps save this switch through the legacy
|
||||
// generic route, not through /settings/values, so without the mirror in the
|
||||
// runtime plan an updated client would resolve the contract default while the
|
||||
// household's own device said otherwise.
|
||||
func TestLegacyDeviceSettingCarriesIntroSkipMode(t *testing.T) {
|
||||
store := newProfileTestStore(t)
|
||||
handler := NewSettingsHandler(testUserStoreProvider{store: store})
|
||||
|
||||
send := func(method string, body []byte) *httptest.ResponseRecorder {
|
||||
key := "playback.auto_skip_intro"
|
||||
req := httptest.NewRequest(method, "/settings/device/"+key, bytes.NewReader(body))
|
||||
req = withRouteParams(req, map[string]string{"key": key})
|
||||
req.Header.Set(deviceIDHeader, "living-room")
|
||||
req = req.WithContext(apimw.SetProfileID(
|
||||
apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7}), "profile-1"))
|
||||
rec := httptest.NewRecorder()
|
||||
if method == http.MethodPut {
|
||||
handler.HandleSetDeviceSetting(rec, req)
|
||||
} else {
|
||||
handler.HandleDeleteDeviceSetting(rec, req)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
canonical := func(key string) *userstore.SettingValue {
|
||||
t.Helper()
|
||||
value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
|
||||
Key: key, Scope: settingscontract.ScopeProfileDevice,
|
||||
ProfileID: "profile-1", DeviceID: "living-room",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettingValue(%s): %v", key, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ body, wantBool, wantMode string }{
|
||||
{`{"value":"true"}`, `true`, `"always"`},
|
||||
{`{"value":"false"}`, `false`, `"ask"`},
|
||||
} {
|
||||
if rec := send(http.MethodPut, []byte(tc.body)); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("PUT %s = %d: %s", tc.body, rec.Code, rec.Body.String())
|
||||
}
|
||||
if value := canonical("playback.auto_skip_intro"); value == nil || string(value.Value) != tc.wantBool {
|
||||
t.Fatalf("canonical auto_skip_intro after %s = %+v, want %s", tc.body, value, tc.wantBool)
|
||||
}
|
||||
if value := canonical("playback.intro_skip_mode"); value == nil || string(value.Value) != tc.wantMode {
|
||||
t.Fatalf("canonical intro_skip_mode after %s = %+v, want %s", tc.body, value, tc.wantMode)
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing through the same route has to reach both rows, or the companion
|
||||
// would go on overriding at a scope the device just gave up.
|
||||
if rec := send(http.MethodDelete, nil); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, key := range []string{"playback.auto_skip_intro", "playback.intro_skip_mode"} {
|
||||
if value := canonical(key); value != nil {
|
||||
t.Errorf("%s survived the legacy delete: %+v", key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,6 +813,21 @@ func (h *SettingValuesHandler) setValueAt(
|
||||
return
|
||||
}
|
||||
|
||||
// A deprecated key and its replacement are one preference stored twice
|
||||
// while old clients are in the field, so a write to either lands on both.
|
||||
// The value is already normalized, so a conversion failure here is a defect
|
||||
// in the pairing rather than bad input — refuse the request rather than
|
||||
// store half of it.
|
||||
mirror, hasMirror, err := settingscontract.MirrorWrite(identity.Key, normalized)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "settings mirror could not convert a normalized value",
|
||||
"component", "api", "key", identity.Key, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
|
||||
return
|
||||
}
|
||||
mirrorIdentity := identity
|
||||
mirrorIdentity.Key = mirror.Key
|
||||
|
||||
// Idempotency: a client that retries a write after a dropped response must
|
||||
// not double-apply it, and must be able to tell "already done" from "that
|
||||
// id means something else".
|
||||
@@ -821,12 +836,30 @@ func (h *SettingValuesHandler) setValueAt(
|
||||
var idempotentResult json.RawMessage
|
||||
if mutationID == "" {
|
||||
stored, err = store.UpsertSettingValue(r.Context(), identity, normalized)
|
||||
if err == nil && hasMirror {
|
||||
// No transaction on this path — the store's plain write API has
|
||||
// none — so the companion write is a second statement. Its failure
|
||||
// is the caller's failure: a request that reported success while
|
||||
// leaving the pair disagreeing is the exact drift the mirror
|
||||
// exists to prevent, and both writes are upserts, so the retry a
|
||||
// 500 provokes is safe.
|
||||
_, err = store.UpsertSettingValue(r.Context(), mirrorIdentity, mirror.Value)
|
||||
}
|
||||
} else {
|
||||
outcome, mutationErr := runIdempotentSettingMutation(
|
||||
r.Context(), store, mutationID, hashMutationRequest(identity, normalized),
|
||||
func(writer userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error) {
|
||||
value, err := writer.UpsertSettingValue(r.Context(), identity, normalized)
|
||||
return value, true, err
|
||||
if err != nil || !hasMirror {
|
||||
return value, true, err
|
||||
}
|
||||
// Same transaction as the primary write and the replay
|
||||
// receipt, so a replay re-serves the receipt without
|
||||
// re-applying either row.
|
||||
if _, err := writer.UpsertSettingValue(r.Context(), mirrorIdentity, mirror.Value); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return value, true, nil
|
||||
},
|
||||
)
|
||||
if errors.Is(mutationErr, errMutationIDConflict) {
|
||||
@@ -855,6 +888,13 @@ func (h *SettingValuesHandler) setValueAt(
|
||||
return
|
||||
}
|
||||
|
||||
// GET /profiles still serves auto_skip_intro from the legacy column, so a
|
||||
// profile-scope choice made through the new key has to reach it or the
|
||||
// profile DTO keeps reporting the preference the household abandoned.
|
||||
if !h.syncLegacyIntroSkipColumn(r, w, store, identity, mirror, hasMirror) {
|
||||
return
|
||||
}
|
||||
|
||||
response := settingValueToResponse(*stored)
|
||||
acting := actingProfileID(r.Context())
|
||||
if identity.Scope == settingscontract.ScopeProfileDevice {
|
||||
@@ -891,6 +931,52 @@ func (h *SettingValuesHandler) setValueAt(
|
||||
}
|
||||
}
|
||||
|
||||
// syncLegacyIntroSkipColumn mirrors a profile-scope playback.intro_skip_mode
|
||||
// write into user_profiles.auto_skip_intro, and reports whether the request may
|
||||
// continue.
|
||||
//
|
||||
// It is deliberately one-directional. The canonical write path does not
|
||||
// otherwise touch the legacy preference columns — the cutover direction is that
|
||||
// the profile DTO stops reading them, which it already has for the language and
|
||||
// subtitle fields — so this is not a general dual-write, only the narrow repair
|
||||
// that keeps the one DTO field still served from its column truthful while the
|
||||
// key that replaced it is the one clients write. A write of the deprecated
|
||||
// boolean itself is left alone, exactly as it is today.
|
||||
//
|
||||
// A failure fails the request for the same reason the companion row write does:
|
||||
// the caller asked for one preference change, and reporting success while half
|
||||
// of it landed is what makes the two halves drift.
|
||||
func (h *SettingValuesHandler) syncLegacyIntroSkipColumn(
|
||||
r *http.Request,
|
||||
w http.ResponseWriter,
|
||||
store userstore.UserStore,
|
||||
identity userstore.SettingIdentity,
|
||||
mirror settingscontract.MirroredWrite,
|
||||
hasMirror bool,
|
||||
) bool {
|
||||
if !hasMirror ||
|
||||
identity.Key != settingskeys.PlaybackIntroSkipMode ||
|
||||
identity.Scope != settingscontract.ScopeProfile {
|
||||
return true
|
||||
}
|
||||
|
||||
var enabled bool
|
||||
if err := json.Unmarshal(mirror.Value, &enabled); err != nil {
|
||||
slog.ErrorContext(r.Context(), "settings mirror produced a non-boolean auto_skip_intro",
|
||||
"component", "api", "profile_id", identity.ProfileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
|
||||
return false
|
||||
}
|
||||
if err := store.UpdateProfile(r.Context(), identity.ProfileID,
|
||||
userstore.UpdateProfileInput{AutoSkipIntro: &enabled}); err != nil {
|
||||
slog.ErrorContext(r.Context(), "failed to mirror intro skip mode into the profile column",
|
||||
"component", "api", "profile_id", identity.ProfileID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store the setting")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// registerWritingDevice refreshes the device registry from the request's
|
||||
// device headers after a successful profile_device write. Best effort and
|
||||
// throttled: the value write already succeeded, and the registry entry is
|
||||
@@ -965,6 +1051,22 @@ func (h *SettingValuesHandler) deleteValueAt(
|
||||
writeError(w, http.StatusNotFound, "not_found", "No value is set at this scope")
|
||||
return
|
||||
}
|
||||
// Clearing one half of a mirrored pair clears both: a surviving companion
|
||||
// row would go on resolving as an explicit choice at a scope the caller
|
||||
// just said it wanted to inherit at. Only attempted once the primary row
|
||||
// really went away, so a 404 for "nothing set here" still means nothing was
|
||||
// touched. A companion that was already absent removes nothing, which is
|
||||
// success.
|
||||
if mirrorKey, ok := settingscontract.MirrorKey(identity.Key); ok {
|
||||
mirrorIdentity := identity
|
||||
mirrorIdentity.Key = mirrorKey
|
||||
if _, err := store.DeleteSettingValue(r.Context(), mirrorIdentity); err != nil {
|
||||
slog.ErrorContext(r.Context(), "failed to clear the mirrored setting",
|
||||
"component", "api", "key", identity.Key, "mirror_key", mirrorKey, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to clear the setting")
|
||||
return
|
||||
}
|
||||
}
|
||||
auditSettingsForOther(r.Context(), settingsAuditRecord{
|
||||
Action: "clear",
|
||||
ActorProfileID: actingProfileID(r.Context()),
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// The intro-skip pair is one preference stored under two keys for one release:
|
||||
// revision 7 replaced the boolean playback.auto_skip_intro with the three-way
|
||||
// playback.intro_skip_mode, and every shipped client still reads the boolean.
|
||||
// These tests hold the canonical write path to keeping the two in step, because
|
||||
// a household whose two keys disagree gets a different intro behavior on every
|
||||
// device it owns.
|
||||
|
||||
// storedValueAt reads one explicit row, whatever scope it lives at.
|
||||
func storedValueAt(
|
||||
t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity,
|
||||
) *userstore.SettingValue {
|
||||
t.Helper()
|
||||
value, err := store.GetSettingValue(context.Background(), identity)
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s at %s: %v", identity.Key, identity.Scope, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func profileIdentity(key string) userstore.SettingIdentity {
|
||||
return userstore.SettingIdentity{
|
||||
Key: key, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
}
|
||||
}
|
||||
|
||||
func deviceIdentity(key string) userstore.SettingIdentity {
|
||||
return userstore.SettingIdentity{
|
||||
Key: key, Scope: settingscontract.ScopeProfileDevice,
|
||||
ProfileID: "profile-1", DeviceID: "device-1",
|
||||
}
|
||||
}
|
||||
|
||||
// requireStored asserts a row exists and holds want.
|
||||
func requireStored(
|
||||
t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity, want string,
|
||||
) {
|
||||
t.Helper()
|
||||
value := storedValueAt(t, store, identity)
|
||||
if value == nil {
|
||||
t.Fatalf("no %s row at %s", identity.Key, identity.Scope)
|
||||
}
|
||||
if string(value.Value) != want {
|
||||
t.Errorf("%s at %s = %s, want %s", identity.Key, identity.Scope, value.Value, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWritingTheDeprecatedBooleanMirrorsTheEnum: an unmigrated client saves the
|
||||
// switch and a current client must read the mode the household actually chose,
|
||||
// not the contract default.
|
||||
func TestWritingTheDeprecatedBooleanMirrorsTheEnum(t *testing.T) {
|
||||
for name, tc := range map[string]struct{ body, wantMode string }{
|
||||
"on": {`{"value":true}`, `"always"`},
|
||||
"off": {`{"value":false}`, `"ask"`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
rec := routeValues(t, handler, http.MethodPut,
|
||||
settingskeys.PlaybackAutoSkipIntro, "scope=profile", []byte(tc.body))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), tc.wantMode)
|
||||
|
||||
// The response is the key the caller addressed, unchanged: the
|
||||
// mirror is a side effect, not a redirect.
|
||||
var response settingValueResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decoding response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
if response.Key != settingskeys.PlaybackAutoSkipIntro {
|
||||
t.Errorf("response key = %s, want the key that was written", response.Key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWritingTheEnumMirrorsTheDeprecatedBoolean covers all three modes,
|
||||
// including the one the boolean cannot express.
|
||||
func TestWritingTheEnumMirrorsTheDeprecatedBoolean(t *testing.T) {
|
||||
for _, tc := range []struct{ mode, wantBool string }{
|
||||
{"never", `false`},
|
||||
{"ask", `false`},
|
||||
{"always", `true`},
|
||||
} {
|
||||
t.Run(tc.mode, func(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
rec := routeValues(t, handler, http.MethodPut,
|
||||
settingskeys.PlaybackIntroSkipMode, "scope=profile",
|
||||
[]byte(`{"value":"`+tc.mode+`"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT %q = %d: %s", tc.mode, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode),
|
||||
`"`+tc.mode+`"`)
|
||||
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), tc.wantBool)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheMirrorFollowsTheIdentity, not just the key: a device override must not
|
||||
// silently become a profile-wide one.
|
||||
func TestTheMirrorFollowsTheIdentity(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
rec := routeValues(t, handler, http.MethodPut,
|
||||
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", []byte(`{"value":"always"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("device PUT = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
requireStored(t, store, deviceIdentity(settingskeys.PlaybackAutoSkipIntro), `true`)
|
||||
if value := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro)); value != nil {
|
||||
t.Errorf("a device write left a profile-scope mirror row: %s", value.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeletingEitherHalfClearsBoth. A row left behind would go on resolving as
|
||||
// an explicit choice at the very scope the caller asked to inherit at.
|
||||
func TestDeletingEitherHalfClearsBoth(t *testing.T) {
|
||||
for _, deleted := range []string{
|
||||
settingskeys.PlaybackIntroSkipMode,
|
||||
settingskeys.PlaybackAutoSkipIntro,
|
||||
} {
|
||||
t.Run(deleted, func(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
if rec := routeValues(t, handler, http.MethodPut,
|
||||
settingskeys.PlaybackIntroSkipMode, "scope=profile",
|
||||
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
rec := routeValues(t, handler, http.MethodDelete, deleted, "scope=profile", nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("DELETE %s = %d: %s", deleted, rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
for _, key := range []string{
|
||||
settingskeys.PlaybackIntroSkipMode,
|
||||
settingskeys.PlaybackAutoSkipIntro,
|
||||
} {
|
||||
if value := storedValueAt(t, store, profileIdentity(key)); value != nil {
|
||||
t.Errorf("%s survived a DELETE of %s: %s", key, deleted, value.Value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeletingAnAbsentValueTouchesNeitherHalf: the 404 for "nothing set here"
|
||||
// still has to mean nothing was written, mirror included.
|
||||
func TestDeletingAnAbsentValueTouchesNeitherHalf(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
if rec := routeValues(t, handler, http.MethodPut,
|
||||
settingskeys.PlaybackAutoSkipIntro, "scope=profile",
|
||||
[]byte(`{"value":true}`)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("seed PUT = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Nothing was ever written at device scope, so this clears nothing —
|
||||
// including the profile-scope pair, which belongs to another identity.
|
||||
rec := routeValues(t, handler, http.MethodDelete,
|
||||
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("DELETE of an unset device value = %d, want 404: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
requireStored(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro), `true`)
|
||||
requireStored(t, store, profileIdentity(settingskeys.PlaybackIntroSkipMode), `"always"`)
|
||||
}
|
||||
|
||||
// TestMirroredWritesReplayInsteadOfDoubleApplying. The mirror rides inside the
|
||||
// mutation's transaction, so a retried write must re-serve the receipt and
|
||||
// leave both rows at the revision the first write produced.
|
||||
func TestMirroredWritesReplayInsteadOfDoubleApplying(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
send := func(mutationID, value string) *httptest.ResponseRecorder {
|
||||
req := valuesRequest(http.MethodPut,
|
||||
"/settings/values/"+settingskeys.PlaybackIntroSkipMode+"?scope=profile",
|
||||
[]byte(`{"value":`+value+`}`))
|
||||
req.Header.Set(mutationIDHeader, mutationID)
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("key", settingskeys.PlaybackIntroSkipMode)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleSetValue(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
if rec := send("mut-intro", `"always"`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("first write = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
mirrored := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro))
|
||||
if mirrored == nil {
|
||||
t.Fatal("the first write stored no mirror row")
|
||||
}
|
||||
|
||||
replay := send("mut-intro", `"always"`)
|
||||
if replay.Code != http.StatusOK || replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
|
||||
t.Fatalf("replay = %d header %q: %s",
|
||||
replay.Code, replay.Header().Get("X-Silo-Idempotent-Replay"), replay.Body.String())
|
||||
}
|
||||
|
||||
after := storedValueAt(t, store, profileIdentity(settingskeys.PlaybackAutoSkipIntro))
|
||||
if after == nil {
|
||||
t.Fatal("the replay removed the mirror row")
|
||||
}
|
||||
if after.Revision != mirrored.Revision {
|
||||
t.Errorf("mirror revision moved from %d to %d on replay; the replay wrote again",
|
||||
mirrored.Revision, after.Revision)
|
||||
}
|
||||
if string(after.Value) != `true` {
|
||||
t.Errorf("mirror value = %s after replay, want true", after.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfileScopeEnumWriteUpdatesTheLegacyColumn. GET /profiles still serves
|
||||
// auto_skip_intro from user_profiles, so a household that switches intros off
|
||||
// through the new key must not keep seeing the old answer in the profile DTO.
|
||||
func TestProfileScopeEnumWriteUpdatesTheLegacyColumn(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
if err := store.UpdateProfile(context.Background(), "profile-1",
|
||||
userstore.UpdateProfileInput{AutoSkipIntro: boolPtr(true)}); err != nil {
|
||||
t.Fatalf("seeding the column: %v", err)
|
||||
}
|
||||
|
||||
rec := routeValues(t, handler, http.MethodPut,
|
||||
settingskeys.PlaybackIntroSkipMode, "scope=profile", []byte(`{"value":"never"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
profile, err := store.GetProfile(context.Background(), "profile-1")
|
||||
if err != nil || profile == nil {
|
||||
t.Fatalf("reading profile: %v", err)
|
||||
}
|
||||
if profile.AutoSkipIntro {
|
||||
t.Error("user_profiles.auto_skip_intro still true after intro_skip_mode was set to never")
|
||||
}
|
||||
|
||||
// And back the other way, so the column tracks the mode rather than only
|
||||
// ever being cleared.
|
||||
if rec := routeValues(t, handler, http.MethodPut,
|
||||
settingskeys.PlaybackIntroSkipMode, "scope=profile",
|
||||
[]byte(`{"value":"always"}`)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("second PUT = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
profile, err = store.GetProfile(context.Background(), "profile-1")
|
||||
if err != nil || profile == nil {
|
||||
t.Fatalf("re-reading profile: %v", err)
|
||||
}
|
||||
if !profile.AutoSkipIntro {
|
||||
t.Error("user_profiles.auto_skip_intro still false after intro_skip_mode was set to always")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeviceScopeEnumWriteLeavesTheProfileColumnAlone: the column is
|
||||
// profile-wide, and one television's override is not the household's choice.
|
||||
func TestDeviceScopeEnumWriteLeavesTheProfileColumnAlone(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
rec := routeValues(t, handler, http.MethodPut,
|
||||
settingskeys.PlaybackIntroSkipMode, "scope=profile_device", []byte(`{"value":"always"}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
profile, err := store.GetProfile(context.Background(), "profile-1")
|
||||
if err != nil || profile == nil {
|
||||
t.Fatalf("reading profile: %v", err)
|
||||
}
|
||||
if profile.AutoSkipIntro {
|
||||
t.Error("a device-scope write moved the profile-wide column")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user