The family-lens hardening: library_visibility was only enforced where file bytes flowed (download/reader/OPDS) — metadata surfaces leaked hidden libraries to any authenticated account, and registration was open by default with no admin-side user creation. - Series are grouped PER LIBRARY now (same name in two libraries = two entries: manga adaptations / LN splits no longer merge); /api/series aggregates only the requester's VISIBLE libraries and /api/series/books REQUIRES a visible library_id (breaking contract; web + app clients updated in the same change). - /api/media-items, item detail, and the reader content endpoints (chapters/panels/outline/THUMBNAILS — page images leaked unguarded next to a gated GetPage) all enforce the visible set; resolveLibrary no longer honors a hand-typed library_id outside the visible set. - Annotation/note/bookmark mutations (update/delete/tombstone, sync LWW paths incl. conflict resolution) carry ownership predicates: another account's bare row id is a 404, not a silent delete. - Admin user creation: POST /api/auth/users (AdminMiddleware) + a Create User card in the admin Users UI — accounts stay creatable with self-service registration OFF, which is now the schema default (fresh installs; the first-admin bootstrap exception is unchanged). - Tests: series contract re-pinned (per-library, visibility 403s), new ownership + admin-create integration suites, OPDS all=1 test now passes an explicit page size (shared-DB growth made the fresh books fall off page 1), device-cap helper switched to the admin endpoint.
838 lines
30 KiB
Go
838 lines
30 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookhoard/internal/database"
|
|
wsync "bookhoard/internal/sync"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func pFloat64(v float64) *float64 { return &v }
|
|
func pStr(v string) *string { return &v }
|
|
func pInt(v int) *int { return &v }
|
|
func pInt64(v int64) *int64 { return &v }
|
|
|
|
func doReq(t *testing.T, method, url string, body interface{}, token string) *http.Response {
|
|
t.Helper()
|
|
var bodyReader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
require.NoError(t, err)
|
|
bodyReader = bytes.NewBuffer(b)
|
|
}
|
|
req, err := http.NewRequest(method, url, bodyReader)
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := (&http.Client{}).Do(req)
|
|
require.NoError(t, err)
|
|
return resp
|
|
}
|
|
|
|
func decodeJSON(t *testing.T, resp *http.Response) map[string]interface{} {
|
|
t.Helper()
|
|
var result map[string]interface{}
|
|
err := json.NewDecoder(resp.Body).Decode(&result)
|
|
require.NoError(t, err)
|
|
return result
|
|
}
|
|
|
|
func progressURL(serverURL, mediaItemID string) string {
|
|
return serverURL + "/api/media-items/" + mediaItemID + "/progress"
|
|
}
|
|
|
|
func getFloatField(t *testing.T, data map[string]interface{}, field string) float64 {
|
|
t.Helper()
|
|
val, ok := data[field]
|
|
require.True(t, ok, "%s should be present in response", field)
|
|
require.NotNil(t, val, "%s should not be null", field)
|
|
f, ok := val.(float64)
|
|
require.True(t, ok, "%s should be a number, got %T: %v", field, val, val)
|
|
return f
|
|
}
|
|
|
|
func getStringField(t *testing.T, data map[string]interface{}, field string) string {
|
|
t.Helper()
|
|
val, ok := data[field]
|
|
require.True(t, ok, "%s should be present in response", field)
|
|
require.NotNil(t, val, "%s should not be null", field)
|
|
s, ok := val.(string)
|
|
require.True(t, ok, "%s should be a string, got %T: %v", field, val, val)
|
|
return s
|
|
}
|
|
|
|
func TestProgressWeb_AuthContexts(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
url := progressURL(setup.Server.URL, mediaItemID)
|
|
|
|
t.Run("unauthenticated PUT returns 401", func(t *testing.T) {
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.5,
|
|
}, "")
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("unauthenticated GET returns 401", func(t *testing.T) {
|
|
resp := doReq(t, "GET", url, nil, "")
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("regular user PUT succeeds", func(t *testing.T) {
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.3,
|
|
}, setup.RegularToken)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("regular user GET succeeds", func(t *testing.T) {
|
|
resp := doReq(t, "GET", url, nil, setup.RegularToken)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("admin PUT succeeds", func(t *testing.T) {
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.7,
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("admin GET succeeds", func(t *testing.T) {
|
|
resp := doReq(t, "GET", url, nil, setup.Token)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("invalid media item ID returns 400", func(t *testing.T) {
|
|
badURL := setup.Server.URL + "/api/media-items/not-a-uuid/progress"
|
|
resp := doReq(t, "GET", badURL, nil, setup.Token)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("nonexistent media item GET returns 200 with empty", func(t *testing.T) {
|
|
fakeID := uuid.New().String()
|
|
fakeURL := progressURL(setup.Server.URL, fakeID)
|
|
resp := doReq(t, "GET", fakeURL, nil, setup.Token)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
}
|
|
|
|
func TestProgressWeb_MergePreservesFields(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
url := progressURL(setup.Server.URL, mediaItemID)
|
|
|
|
t.Run("second PUT with only percentage preserves epubcfi and chapter from first", func(t *testing.T) {
|
|
resp1 := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.3,
|
|
"epubcfi": "epubcfi(/6/4/2:first)",
|
|
"chapter": 2,
|
|
}, setup.Token)
|
|
defer resp1.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp1.StatusCode)
|
|
|
|
resp2 := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.5,
|
|
}, setup.Token)
|
|
defer resp2.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
|
|
|
getResp := doReq(t, "GET", url, nil, setup.Token)
|
|
defer getResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
|
|
|
result := decodeJSON(t, getResp)
|
|
pct := getFloatField(t, result, "percentage")
|
|
assert.InDelta(t, 0.5, pct, 0.01)
|
|
|
|
epubcfi := getStringField(t, result, "epubcfi")
|
|
assert.Equal(t, "epubcfi(/6/4/2:first)", epubcfi, "epubcfi should be preserved from first save")
|
|
|
|
chapter := getFloatField(t, result, "chapter")
|
|
assert.Equal(t, float64(2), chapter, "chapter should be preserved from first save")
|
|
})
|
|
|
|
t.Run("web save preserves koreader character_offset", func(t *testing.T) {
|
|
mediaItemID2 := createTestMediaItemID(t, setup)
|
|
url2 := progressURL(setup.Server.URL, mediaItemID2)
|
|
mediaUUID, _ := uuid.Parse(mediaItemID2)
|
|
userID := getTestUserID(t, setup.DB)
|
|
ctx := context.Background()
|
|
|
|
charOffset := int64(15000)
|
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Source: "koreader",
|
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Percentage: pFloat64(0.45),
|
|
CharacterOffset: &charOffset,
|
|
Chapter: pInt(5),
|
|
DeviceType: "koreader",
|
|
DeviceName: "KOReader Test",
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
resp := doReq(t, "PUT", url2, map[string]interface{}{
|
|
"percentage": 0.5,
|
|
"epubcfi": "epubcfi(/6/4/2:10)",
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be preserved")
|
|
assert.Equal(t, int64(15000), progress.CharacterOffset.Int64)
|
|
assert.True(t, progress.Chapter.Valid, "chapter should be preserved")
|
|
assert.Equal(t, int32(5), progress.Chapter.Int32)
|
|
assert.InDelta(t, 0.5, progress.Percentage.Float64, 0.001)
|
|
assert.Equal(t, "web", progress.LastSyncSource.String)
|
|
})
|
|
}
|
|
|
|
func TestProgressWeb_EnrichmentComputesFields(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
url := progressURL(setup.Server.URL, mediaItemID)
|
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
|
userID := getTestUserID(t, setup.DB)
|
|
ctx := context.Background()
|
|
|
|
t.Run("character_offset computed from percentage when total_characters set", func(t *testing.T) {
|
|
_, err := setup.DBPool.Exec(ctx, "UPDATE media_items SET total_characters = $1 WHERE id = $2", int64(200000), mediaUUID)
|
|
require.NoError(t, err)
|
|
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.5,
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be computed from percentage")
|
|
assert.Equal(t, int64(100000), progress.CharacterOffset.Int64)
|
|
})
|
|
|
|
t.Run("GET returns enriched format_group and total_characters", func(t *testing.T) {
|
|
getResp := doReq(t, "GET", url, nil, setup.Token)
|
|
defer getResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
|
|
|
result := decodeJSON(t, getResp)
|
|
_, hasFormatGroup := result["format_group"]
|
|
assert.True(t, hasFormatGroup, "format_group should be present in GET response")
|
|
_, hasTotalChars := result["total_characters"]
|
|
assert.True(t, hasTotalChars, "total_characters should be present in GET response")
|
|
})
|
|
}
|
|
|
|
func TestProgressWeb_ConflictDetection(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
|
userID := getTestUserID(t, setup.DB)
|
|
ctx := context.Background()
|
|
|
|
t.Run("different sources with >1% diff within 5min creates conflict record", func(t *testing.T) {
|
|
pct1 := 0.3
|
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Source: "koreader",
|
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Percentage: &pct1,
|
|
DeviceType: "koreader",
|
|
DeviceName: "KOReader",
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
pct2 := 0.6
|
|
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Source: "kobo",
|
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Percentage: &pct2,
|
|
DeviceType: "kobo",
|
|
DeviceName: "Kobo",
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001)
|
|
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
|
|
|
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, conflicts, "conflict should be recorded in sync_conflicts table")
|
|
assert.Equal(t, "progress", conflicts[0].ConflictType)
|
|
assert.True(t, conflicts[0].ResolutionStatus.Valid)
|
|
assert.Equal(t, "unresolved", conflicts[0].ResolutionStatus.String)
|
|
})
|
|
|
|
mediaItemID2 := createTestMediaItemID(t, setup)
|
|
mediaUUID2, _ := uuid.Parse(mediaItemID2)
|
|
|
|
t.Run("same source rapid saves create no conflict", func(t *testing.T) {
|
|
for _, pct := range []float64{0.1, 0.3, 0.5, 0.7} {
|
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Source: "web",
|
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Percentage: &pct,
|
|
DeviceType: "web",
|
|
DeviceName: "Web",
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, conflicts, "same-source saves should not create conflicts")
|
|
})
|
|
|
|
mediaItemID3 := createTestMediaItemID(t, setup)
|
|
mediaUUID3, _ := uuid.Parse(mediaItemID3)
|
|
|
|
t.Run("different sources with <1% diff creates no conflict", func(t *testing.T) {
|
|
pct1 := 0.5
|
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Source: "koreader",
|
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Percentage: &pct1,
|
|
DeviceType: "koreader",
|
|
DeviceName: "KOReader",
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
pct2 := 0.505
|
|
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Source: "kobo",
|
|
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Percentage: &pct2,
|
|
DeviceType: "kobo",
|
|
DeviceName: "Kobo",
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Empty(t, conflicts, "small percentage diff should not create conflict")
|
|
})
|
|
}
|
|
|
|
func TestProgressWeb_KoboIntegration(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
ctx := context.Background()
|
|
userID := getTestUserID(t, setup.DB)
|
|
|
|
koboDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
|
UserID: pgUserID,
|
|
DeviceName: "Test Kobo Progress",
|
|
DeviceType: "kobo",
|
|
DeviceIdentifier: "kobo-progress-test",
|
|
AuthToken: koboDeviceToken,
|
|
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, "Should create kobo device")
|
|
_ = device
|
|
|
|
t.Run("unauthenticated Kobo markup returns 401", func(t *testing.T) {
|
|
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/kobo/invalid-token/markup", map[string]interface{}{
|
|
"ReadingSync": []map[string]interface{}{
|
|
{"ContentId": uuid.New().String(), "PercentRead": 50.0},
|
|
},
|
|
}, "")
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
})
|
|
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
|
|
t.Run("ReadingSync then last-read-place preserves percentage", func(t *testing.T) {
|
|
readingSyncBody := map[string]interface{}{
|
|
"ReadingSync": []map[string]interface{}{
|
|
{
|
|
"ContentId": mediaItemID,
|
|
"PercentRead": 55.0,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), readingSyncBody, "")
|
|
defer resp.Body.Close()
|
|
io.ReadAll(resp.Body)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
bookmarkBody := map[string]interface{}{
|
|
"BookmarkSync": []map[string]interface{}{
|
|
{
|
|
"ContentId": mediaItemID,
|
|
"BookmarkId": "epubcfi(/6/4!/4/2/1:0)",
|
|
"BookmarkType": "last-read-place",
|
|
"Chapter": 5,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp2 := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
|
|
defer resp2.Body.Close()
|
|
io.ReadAll(resp2.Body)
|
|
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
|
|
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, progress.Percentage.Valid)
|
|
assert.InDelta(t, 0.55, progress.Percentage.Float64, 0.01, "percentage should still be 55% from ReadingSync")
|
|
})
|
|
|
|
t.Run("Kobo last-read-place without prior ReadingSync sets epubcfi and chapter", func(t *testing.T) {
|
|
newMediaID := createTestMediaItemID(t, setup)
|
|
newMediaUUID, _ := uuid.Parse(newMediaID)
|
|
|
|
bookmarkBody := map[string]interface{}{
|
|
"BookmarkSync": []map[string]interface{}{
|
|
{
|
|
"ContentId": newMediaID,
|
|
"BookmarkId": "epubcfi(/6/14!/4/2/1:0)",
|
|
"BookmarkType": "last-read-place",
|
|
"Chapter": 3,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
|
|
defer resp.Body.Close()
|
|
io.ReadAll(resp.Body)
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: newMediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, progress.Epubcfi.Valid, "epubcfi should be set from last-read-place")
|
|
assert.True(t, progress.Chapter.Valid, "chapter should be set from last-read-place")
|
|
assert.Equal(t, int32(3), progress.Chapter.Int32)
|
|
})
|
|
}
|
|
|
|
func TestProgressWeb_KOReaderIntegration(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
userID := getTestUserID(t, setup.DB)
|
|
ctx := context.Background()
|
|
|
|
koreaderDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
_, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
|
UserID: pgUserID,
|
|
DeviceName: "Test KOReader Progress",
|
|
DeviceType: "koreader",
|
|
DeviceIdentifier: "koreader-progress-test",
|
|
AuthToken: koreaderDeviceToken,
|
|
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, "Should create koreader device")
|
|
|
|
t.Run("KOReader progress sync via HTTP", func(t *testing.T) {
|
|
progressBody := map[string]interface{}{
|
|
"books": []map[string]interface{}{
|
|
{
|
|
"file_path": "/tmp/test.epub",
|
|
"percentage": 0.42,
|
|
"chapter": 3,
|
|
"device_info": map[string]interface{}{
|
|
"device_model": "Test Device",
|
|
"koreader_version": "1.0",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
bodyBytes, _ := json.Marshal(progressBody)
|
|
req, err := http.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(bodyBytes))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+koreaderDeviceToken)
|
|
|
|
resp, err := (&http.Client{}).Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
io.ReadAll(resp.Body)
|
|
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
|
|
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
})
|
|
if err == nil {
|
|
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
|
|
}
|
|
})
|
|
|
|
t.Run("unauthenticated KOReader sync returns 401", func(t *testing.T) {
|
|
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", map[string]interface{}{
|
|
"books": []map[string]interface{}{
|
|
{"file_path": "/tmp/test.epub", "percentage": 0.5},
|
|
},
|
|
}, "")
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
})
|
|
}
|
|
|
|
func TestProgressWeb_DeleteProgress(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
url := progressURL(setup.Server.URL, mediaItemID)
|
|
|
|
t.Run("DELETE removes progress", func(t *testing.T) {
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.75,
|
|
"epubcfi": "epubcfi(/6/4/2:20)",
|
|
"chapter": 7,
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
delResp := doReq(t, "DELETE", url, nil, setup.Token)
|
|
defer delResp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, delResp.StatusCode)
|
|
|
|
getResp := doReq(t, "GET", url, nil, setup.Token)
|
|
defer getResp.Body.Close()
|
|
result := decodeJSON(t, getResp)
|
|
assert.Equal(t, float64(0), result["current_page"], "progress should be cleared after delete")
|
|
})
|
|
|
|
t.Run("DELETE without auth returns 401", func(t *testing.T) {
|
|
resp := doReq(t, "DELETE", url, nil, "")
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
|
})
|
|
}
|
|
|
|
func TestProgressWeb_EdgeCases(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
url := progressURL(setup.Server.URL, mediaItemID)
|
|
|
|
t.Run("PUT with empty body still succeeds", func(t *testing.T) {
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{}, setup.Token)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("PUT percentage 0.0", func(t *testing.T) {
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 0.0,
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
getResp := doReq(t, "GET", url, nil, setup.Token)
|
|
defer getResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
|
})
|
|
|
|
t.Run("PUT percentage 1.0", func(t *testing.T) {
|
|
resp := doReq(t, "PUT", url, map[string]interface{}{
|
|
"percentage": 1.0,
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
})
|
|
|
|
t.Run("PUT with all fields then GET verifies each", func(t *testing.T) {
|
|
mediaItemID2 := createTestMediaItemID(t, setup)
|
|
url2 := progressURL(setup.Server.URL, mediaItemID2)
|
|
mediaUUID2, _ := uuid.Parse(mediaItemID2)
|
|
userID := getTestUserID(t, setup.DB)
|
|
ctx := context.Background()
|
|
|
|
resp := doReq(t, "PUT", url2, map[string]interface{}{
|
|
"percentage": 0.42,
|
|
"current_page": 84,
|
|
"total_pages": 200,
|
|
"epubcfi": "epubcfi(/6/4!/4/2/1:0)",
|
|
"chapter": 3,
|
|
"chapter_progress": 0.5,
|
|
"character_offset": 15000,
|
|
"reading_mode": "page",
|
|
"zoom_level": 1.5,
|
|
"scroll_position_x": 0.0,
|
|
"scroll_position_y": 100.0,
|
|
}, setup.Token)
|
|
defer resp.Body.Close()
|
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
|
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, progress.Percentage.Valid)
|
|
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
|
|
assert.True(t, progress.Chapter.Valid)
|
|
assert.Equal(t, int32(3), progress.Chapter.Int32)
|
|
assert.True(t, progress.Epubcfi.Valid)
|
|
assert.Equal(t, "epubcfi(/6/4!/4/2/1:0)", progress.Epubcfi.String)
|
|
assert.True(t, progress.CurrentPage.Valid)
|
|
assert.Equal(t, int32(84), progress.CurrentPage.Int32)
|
|
assert.True(t, progress.TotalPages.Valid)
|
|
assert.Equal(t, int32(200), progress.TotalPages.Int32)
|
|
assert.True(t, progress.ReadingMode.Valid)
|
|
assert.Equal(t, "page", progress.ReadingMode.String)
|
|
assert.True(t, progress.ZoomLevel.Valid)
|
|
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
|
|
})
|
|
}
|
|
|
|
// TestDeletedUserTokenRejected pins the ghost-session guard: a
|
|
// signature-valid JWT for a user that has been DELETED must stop
|
|
// authenticating (401 user_not_found), not serve a phantom session.
|
|
// Before the guard, deleted users kept full API access until token
|
|
// expiry (observed live on hardware 2026-10-09).
|
|
func TestDeletedUserTokenRejected(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
ctx := context.Background()
|
|
|
|
// Create a dedicated user, then log in as it to get a fresh token.
|
|
// Admin-created (not self-registered) so the test stays deterministic
|
|
// regardless of the registration_enabled setting.
|
|
createResp := doReq(t, "POST", setup.Server.URL+"/api/auth/users", map[string]interface{}{
|
|
"email": "ghost-test@tests.bookhoard.internal",
|
|
"username": "ghosttest",
|
|
"password": "Ghost@Pass123!",
|
|
}, setup.Token)
|
|
defer createResp.Body.Close()
|
|
if createResp.StatusCode != http.StatusCreated {
|
|
t.Skipf("admin user creation unavailable (status %d)", createResp.StatusCode)
|
|
}
|
|
|
|
loginResp := doReq(t, "POST", setup.Server.URL+"/api/auth/login", map[string]interface{}{
|
|
"login": "ghost-test@tests.bookhoard.internal",
|
|
"password": "Ghost@Pass123!",
|
|
}, "")
|
|
defer loginResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
|
var loginBody struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
|
|
require.NotEmpty(t, loginBody.AccessToken)
|
|
|
|
ghostToken := loginBody.AccessToken
|
|
|
|
// Sanity: the token works while the user exists (user-scoped endpoint
|
|
// a freshly-registered regular user can access).
|
|
okResp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
|
|
defer okResp.Body.Close()
|
|
require.Equal(t, http.StatusOK, okResp.StatusCode, "token must work while the user exists")
|
|
|
|
// Delete the user directly via the DB for determinism (same query the
|
|
// suite's own cleanup uses).
|
|
ghostID := ""
|
|
users, err := setup.DB.ListUsers(ctx)
|
|
require.NoError(t, err)
|
|
for _, u := range users {
|
|
if u.Email == "ghost-test@tests.bookhoard.internal" {
|
|
ghostID = uuid.UUID(u.ID.Bytes).String()
|
|
require.NoError(t, setup.DB.DeleteUser(ctx, u.ID))
|
|
}
|
|
}
|
|
require.NotEmpty(t, ghostID, "ghost user row must exist before deletion")
|
|
|
|
// The same token must now be rejected with 401 user_not_found.
|
|
resp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
|
|
defer resp.Body.Close()
|
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "ghost token must be rejected")
|
|
|
|
var errBody map[string]string
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
|
|
assert.Contains(t, errBody["message"], "user_not_found")
|
|
}
|
|
|
|
// TestProgressWeb_LocatorCoherence pins the locator coherence rule: a
|
|
// percentage-only write that DECLARES no locator (ClearLocator) must clear
|
|
// any inherited stale anchor instead of re-pairing the new percentage with
|
|
// the previous writer's CFI/context — the incoherent row shape that fed the
|
|
// client-side far-anchor restore floor. Request-carried fields always win.
|
|
func TestProgressWeb_LocatorCoherence(t *testing.T) {
|
|
setup := setupTestServer(t)
|
|
ctx := context.Background()
|
|
userID := getTestUserID(t, setup.DB)
|
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
|
|
seedAnchor := func(t *testing.T, mediaItemID string) uuid.UUID {
|
|
mediaUUID, _ := uuid.Parse(mediaItemID)
|
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
Source: "web",
|
|
Percentage: pFloat64(0.3),
|
|
Epubcfi: pStr("epubcfi(/6/4/2:first)"),
|
|
ContextText: pStr("some anchor context sentence"),
|
|
DeviceType: "web",
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
})
|
|
require.NoError(t, err)
|
|
require.True(t, progress.Epubcfi.Valid, "seed: anchor stored")
|
|
return mediaUUID
|
|
}
|
|
|
|
t.Run("percentage-only kobo write clears the inherited anchor", func(t *testing.T) {
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
mediaUUID := seedAnchor(t, mediaItemID)
|
|
|
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
Source: "kobo",
|
|
Percentage: pFloat64(0.6),
|
|
DeviceType: "kobo",
|
|
ClearLocator: true,
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001, "percentage updated")
|
|
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared, not re-paired")
|
|
assert.False(t, progress.ContextText.Valid, "stale context cleared")
|
|
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
|
})
|
|
|
|
t.Run("request-carried context survives the clear", func(t *testing.T) {
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
mediaUUID := seedAnchor(t, mediaItemID)
|
|
|
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
Source: "koreader",
|
|
Percentage: pFloat64(0.7),
|
|
ContextText: pStr("a brand new context sentence"),
|
|
DeviceType: "koreader",
|
|
ClearLocator: true,
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.InDelta(t, 0.7, progress.Percentage.Float64, 0.001)
|
|
assert.True(t, progress.ContextText.Valid, "request-carried context kept")
|
|
assert.Equal(t, "a brand new context sentence", progress.ContextText.String)
|
|
// The verify gate is inert in the test harness (no media path
|
|
// resolver wired), so no anchor can be minted here; the rule
|
|
// under test is that the STALE one is gone.
|
|
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared")
|
|
})
|
|
|
|
t.Run("HTTP merge semantics untouched: key-absent PUT preserves fields", func(t *testing.T) {
|
|
mediaItemID := createTestMediaItemID(t, setup)
|
|
mediaUUID := seedAnchor(t, mediaItemID)
|
|
|
|
// Service-level save WITHOUT ClearLocator mimics the HTTP merge
|
|
// path (field absent = inherit): the anchor must survive.
|
|
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
Source: "queue",
|
|
Percentage: pFloat64(0.4),
|
|
Broadcast: false,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
|
UserID: pgUserID,
|
|
})
|
|
require.NoError(t, err)
|
|
assert.True(t, progress.Epubcfi.Valid, "merge path preserves the anchor")
|
|
assert.Equal(t, "epubcfi(/6/4/2:first)", progress.Epubcfi.String)
|
|
})
|
|
}
|