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>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user