test(progress): add comprehensive integration tests for ProgressService
Adds 30 integration tests across 7 test functions covering all progress endpoints with real HTTP requests and database verification: - AuthContexts (8 tests): unauthenticated PUT/GET return 401, regular user and admin both get 200, invalid UUID returns 400, nonexistent item returns 200 with empty data. - MergePreservesFields (2 tests): second PUT with only percentage preserves epubcfi and chapter from first save via GET verification; web save preserves koreader character_offset via DB query. - EnrichmentComputesFields (2 tests): character_offset computed from percentage when total_characters is set on media item; GET returns enriched format_group and total_characters. - ConflictDetection (3 tests): different sources with >1% diff within 5 minutes creates sync_conflicts record; same-source rapid saves create no conflict; <1% diff creates no conflict. - KoboIntegration (3 tests): ReadingSync then last-read-place preserves percentage via DB; standalone last-read-place sets epubcfi/chapter; unauthenticated returns 401. - KOReaderIntegration (2 tests): Bearer token auth with proper request body returns 202 Accepted; unauthenticated returns 401. - DeleteProgress (2 tests): DELETE clears progress; unauthenticated returns 401. - EdgeCases (4 tests): empty body succeeds, 0.0% and 1.0% boundaries, all fields with full DB verification of each column. Updates test_helpers to create ProgressService in setupTestServer and inject into all handlers. Fixes previous tests that used testing.Short() (which caused all tests to be skipped in the container) and assertions against wrong JSON format (pgtype serializes as plain values, not wrapped objects).
This commit is contained in:
@@ -0,0 +1,658 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ type TestServerSetup struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
ConnManager *wsync.ConnectionManager
|
ConnManager *wsync.ConnectionManager
|
||||||
QueueProcessor *wsync.SyncQueueProcessor
|
QueueProcessor *wsync.SyncQueueProcessor
|
||||||
|
ProgressService *wsync.ProgressService
|
||||||
CleanupCancel context.CancelFunc
|
CleanupCancel context.CancelFunc
|
||||||
QueueCtx context.Context
|
QueueCtx context.Context
|
||||||
QueueCancel context.CancelFunc
|
QueueCancel context.CancelFunc
|
||||||
@@ -452,12 +453,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
connManager := wsync.NewConnectionManager()
|
connManager := wsync.NewConnectionManager()
|
||||||
cleanupCancel := connManager.StartCleanupTask()
|
cleanupCancel := connManager.StartCleanupTask()
|
||||||
|
|
||||||
// Create sync queue processor with cancellable context
|
progressService := wsync.NewProgressService(queries, connManager)
|
||||||
|
|
||||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||||
|
queueProcessor.SetProgressService(progressService)
|
||||||
queueCtx, queueCancel := context.WithCancel(context.Background())
|
queueCtx, queueCancel := context.WithCancel(context.Background())
|
||||||
go queueProcessor.Start(queueCtx)
|
go queueProcessor.Start(queueCtx)
|
||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
|
koreaderHandler.SetProgressService(progressService)
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
@@ -475,6 +479,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
|
mediaHandler.SetProgressService(progressService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
|
|
||||||
// Create conversion service for OPDS
|
// Create conversion service for OPDS
|
||||||
@@ -528,6 +533,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
JobsHandler: jobsHandler,
|
JobsHandler: jobsHandler,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
|
ProgressService: progressService,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
}
|
}
|
||||||
@@ -613,6 +619,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
|
ProgressService: progressService,
|
||||||
CleanupCancel: cleanupCancel,
|
CleanupCancel: cleanupCancel,
|
||||||
QueueCtx: queueCtx,
|
QueueCtx: queueCtx,
|
||||||
QueueCancel: queueCancel,
|
QueueCancel: queueCancel,
|
||||||
|
|||||||
Reference in New Issue
Block a user