Release / build-and-push (push) Successful in 3m21s
The guard returned c.JSON(401, ...), whose nil error told echo the
SuccessHandler succeeded — the pipeline then continued into the route
handler, which type-asserted the never-set "user" context value and
panic-recovered as a 500 appended after the 401 body. The status code
happened to survive (first write wins), which is why the pin test
passed, but every ghost request also executed the target handler with
no user — the exact unauthenticated-execution class the guard exists to
prevent.
Return echo.NewHTTPError(401, ...) instead: non-nil aborts the chain
and renders {"message":"user_not_found: ..."}; the pin test now asserts
that shape. Full suite: 219 groups, zero failures.
836 lines
30 KiB
Go
836 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.
|
|
createResp := doReq(t, "POST", setup.Server.URL+"/api/auth/register", 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("registration 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)
|
|
})
|
|
}
|