diff --git a/cmd/server/tests/progress_service_test.go b/cmd/server/tests/progress_service_test.go new file mode 100644 index 0000000..2127648 --- /dev/null +++ b/cmd/server/tests/progress_service_test.go @@ -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) + }) +} diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index 03ecbe5..ae8507e 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -77,19 +77,20 @@ type DeviceTestData struct { // TestServerSetup manages the lifecycle of a test server with proper resource cleanup type TestServerSetup struct { - Server *httptest.Server - DB *database.Queries - DBPool *pgxpool.Pool - Config *config.Config - ConnManager *wsync.ConnectionManager - QueueProcessor *wsync.SyncQueueProcessor - CleanupCancel context.CancelFunc - QueueCtx context.Context - QueueCancel context.CancelFunc - Token string - RegularToken string - mu sync.Mutex - closed bool + Server *httptest.Server + DB *database.Queries + DBPool *pgxpool.Pool + Config *config.Config + ConnManager *wsync.ConnectionManager + QueueProcessor *wsync.SyncQueueProcessor + ProgressService *wsync.ProgressService + CleanupCancel context.CancelFunc + QueueCtx context.Context + QueueCancel context.CancelFunc + Token string + RegularToken string + mu sync.Mutex + closed bool } // Close cleans up all resources in the correct order @@ -452,12 +453,15 @@ func setupTestServer(t *testing.T) *TestServerSetup { connManager := wsync.NewConnectionManager() cleanupCancel := connManager.StartCleanupTask() - // Create sync queue processor with cancellable context + progressService := wsync.NewProgressService(queries, connManager) + queueProcessor := wsync.NewSyncQueueProcessor(queries) + queueProcessor.SetProgressService(progressService) queueCtx, queueCancel := context.WithCancel(context.Background()) go queueProcessor.Start(queueCtx) koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) + koreaderHandler.SetProgressService(progressService) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) conflictHandler := handlers.NewConflictHandler(queries, connManager) analyticsHandler := handlers.NewAnalyticsHandler(queries) @@ -475,6 +479,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { dashboardService := services.NewDashboardService(queries) dashboardHandler := handlers.NewDashboardHandler(queries) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) + mediaHandler.SetProgressService(progressService) matchingHandler := handlers.NewMatchingHandler(queries, connManager) // Create conversion service for OPDS @@ -528,6 +533,7 @@ func setupTestServer(t *testing.T) *TestServerSetup { JobsHandler: jobsHandler, ConnManager: connManager, QueueProcessor: queueProcessor, + ProgressService: progressService, DeviceAuthMiddleware: deviceAuthMiddleware, LoginTracker: loginAttemptTracker, } @@ -607,17 +613,18 @@ func setupTestServer(t *testing.T) *TestServerSetup { // Create TestServerSetup struct with all resources setup := &TestServerSetup{ - Server: ts, - DB: queries, - DBPool: dbPool, - Config: cfg, - ConnManager: connManager, - QueueProcessor: queueProcessor, - CleanupCancel: cleanupCancel, - QueueCtx: queueCtx, - QueueCancel: queueCancel, - Token: adminToken, - RegularToken: regularToken, + Server: ts, + DB: queries, + DBPool: dbPool, + Config: cfg, + ConnManager: connManager, + QueueProcessor: queueProcessor, + ProgressService: progressService, + CleanupCancel: cleanupCancel, + QueueCtx: queueCtx, + QueueCancel: queueCancel, + Token: adminToken, + RegularToken: regularToken, } // Register cleanup function to run automatically when test completes