feat(stats): per-device reading breakdown + device attribution on progress syncs

- Attribution: registered clients may now send their device token in
  X-Device-Token on PUT /api/media-items/:id/progress (the user JWT
  stays primary). The token must map to a device OWNED BY the caller —
  foreign/invalid/absent tokens soft-fail to plain web attribution so a
  stats decoration can never break a sync. Attributed pushes capture
  device_id (FK-safe), device_metadata.source = the registered device
  name ('Pixel 9', 'Kindle Paperwhite'), and last_sync surfaces show
  that device. Side effect worth having: app-vs-web pushes used to be
  LWW-invisible (both sources read 'web'); they now surface as
  legitimate cross-source progress conflicts when they diverge.
- /stats gains a By Device panel alongside By Library (grid 3-wide):
  reading minutes + sessions + books per registered device, plus one
  Web / unsynced bucket for device-less pushes so the panel always
  reconciles with the headline totals. Range-aware, family-lens
  scoped; renderBars shared between panels in stats.ts.
- The device identity IS the registered platform: devices rows already
  carry the name/type the client registered (app sends Build.MODEL +
  'mobile' at /api/devices/register, KOReader/Kobo likewise) — no new
  identity plumbing on the server.

Tests: capture attributed to the device (row device_id + source
metadata + last_sync_source), foreign/nonexistent tokens soft-fail,
device buckets reconcile (80 attributed + 75 unsynced = 155 total).
Full suite PASS.
This commit is contained in:
John O'Keefe committed 2026-10-14 13:04:34 -04:00
1 parent be10adc24b
commit 7a604c2fec
8 files changed
+399 -20

No files matched your search

