Files
bookhoard/cmd/server/tests/conflict_test.go
T
john-okeefe a6700f73e0 fix(tests): handle all Close() and Decode() errors across integration tests
Replace all unhandled resp.Body.Close() calls throughout the test suite:

- Deferred calls: replace 'defer VAR.Body.Close()' with a closure that explicitly
  discards the error via 'defer func(Body io.ReadCloser) { _ = Body.Close() }(VAR.Body)'
- Immediate calls: replace 'VAR.Body.Close()' with '_ = VAR.Body.Close()'

Replace all unhandled json.NewDecoder(VAR.Body).Decode(&x) calls with error capture
and require.NoError assertion. Files using httptest.ResponseRecorder (collections_preview,
processing_issues) use 'err :=' declaration; suite-style tests (scanner_integration,
dashboard_integration) use s.T() instead of t.
2026-04-21 20:33:05 -04:00

583 lines
17 KiB
Go

package main
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type conflictTestEnv struct {
setup *TestServerSetup
mediaID string
userID pgtype.UUID
mediaPGID pgtype.UUID
}
func setupConflictTest(t *testing.T) *conflictTestEnv {
t.Helper()
setup := setupTestServer(t)
mediaID := createTestMediaItemID(t, setup)
ctx := context.Background()
user, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
require.NoError(t, err)
mediaUUID, err := uuid.Parse(mediaID)
require.NoError(t, err)
return &conflictTestEnv{
setup: setup,
mediaID: mediaID,
userID: user.ID,
mediaPGID: pgtype.UUID{Bytes: [16]byte(mediaUUID), Valid: true},
}
}
func createTestConflict(t *testing.T, env *conflictTestEnv, conflictData map[string]interface{}) database.SyncConflicts {
t.Helper()
ctx := context.Background()
dataJSON, err := json.Marshal(conflictData)
require.NoError(t, err)
conflict, err := env.setup.DB.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
MediaItemID: env.mediaPGID,
UserID: env.userID,
ConflictType: "progress",
ConflictData: dataJSON,
})
require.NoError(t, err)
return conflict
}
func makeConflictData(koreaderPct, koboPct float64) map[string]interface{} {
return map[string]interface{}{
"koreader": map[string]interface{}{
"source": "koreader",
"timestamp": time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
"data": map[string]interface{}{
"percentage": koreaderPct,
},
},
"kobo": map[string]interface{}{
"source": "kobo",
"timestamp": time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
"data": map[string]interface{}{
"percentage": koboPct,
},
},
}
}
func TestConflictList_Empty(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictListResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Total)
assert.Empty(t, result.Conflicts)
}
func TestConflictList_WithConflicts(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
createTestConflict(t, env, makeConflictData(0.45, 0.42))
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts?status=all", nil)
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictListResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Total, 1)
require.NotEmpty(t, result.Conflicts)
conflict := result.Conflicts[0]
assert.Equal(t, "progress", conflict.ConflictType)
assert.Equal(t, "unresolved", conflict.ResolutionStatus)
assert.Contains(t, conflict.ConflictData, "koreader")
assert.Contains(t, conflict.ConflictData, "kobo")
}
func TestConflictList_UnresolvedCount(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
createTestConflict(t, env, makeConflictData(0.45, 0.42))
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts", nil)
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
var result handlers.ConflictListResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.GreaterOrEqual(t, result.Unresolved, 1)
}
func TestConflictGet_ByID(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var detail handlers.ConflictDetailResponse
err = json.NewDecoder(resp.Body).Decode(&detail)
require.NoError(t, err)
assert.Equal(t, conflictID, detail.ID)
assert.Equal(t, env.mediaID, detail.MediaItemID)
assert.Equal(t, "progress", detail.ConflictType)
assert.Contains(t, detail.ConflictData, "koreader")
assert.Contains(t, detail.ConflictData, "kobo")
}
func TestConflictGet_NotFound(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestConflictGet_InvalidID(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/not-a-uuid", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestConflictResolve_ByKOReader(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.75, 0.30))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "koreader",
"manual_data": nil,
"apply_to_all_future_conflicts": false,
"reason": "More recent progress",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.True(t, result.ConflictResolved)
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
}
func TestConflictResolve_ByKobo(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.30, 0.75))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "kobo",
"apply_to_all_future_conflicts": false,
"reason": "Higher progress",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.True(t, result.ConflictResolved)
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
}
func TestConflictResolve_WithManualData(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "manual",
"manual_data": map[string]interface{}{
"percentage": 0.43,
"epubcfi": "epubcfi(/6/4/2:20)",
"chapter": 3,
},
"apply_to_all_future_conflicts": false,
"reason": "Custom merged position",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result handlers.ConflictResolveResponse
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.True(t, result.ConflictResolved)
}
func TestConflictResolve_ManualWithoutData(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "manual",
"manual_data": nil,
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestConflictResolve_AlreadyResolved(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "koreader",
"reason": "First resolution",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp2, err := client.Do(req2)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
assert.Equal(t, http.StatusBadRequest, resp2.StatusCode)
}
func TestConflictResolve_InvalidWinner(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "nonexistent_source",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestConflictResolve_NotFound(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
resolveReq := map[string]interface{}{
"winner": "koreader",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestConflictDelete(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
req, _ := http.NewRequest("DELETE", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
req2, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp2, err := client.Do(req2)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
}
func TestConflictDelete_NotFound(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestConflictDismissAllResolved(t *testing.T) {
env := setupConflictTest(t)
client := &http.Client{}
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
conflictID := uuid.UUID(conflict.ID.Bytes).String()
resolveReq := map[string]interface{}{
"winner": "koreader",
}
body, _ := json.Marshal(resolveReq)
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/dismiss-all", nil)
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
resp2, err := client.Do(req2)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp2.Body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
err = json.NewDecoder(resp2.Body).Decode(&result)
require.NoError(t, err)
deleted, ok := result["deleted"].(float64)
assert.True(t, ok)
assert.GreaterOrEqual(t, int(deleted), 1)
}
func TestConflictEndpoints_RequireAuth(t *testing.T) {
setup := setupTestServer(t)
client := &http.Client{}
t.Run("list conflicts requires auth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("get conflict requires auth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("resolve conflict requires auth", func(t *testing.T) {
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("delete conflict requires auth", func(t *testing.T) {
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("dismiss-all requires auth", func(t *testing.T) {
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/dismiss-all", nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}