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) }) }