+172 -6
View File
@@ -108,7 +108,115 @@ func TestReadingStatsCapture(t *testing.T) {
assert.InEpsilon(t, 0.15, progress.Percentage.Float64, 0.001, "capture never disturbs stored position")
}
func TestReadingStatsDeviceAttribution(t *testing.T) {
setup := setupTestServer(t)
userID := getTestUserID(t, setup.DB)
ctx := context.Background()
bookID := createTestMediaItemID(t, setup)
// A registered device owned by the test user, plus one owned by a
// DIFFERENT user (the foreign token must be ignored, never sent).
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgUser(userID),
DeviceName: "Pixel 9 Test",
DeviceType: "mobile",
DeviceIdentifier: "stats-test-android-" + uuid.NewString(),
AuthToken: "dev_test_token_" + uuid.NewString(),
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, device.ID) })
otherUser := getRegularUserID(t, setup.DB)
foreign, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgUser(otherUser),
DeviceName: "Foreign Device",
DeviceType: "mobile",
DeviceIdentifier: "stats-test-foreign-" + uuid.NewString(),
AuthToken: "dev_foreign_token_" + uuid.NewString(),
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, foreign.ID) })
push := func(body map[string]interface{}, deviceToken string) int {
payload, _ := json.Marshal(body)
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+bookID+"/progress", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
if deviceToken != "" {
req.Header.Set("X-Device-Token", deviceToken)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode
}
// Registered device push → attributed to the device with its name.
require.Equal(t, http.StatusOK, push(map[string]interface{}{
"percentage": 0.10, "current_page": 10, "total_pages": 100,
}, device.AuthToken))
rows, err := setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
UserID: pgUser(userID),
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
})
require.NoError(t, err)
require.Len(t, rows, 1)
require.True(t, rows[0].DeviceID.Valid)
assert.Equal(t, uuid.UUID(device.ID.Bytes).String(), uuid.UUID(rows[0].DeviceID.Bytes).String())
var meta struct {
Source string `json:"source"`
}
require.NoError(t, json.Unmarshal(rows[0].DeviceMetadata, &meta))
assert.Equal(t, "Pixel 9 Test", meta.Source, "capture metadata carries the registered device name")
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
UserID: pgUser(userID),
})
require.NoError(t, err)
assert.Equal(t, "Pixel 9 Test", progress.LastSyncSource.String, "last-sync surface shows the device name")
// Foreign device token → soft-fails: unattributed, plain web source.
require.Equal(t, http.StatusOK, push(map[string]interface{}{
"percentage": 0.20, "current_page": 20, "total_pages": 100,
}, "dev_foreign_token_"+uuid.NewString() /* nonexistent token */))
// A REAL foreign token must also be ignored, not adopted.
require.Equal(t, http.StatusOK, push(map[string]interface{}{
"percentage": 0.25, "current_page": 25, "total_pages": 100,
}, foreign.AuthToken))
rows, err = setup.DB.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
UserID: pgUser(userID),
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
})
require.NoError(t, err)
require.Len(t, rows, 3)
for _, row := range rows[1:] {
assert.False(t, row.DeviceID.Valid, "invalid/foreign tokens never attribute a device")
require.NoError(t, json.Unmarshal(row.DeviceMetadata, &meta))
assert.Equal(t, "web", meta.Source)
}
progress, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: uuidToPGType(uuid.MustParse(bookID)),
UserID: pgUser(userID),
})
require.NoError(t, err)
assert.Equal(t, "web", progress.LastSyncSource.String)
}
func TestReadingStatsSummary(t *testing.T) {
setup := setupTestServer(t)
userID := getTestUserID(t, setup.DB)
ctx := context.Context(context.Background())
@@ -117,7 +225,24 @@ func TestReadingStatsSummary(t *testing.T) {
libUUID := uuid.MustParse(libIDStr)
var finishedBookID, offRangeBookID string
insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, reuseMediaID ...string) string {
// A real registered device: attributed sessions carry its id (the
// rest fall into the "Web / unsynced" bucket).
seedDevice, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
UserID: pgUser(userID),
DeviceName: "Kindle Papered Test",
DeviceType: "koreader",
DeviceIdentifier: "stats-test-kindle-" + uuid.NewString(),
AuthToken: "dev_kindle_token_" + uuid.NewString(),
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
AutoSync: pgtype.Bool{Bool: true, Valid: true},
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
DeviceMetadata: []byte("{}"),
})
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteDevice(ctx, seedDevice.ID) })
deviceID := seedDevice.ID
insertSession := func(daysAgo int, minutes int32, pages int32, pct float64, title string, attributed bool, reuseMediaID ...string) string {
var media database.MediaItems
var err error
if len(reuseMediaID) > 0 && reuseMediaID[0] != "" {
@@ -134,10 +259,16 @@ func TestReadingStatsSummary(t *testing.T) {
require.NoError(t, err, "seed media item")
}
deviceIDArg := pgtype.UUID{}
if attributed {
deviceIDArg = deviceID
}
pushAt := middayDaysAgo(daysAgo)
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
UserID: pgUser(userID),
MediaItemID: media.ID,
DeviceID: deviceIDArg,
ProgressPercentage: pgFloat(pct),
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
ReadingSessionEnd: pgTS(pushAt),
@@ -151,11 +282,12 @@ func TestReadingStatsSummary(t *testing.T) {
return id.String()
}
finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book")
insertSession(1, 45, 20, 0.5, "Stats Streak Book")
insertSession(2, 60, 25, 0.42, "Stats Back Book")
insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", finishedBookID)
offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book")
// Device-split scenario: attributed (80 min) + unattributed (75 min).
finishedBookID = insertSession(0, 30, 12, 1.0, "Stats Finished Book", false)
insertSession(1, 45, 20, 0.5, "Stats Streak Book", false)
insertSession(2, 60, 25, 0.42, "Stats Back Book", true)
insertSession(2, 20, 8, 0.9, "Stats Finished Book (same book, second session)", true, finishedBookID)
offRangeBookID = insertSession(400, 120, 40, 0.2, "Stats OffRange Book", false)
// Summary -----------------------------------------------------------
resp, body := statsLogin(t, setup, "/api/stats/summary?range=7")
@@ -187,6 +319,13 @@ func TestReadingStatsSummary(t *testing.T) {
Name string `json:"name"`
Minutes int `json:"minutes"`
} `json:"library_breakdown"`
DeviceBreakdown []struct {
DeviceID string `json:"device_id"`
Name string `json:"name"`
Type string `json:"type"`
Minutes int `json:"minutes"`
Sessions int `json:"sessions"`
} `json:"device_breakdown"`
}
require.NoError(t, json.Unmarshal(body, &summary))
@@ -222,6 +361,33 @@ func TestReadingStatsSummary(t *testing.T) {
}
assert.True(t, found, "library breakdown aggregates the seeded library")
// Device breakdown: attributed (80) + unattributed (75) reconcile
// against the headline total; both buckets present.
require.Len(t, summary.DeviceBreakdown, 2, "one device bucket + one unattributed bucket")
var kindleFound, unattributedFound bool
for _, dev := range summary.DeviceBreakdown {
if dev.Name == "Kindle Papered Test" {
kindleFound = true
assert.Equal(t, 80, dev.Minutes, "60 + 20 attributed sessions")
assert.Equal(t, 2, dev.Sessions)
assert.NotEqual(t, "", dev.DeviceID, "device bucket carries its id")
assert.Equal(t, "koreader", dev.Type)
}
if dev.Name == "Web / unsynced" {
unattributedFound = true
assert.Equal(t, 75, dev.Minutes, "30 + 45 unattributed sessions")
assert.Equal(t, 2, dev.Sessions)
assert.Equal(t, "", dev.DeviceID)
}
}
assert.True(t, kindleFound, "the registered device has its own bucket")
assert.True(t, unattributedFound, "device-less pushes aggregate into 'Web / unsynced'")
deviceMinutes := 0
for _, dev := range summary.DeviceBreakdown {
deviceMinutes += dev.Minutes
}
assert.Equal(t, 155, deviceMinutes, "device buckets reconcile with the total")
// Range validation ---------------------------------------------------
badResp, badBody := statsLogin(t, setup, "/api/stats/summary?range=fortnight")
assert.Equal(t, http.StatusBadRequest, badResp.StatusCode)