Files
mathiasandClaude Opus 4.8 a032c324f6
CI / Lint / Test / Vet (push) Successful in 6s
CI / Build & Import (push) Failing after 1s
CI / Mirror to GitHub (push) Has been skipped
feat(store): persist summary actions (watch/skip/save) — Stage-0 metric
The summary_actions table records the maintainer's act on a summary, the
column that makes the Stage-0 headline test ("acts on >=1 summary") queryable
(ui-spec.md §5, ADR-011). This is the gate lanes B/C build on.

- Migration 002: summary_actions (id, user_id, video_id TEXT, action, acted_at)
  with a CHECK on action IN ('watched','skipped','saved') and a UNIQUE
  (user_id, video_id, action). Per-user isolation: every row carries user_id.
- New actions.go: SetAction (idempotent, atomic watched<->skipped mutual
  exclusion in one tx; saved independent), ClearAction, ActionsFor for the list
  view, plus Go-side action validation.
- reads.go: additive SummaryRow.Actions, populated by composing ActionsFor
  (Go-side, not a SQL join — summaries.video_id is UUID, actions.video_id TEXT).
- embedded-postgres tests: set/clear, mutual exclusion, saved coexistence,
  idempotency, invalid rejection, user scoping, read-view surfacing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:36:55 +02:00

162 lines
4.8 KiB
Go

package store_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
func TestSetActionThenActionsFor(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
got, err := s.ActionsFor(ctx, userA, []string{videoX})
require.NoError(t, err)
require.Equal(t, map[string][]string{videoX: {"watched"}}, got)
}
func TestSetActionWatchedSkippedMutuallyExclusive(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
// Switching to skipped must clear watched (and vice versa).
require.NoError(t, s.SetAction(ctx, userA, videoX, "skipped"))
got, err := s.ActionsFor(ctx, userA, []string{videoX})
require.NoError(t, err)
require.Equal(t, map[string][]string{videoX: {"skipped"}}, got, "skipped replaces watched")
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
got, err = s.ActionsFor(ctx, userA, []string{videoX})
require.NoError(t, err)
require.Equal(t, map[string][]string{videoX: {"watched"}}, got, "watched replaces skipped")
}
func TestSetActionSavedCoexists(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
require.NoError(t, s.SetAction(ctx, userA, videoX, "saved"))
got, err := s.ActionsFor(ctx, userA, []string{videoX})
require.NoError(t, err)
// ActionsFor orders by action: saved, watched.
require.Equal(t, map[string][]string{videoX: {"saved", "watched"}}, got,
"saved is independent and coexists with watched")
}
func TestClearAction(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.NoError(t, s.SetAction(ctx, userA, videoX, "saved"))
require.NoError(t, s.ClearAction(ctx, userA, videoX, "saved"))
got, err := s.ActionsFor(ctx, userA, []string{videoX})
require.NoError(t, err)
require.Empty(t, got, "cleared action no longer active")
// Clearing an action that was never set is a harmless no-op.
require.NoError(t, s.ClearAction(ctx, userA, videoX, "watched"))
}
func TestSetActionIsIdempotent(t *testing.T) {
ctx := context.Background()
s := newStore(t)
p := rawPool(t)
resetDB(t, p)
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
var count int
require.NoError(t, p.QueryRow(ctx,
`SELECT count(*) FROM summary_actions
WHERE user_id = $1 AND video_id = $2 AND action = 'watched'`,
userA, videoX).Scan(&count))
require.Equal(t, 1, count, "re-setting must refresh, not duplicate")
}
func TestSetActionRejectsInvalid(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.Error(t, s.SetAction(ctx, userA, videoX, "bookmarked"))
require.Error(t, s.ClearAction(ctx, userA, videoX, ""))
}
func TestActionsAreUserScoped(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
got, err := s.ActionsFor(ctx, userB, []string{videoX})
require.NoError(t, err)
require.Empty(t, got, "user B must not see user A's actions")
}
func TestActionsForMultipleVideos(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
require.NoError(t, s.SetAction(ctx, userA, videoY, "saved"))
got, err := s.ActionsFor(ctx, userA, []string{videoX, videoY})
require.NoError(t, err)
require.Equal(t, map[string][]string{
videoX: {"watched"},
videoY: {"saved"},
}, got)
// Empty input -> empty (non-nil) map, no query.
empty, err := s.ActionsFor(ctx, userA, nil)
require.NoError(t, err)
require.Empty(t, empty)
}
func TestReadsSurfaceActions(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "body")))
require.NoError(t, s.SetAction(ctx, userA, videoX, "watched"))
require.NoError(t, s.SetAction(ctx, userA, videoX, "saved"))
list, err := s.ListSummaries(ctx, userA, 50)
require.NoError(t, err)
require.Len(t, list, 1)
require.Equal(t, []string{"saved", "watched"}, list[0].Actions, "list view carries current actions")
detail, err := s.GetSummaryByVideo(ctx, userA, videoX)
require.NoError(t, err)
require.Equal(t, []string{"saved", "watched"}, detail.Actions, "detail view carries current actions")
}
func TestReadsSurfaceNoActionsAsNil(t *testing.T) {
ctx := context.Background()
s := newStore(t)
resetDB(t, rawPool(t))
require.NoError(t, s.Deliver(ctx, summary(userA, videoX, "body")))
list, err := s.ListSummaries(ctx, userA, 50)
require.NoError(t, err)
require.Len(t, list, 1)
require.Nil(t, list[0].Actions, "no actions -> nil slice")
}