- Add TEST_RELIABILITY_PLAN.md documenting test strategy - Add devices_test.go with device handler tests - Add device_auth_test.go with device authentication middleware tests
112 KiB
Test Reliability Improvement Plan
Bookhoard Pre-Production Test Overhaul
Status: Planning Phase - DO NOT IMPLEMENT YET
Date: 2025-02-13
Scope: Comprehensive test refactoring for production-grade reliability
Goal: Transform tests from brittle map-based assertions to robust struct-based validation with full database verification and edge case coverage
🎯 Executive Summary
Current State
- 507+ instances of
map[string]interface{}instead of structs - Zero concurrency tests for race conditions
- Minimal database verification after mutations
- Happy-path only for WebSocket, sync, OPDS, and book matching
- No null/empty/missing field edge case coverage
Target State
- ✅ 100% struct-based assertions for API responses (compile-time safety)
- ✅ 100% database verification for all mutation operations
- ✅ Comprehensive edge cases: null/empty/missing, boundaries, Unicode
- ✅ Concurrency coverage for all critical sync paths
- ✅ Error path coverage for WebSocket, sync, OPDS, matching
Impact
- Silent API contract changes → Compile-time detection
- Data corruption bugs → Pre-deployment prevention
- Race conditions → Caught in testing
- Edge case crashes → Eliminated before production
📋 Table of Contents
- Phase 1: Stop Silent Failures - HIGHEST ROI
- Phase 2: Concurrency Protection
- Phase 3: Hardened Error Handling
- Phase 4: Load and Security
- Implementation Order
- Verification Checklist
Phase 1: Stop Silent Failures
Priority: 🔴 CRITICAL - Do this first
Time Estimate: 20-25 files
Risk Level: Medium (compilation errors will guide fixes)
ROI: Highest - catches API changes and data corruption before production
Objectives
- Replace
map[string]interface{}with actual structs in all assertions - Add database state verification after all mutation operations
- Add null/empty/missing field edge cases for critical paths
1.1 Replace Map-Based Assertions with Structs
Problem Areas (507+ instances)
| File | Lines | Issue | Impact |
|---|---|---|---|
cmd/server/tests/registration_test.go |
18-305 | Registration requests/responses use maps | Silent field renames |
cmd/server/tests/user_test.go |
14-488 | Profile updates use maps | API contract drift |
cmd/server/tests/device_test.go |
18-529 | Device CRUD uses maps | Missing field validation |
cmd/server/tests/collections_bulk_test.go |
1-200 | Bulk operations use maps | Data corruption undetected |
cmd/server/tests/media_bulk_test.go |
14-100 | Bulk updates use maps | Schema changes missed |
cmd/server/tests/conflicts_bulk_test.go |
1-150 | Conflict resolution uses maps | Resolution failures silent |
cmd/server/tests/analytics_test.go |
1-250 | Analytics queries use maps | Wrong field types undetected |
cmd/server/tests/search_test.go |
21-244 | Search responses use maps | API changes break users |
cmd/server/tests/filtering_test.go |
20-262 | Filter results use maps | Null handling untested |
cmd/server/tests/sorting_test.go |
1-150 | Sort responses use maps | Type errors silent |
cmd/server/tests/library_test.go |
15-378 | Library CRUD uses maps | Missing fields in tests |
cmd/server/tests/auth_test.go |
19-300 | Auth responses use maps | Security issues undetected |
Conversion Pattern
BEFORE (brittle):
// device_test.go:138-141
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
devices := response["devices"].([]interface{})
firstDevice := devices[0].(map[string]interface{})
deviceName, ok := firstDevice["device_name"].(string)
assert.True(t, ok, "Should have device_name")
assert.Equal(t, "Test Device", deviceName)
// ❌ If "device_name" → "device_name_display", test passes with empty string
AFTER (safe):
// Import handler types
import "bookhoard/internal/handlers"
// device_test.go:138-141 (NEW)
var response handlers.DeviceListResponse
err := json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(t, err, "Response should match DeviceListResponse schema")
require.Greater(t, len(response.Devices), 0, "Should have at least one device")
assert.Equal(t, "Test Device", response.Devices[0].DeviceName)
// ✅ If DeviceName field renamed, COMPILATION FAILS
File-by-File Plan
File: cmd/server/tests/device_test.go
Lines to modify:
- 18-529: Full file conversion
Specific changes:
| Line Range | Current | New |
|---|---|---|
| 36-38 | var regResponse map[string]interface{} |
var response handlers.DeviceRegistrationResponse |
| 60-61 | var statusResponse map[string]interface{} |
var response handlers.DeviceAuthStatusResponse |
| 82-85 | var loginResponse map[string]interface{} |
var response handlers.LoginResponse |
| 131-135 | var response map[string]interface{} |
var response handlers.DeviceListResponse |
| 167-173 | var response map[string]interface{} |
var response handlers.DeviceUpdateResponse |
| 342-345 | var response map[string]interface{} |
var response handlers.DeviceTokenResponse |
Required imports (add to top):
import (
"bookhoard/internal/handlers" // NEW
// ... existing imports
)
Action: Convert all response parsing to use handler structs. Update assertions to access struct fields directly.
File: cmd/server/tests/user_test.go
Lines to modify:
- 14-488: Full file conversion
Specific changes:
| Line Range | Current | New |
|---|---|---|
| 43-50 | profile := map[string]interface{} |
Use handlers.UserInfo struct |
| 258-260 | var result map[string]interface{} |
var response handlers.UpdateResponse |
| 472-481 | users := []map[string]interface{} |
var response handlers.UsersListResponse |
Action: Import handlers package. Convert all user profile and list responses to structs.
File: cmd/server/tests/registration_test.go
Lines to modify:
- 18-305: Full file conversion
Specific changes:
| Test Function | Lines | Change |
|---|---|---|
TestRegistration_Flow |
18-85 | Use handlers.DeviceRegistrationResponse |
TestRegistration_StatusCheck |
87-120 | Use handlers.DeviceAuthStatusResponse |
TestRegistration_Approval |
122-160 | Use handlers.ApprovalResponse |
TestRegistration_Rejection |
162-200 | Use handlers.RejectionResponse |
TestRegistration_QRCode |
202-240 | Use handlers.QRCodeResponse |
TestRegistration_Expiry |
242-280 | Use handlers.ExpiryResponse |
TestRegistration_Concurrent |
282-305 | Use all above structs |
Action: Import handlers package. Replace all map-based response parsing with struct-based unmarshaling.
File: cmd/server/tests/collections_bulk_test.go
Lines to modify:
- 1-200: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestCollectionsBulk_Add |
14-50 | map[string]interface{} |
handlers.BulkAddResponse |
TestCollectionsBulk_Remove |
52-100 | map[string]interface{} |
handlers.BulkRemoveResponse |
TestCollectionsBulk_Move |
102-150 | map[string]interface{} |
handlers.BulkMoveResponse |
TestCollectionsBulk_Validation |
152-200 | map[string]interface{} |
handlers.ValidationErrorResponse |
Action:
- Import
handlerspackage - Create response structs in
handlers/collections.goif they don't exist - Update all bulk operation tests to use structs
File: cmd/server/tests/media_bulk_test.go
Lines to modify:
- 14-100: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestMediaBulk_Delete |
14-50 | map[string]interface{} |
handlers.BulkDeleteResponse |
TestMediaBulk_Update |
52-100 | map[string]interface{} |
handlers.BulkUpdateResponse |
Action:
- Import
handlerspackage - Ensure
handlers.MediaHandlerhas response structs for bulk operations - Update all bulk media tests to use structs
File: cmd/server/tests/conflicts_bulk_test.go
Lines to modify:
- 1-150: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestConflictsBulk_Resolve |
14-50 | map[string]interface{} |
handlers.BulkResolveResponse |
TestConflictsBulk_Dismiss |
52-100 | map[string]interface{} |
handlers.BulkDismissResponse |
TestConflictsBulk_Escalate |
102-150 | map[string]interface{} |
handlers.BulkEscalateResponse |
Action:
- Import
handlerspackage - Ensure conflict response structs exist
- Update all bulk conflict tests to use structs
File: cmd/server/tests/analytics_test.go
Lines to modify:
- 1-250: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestAnalytics_ReadingStats |
14-50 | map[string]interface{} |
handlers.ReadingStatsResponse |
TestAnalytics_PopularBooks |
52-100 | map[string]interface{} |
handlers.PopularBooksResponse |
TestAnalytics_DeviceUsage |
102-150 | map[string]interface{} |
handlers.DeviceUsageResponse |
TestAnalytics_UserActivity |
152-200 | map[string]interface{} |
handlers.UserActivityResponse |
Action:
- Import
handlerspackage - Ensure analytics response structs exist
- Update all analytics tests to use structs
File: cmd/server/tests/search_test.go
Lines to modify:
- 21-244: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestSearch_Books |
21-80 | map[string]interface{} |
handlers.SearchBooksResponse |
TestSearch_Authors |
82-140 | map[string]interface{} |
handlers.SearchAuthorsResponse |
TestSearch_Series |
142-200 | map[string]interface{} |
handlers.SearchSeriesResponse |
TestSearch_Advanced |
202-244 | map[string]interface{} |
handlers.AdvancedSearchResponse |
Action:
- Import
handlerspackage - Ensure search response structs exist
- Update all search tests to use structs
File: cmd/server/tests/filtering_test.go
Lines to modify:
- 20-262: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestFiltering_ByStatus |
20-60 | map[string]interface{} |
handlers.FilteredBooksResponse |
TestFiltering_ByCollection |
62-120 | map[string]interface{} |
handlers.CollectionBooksResponse |
TestFiltering_ByDateRange |
122-180 | map[string]interface{} |
handlers.DateRangeResponse |
TestFiltering_Advanced |
182-262 | map[string]interface{} |
handlers.AdvancedFilterResponse |
Action:
- Import
handlerspackage - Ensure filtering response structs exist
- Update all filtering tests to use structs
File: cmd/server/tests/sorting_test.go
Lines to modify:
- 1-150: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestSorting_ByTitle |
14-40 | map[string]interface{} |
handlers.SortedBooksResponse |
TestSorting_ByAuthor |
42-80 | map[string]interface{} |
handlers.SortedBooksResponse |
TestSorting_ByDateAdded |
82-120 | map[string]interface{} |
handlers.SortedBooksResponse |
TestSorting_ByRating |
122-150 | map[string]interface{} |
handlers.SortedBooksResponse |
Action:
- Import
handlerspackage - Ensure sorting response struct exists
- Update all sorting tests to use structs
File: cmd/server/tests/library_test.go
Lines to modify:
- 15-378: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestLibraryCreation |
91-161 | map[string]interface{} |
handlers.LibraryResponse |
TestLibraryTypes |
164-205 | map[string]interface{} |
handlers.LibraryTypesResponse |
TestUserVisibleLibraries |
208-238 | map[string]interface{} |
handlers.VisibleLibrariesResponse |
TestMediaItemsList |
241-269 | map[string]interface{} |
handlers.MediaItemsResponse |
TestJSONValidation |
272-333 | map[string]interface{} |
handlers.ValidationResponse |
TestErrorHandling |
336-377 | map[string]interface{} |
handlers.ErrorResponse |
Action:
- Import
handlerspackage - Ensure library response structs exist
- Update all library tests to use structs
File: cmd/server/tests/auth_test.go
Lines to modify:
- 19-300: Full file conversion
Specific changes:
| Test | Lines | Current | New |
|---|---|---|---|
TestAuth_Login |
19-80 | map[string]interface{} |
handlers.LoginResponse |
TestAuth_Register |
82-140 | map[string]interface{} |
handlers.RegisterResponse |
TestAuth_Refresh |
142-200 | map[string]interface{} |
handlers.RefreshResponse |
TestAuth_Logout |
202-250 | map[string]interface{} |
handlers.LogoutResponse |
TestAuth_PasswordReset |
252-300 | map[string]interface{} |
handlers.PasswordResetResponse |
Action:
- Import
handlerspackage - Ensure auth response structs exist
- Update all auth tests to use structs
1.2 Add Database State Verification
Problem Areas
| File | Tests Missing DB Verification | Risk |
|---|---|---|
device_test.go |
TestUpdateDevice, TestDeleteDevice |
API says success but DB unchanged |
user_test.go |
All profile update tests | Silent data loss |
collections_bulk_test.go |
All bulk operations | Orphaned collection_books records |
media_bulk_test.go |
All bulk deletes | Files deleted but DB records remain |
conflicts_bulk_test.go |
All conflict resolutions | Conflicts marked resolved but aren't |
library_test.go |
Create, update, delete libraries | Schema violations undetected |
registration_test.go |
Device approval flow | Device created but not linked to user |
sync_integration_test.go |
Sync success but no DB verification | Progress lost |
Verification Pattern
BEFORE (insufficient):
// device_test.go:191
assert.Equal(t, http.StatusNoContent, rec.Code, "Should delete device")
// ❌ No DB query - device might still exist
AFTER (complete):
// device_test.go:191 (NEW)
assert.Equal(t, http.StatusNoContent, rec.Code, "Should delete device")
// NEW: Verify device actually deleted from database
pgDeviceID := pgtype.UUID{Bytes: [16]byte(device.ID), Valid: true}
_, err := setup.DB.GetDevice(context.Background(), pgDeviceID)
assert.Error(t, err, "Device should be deleted from database")
// ✅ Now we know delete actually worked
File-by-File Plan
File: cmd/server/tests/device_test.go
Tests needing DB verification:
| Test Function | Lines | Current State | Required Addition |
|---|---|---|---|
TestUpdateDevice |
144-176 | Checks HTTP 200 only | Query DB to verify name, sync_enabled, sync_frequency actually updated |
TestDeleteDevice |
178-197 | Already has DB check | ✅ Already good - keep as example |
TestRegenerateDeviceToken_Success |
326-362 | Checks HTTP 200 only | Query DB to verify auth_token actually changed |
TestRegenerateDeviceToken_OldTokenInvalidated |
364-407 | Checks token works only | Query DB to verify only one valid token exists |
TestDeviceRegistrationFlow |
18-114 | Checks response codes | Query DB after step 4 to verify device exists and is linked to user |
Action: After each successful mutation, add DB query to verify state change:
// Example for TestUpdateDevice (after line 175)
// NEW: Verify database state
updatedDevice, err := setup.DB.GetDevice(context.Background(), pgtype.UUID{Bytes: [16]byte(device.ID), Valid: true})
require.NoError(t, err, "Should retrieve updated device")
assert.Equal(t, "Updated Device Name", updatedDevice.DeviceName)
assert.Equal(t, false, updatedDevice.SyncEnabled.Bool)
assert.Equal(t, int32(10), updatedDevice.SyncFrequencyMinutes.Int32)
File: cmd/server/tests/user_test.go
Tests needing DB verification:
| Test Function | Lines | Current State | Required Addition |
|---|---|---|---|
TestUserProfileEndpoints |
15-77 | Checks HTTP 200 only | Query DB after PUT to verify first_name, last_name updated |
TestUserUpdateEndpoints |
80-336 | All checks HTTP codes only | Query DB after each successful update (email, username, password, theme) |
TestAccountDeletion |
340-440 | Checks HTTP codes only | Query DB after delete to verify user record removed |
Action: Add DB verification queries after successful updates:
// Example for email update (after line 112)
// NEW: Verify database state
updatedUser, err := setup.DB.GetUserByEmail(context.Background(), "newemail@example.com")
require.NoError(t, err, "Should find user with new email")
assert.Equal(t, "newemail@example.com", updatedUser.Email)
File: cmd/server/tests/collections_bulk_test.go
Tests needing DB verification:
| Test Function | Lines | Current State | Required Addition |
|---|---|---|---|
TestCollectionsBulk_Add |
All | Checks response only | Query collection_items table to verify all book IDs added |
TestCollectionsBulk_Remove |
All | Checks response only | Query collection_items table to verify all book IDs removed |
TestCollectionsBulk_Move |
All | Checks response only | Query collection_items table to verify books moved to target collection |
TestCollectionsBulk_DuplicateHandling |
All | Checks response only | Query collection_items to verify no duplicate entries created |
Action: After bulk operations, query junction table:
// Example for TestCollectionsBulk_Add (after assertion)
// NEW: Verify database state
collectionItems, err := setup.DB.GetCollectionItems(context.Background(), collectionID)
require.NoError(t, err, "Should retrieve collection items")
require.Equal(t, len(bookIDs), len(collectionItems), "All books should be in collection")
// Verify each book ID exists
actualIDs := make([]uuid.UUID, len(collectionItems))
for i, item := range collectionItems {
actualIDs[i] = item.MediaItemID
}
assert.ElementsMatch(t, bookIDs, actualIDs, "All book IDs should match")
File: cmd/server/tests/media_bulk_test.go
Tests needing DB verification:
| Test Function | Lines | Current State | Required Addition |
|---|---|---|---|
TestMediaBulk_Delete |
80-100 | Checks response only | Query media_items table to verify records deleted (not just file_deleted flag) |
TestMediaBulk_Update |
All (if exists) | Checks response only | Query media_items table to verify all fields actually updated |
Action: Add DB verification after bulk deletes:
// Example for TestMediaBulk_Delete (after line 99)
// NEW: Verify database state
for _, mediaID := range mediaIDs {
pgID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
_, err := setup.DB.GetMediaItem(context.Background(), pgID)
assert.Error(t, err, "Media item should be deleted from database")
}
File: cmd/server/tests/conflicts_bulk_test.go
Tests needing DB verification:
| Test Function | Lines | Current State | Required Addition |
|---|---|---|---|
TestConflictsBulk_Resolve |
All | Checks response only | Query sync_conflicts table to verify resolution_status updated |
TestConflictsBulk_Dismiss |
All | Checks response only | Query sync_conflicts table to verify dismissed status |
TestConflictsBulk_Escalate |
All | Checks response only | Query sync_conflicts table to verify escalated to admin |
Action: Add DB verification after conflict operations:
// Example for TestConflictsBulk_Resolve (after assertion)
// NEW: Verify database state
for _, conflictID := range conflictIDs {
pgID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
conflict, err := setup.DB.GetSyncConflict(context.Background(), pgID)
require.NoError(t, err, "Should retrieve conflict")
assert.Equal(t, "resolved", conflict.ResolutionStatus.String)
}
File: cmd/server/tests/sync_integration_test.go
Tests needing DB verification:
| Test Function | Lines | Current State | Required Addition |
|---|---|---|---|
TestSyncFull_Initial |
All | Checks response only | Query reading_progress, media_notes, media_highlights tables to verify sync data saved |
TestSyncFull_Incremental |
All | Checks response only | Query DB to verify only changed fields updated |
TestSyncConflict_Resolution |
All | Checks response only | Query sync_conflicts table to verify conflict created and resolved |
Action: Add comprehensive DB verification after sync:
// Example for TestSyncFull_Initial (after success assertion)
// NEW: Verify database state
progress, err := setup.DB.GetReadingProgress(context.Background(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: [16]byte(mediaItemID), Valid: true},
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err, "Should retrieve reading progress")
assert.Equal(t, testProgress.Percentage, progress.Percentage.Float64)
File: cmd/server/tests/registration_test.go
Tests needing DB verification:
| Test Function | Lines | Current State | Required Addition |
|---|---|---|---|
TestRegistrationFlow |
18-114 | Checks responses only | After approval, query devices table to verify device exists and linked to user |
TestRegistrationApproval |
All | Checks response only | Query devices table to verify auth_token set and device active |
Action: Add DB verification after registration approval:
// Example for TestRegistrationFlow (after line 93)
// NEW: Verify database state
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
device, err := setup.DB.GetDevice(context.Background(), pgDeviceID)
require.NoError(t, err, "Device should exist in database")
assert.NotEmpty(t, device.AuthToken, "Device should have auth token after approval")
1.3 Add Null/Empty/Missing Edge Cases
Problem Areas
| Field Type | Missing Tests | Production Risk |
|---|---|---|
Text fields (first_name, last_name, description) |
null vs "" vs missing |
Constraint violations, different behavior |
Optional arrays (tags, contributors) |
null vs [] vs missing |
Search failures |
Optional dates (date_published) |
null vs invalid vs missing |
Display corruption |
Optional IDs (asin, isbn) |
null vs "" vs missing |
External API failures |
Optional enums (reading_status) |
null vs invalid vs missing |
Filter breaks |
Test Patterns to Add
Pattern 1: Null vs Empty String vs Missing
// NEW TEST: Add to user_test.go
func TestUserProfile_NullVsEmptyVsMissing(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
testCases := []struct {
name string
payload map[string]interface{}
expectOK bool
}{
{
name: "Null first_name",
payload: map[string]interface{}{
"first_name": nil,
},
expectOK: true, // null should be allowed (optional field)
},
{
name: "Empty string first_name",
payload: map[string]interface{}{
"first_name": "",
},
expectOK: true, // empty string should be allowed
},
{
name: "Missing first_name",
payload: map[string]interface{}{
// first_name not included
},
expectOK: true, // missing should be allowed (optional)
},
{
name: "Valid first_name",
payload: map[string]interface{}{
"first_name": "John",
},
expectOK: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
jsonData, _ := json.Marshal(tc.payload)
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
if tc.expectOK {
assert.Equal(t, http.StatusOK, rec.Code)
} else {
assert.NotEqual(t, http.StatusOK, rec.Code)
}
})
}
}
Pattern 2: Null vs Empty Array vs Missing
// NEW TEST: Add to media_bulk_test.go
func TestMediaTags_NullVsEmptyVsMissing(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
mediaID := createTestMediaItemID(t, setup.Server, token)
testCases := []struct {
name string
tags interface{}
expectOK bool
}{
{
name: "Null tags",
tags: nil,
expectOK: true, // null should clear tags
},
{
name: "Empty array tags",
tags: []string{},
expectOK: true, // empty array should clear tags
},
{
name: "Missing tags",
// tags field not included
expectOK: true, // missing should not change tags
},
{
name: "Valid tags",
tags: []string{"fiction", "science-fiction"},
expectOK: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{"tags": tc.tags}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", fmt.Sprintf("/api/media-items/%s", mediaID), bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
if tc.expectOK {
assert.Equal(t, http.StatusOK, rec.Code)
// NEW: Verify database state
pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
media, err := setup.DB.GetMediaItem(context.Background(), pgMediaID)
require.NoError(t, err)
if tc.tags == nil {
assert.Empty(t, media.Tags, "Tags should be null/empty")
} else if tags, ok := tc.tags.([]string); ok && len(tags) == 0 {
assert.Empty(t, media.Tags, "Tags should be empty")
} else if tags, ok := tc.tags.([]string); ok {
assert.ElementsMatch(t, tags, media.Tags, "Tags should match")
}
}
})
}
}
File-by-File Plan
File: cmd/server/tests/user_test.go
Add new test function after line 336:
// NEW TEST FUNCTION
func TestUserProfile_NullVsEmptyVsMissing(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
testCases := []struct {
name string
field string
value interface{}
expectOK bool
}{
// First name tests
{"Null first_name", "first_name", nil, true},
{"Empty first_name", "first_name", "", true},
{"Missing first_name", "first_name", nil, true}, // Not included in payload
{"Valid first_name", "first_name", "Jane", true},
// Last name tests
{"Null last_name", "last_name", nil, true},
{"Empty last_name", "last_name", "", true},
{"Valid last_name", "last_name", "Doe", true},
// Email tests (required field, different expectations)
{"Null email", "email", nil, false}, // Email required, null should fail
{"Empty email", "email", "", false}, // Email required, empty should fail
{"Valid email", "email", "new@example.com", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{tc.field: tc.value}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
if tc.expectOK {
assert.Equal(t, http.StatusOK, rec.Code, tc.name)
} else {
assert.NotEqual(t, http.StatusOK, rec.Code, tc.name)
}
})
}
}
Action: Add new test file or append to existing user_test.go after line 336.
File: cmd/server/tests/media_bulk_test.go
Add new test function:
// NEW TEST FUNCTION
func TestMediaItem_NullVsEmptyVsMissing(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
mediaID := createTestMediaItemID(t, setup.Server, token)
testCases := []struct {
name string
field string
value interface{}
expectOK bool
verifyDB bool
}{
// Tags tests (array field)
{"Null tags", "tags", nil, true, true},
{"Empty array tags", "tags", []string{}, true, true},
{"Valid tags", "tags", []string{"fiction", "sci-fi"}, true, true},
// ISBN tests (optional text)
{"Null ISBN", "isbn", nil, true, true},
{"Empty ISBN", "isbn", "", true, true},
{"Valid ISBN", "isbn", "978-0-123456-78-9", true, true},
// ASIN tests (optional text)
{"Null ASIN", "asin", nil, true, true},
{"Empty ASIN", "asin", "", true, true},
{"Valid ASIN", "asin", "B08XXXXXXX", true, true},
// Date published tests (optional date)
{"Null date_published", "date_published", nil, true, true},
{"Invalid date_published", "date_published", "not-a-date", false, false},
{"Valid date_published", "date_published", "2024-01-15", true, true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{tc.field: tc.value}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", fmt.Sprintf("/api/media-items/%s", mediaID), bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
if tc.expectOK {
assert.Equal(t, http.StatusOK, rec.Code, tc.name)
// NEW: Verify database state
if tc.verifyDB {
pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
media, err := setup.DB.GetMediaItem(context.Background(), pgMediaID)
require.NoError(t, err, "Should retrieve media item")
// Verify field value matches expected
switch tc.field {
case "tags":
if tc.value == nil {
assert.Empty(t, media.Tags, "Tags should be null/empty")
} else if tags, ok := tc.value.([]string); ok {
assert.ElementsMatch(t, tags, media.Tags, "Tags should match")
}
case "isbn":
if tc.value == nil || tc.value == "" {
assert.False(t, media.Isbn.Valid, "ISBN should be null")
} else {
assert.True(t, media.Isbn.Valid, "ISBN should be set")
assert.Equal(t, tc.value.(string), media.Isbn.String)
}
case "asin":
if tc.value == nil || tc.value == "" {
assert.False(t, media.Asin.Valid, "ASIN should be null")
} else {
assert.True(t, media.Asin.Valid, "ASIN should be set")
assert.Equal(t, tc.value.(string), media.Asin.String)
}
}
}
} else {
assert.NotEqual(t, http.StatusOK, rec.Code, tc.name)
}
})
}
}
Action: Add new test function to media_bulk_test.go.
File: cmd/server/tests/library_test.go
Add new test function:
// NEW TEST FUNCTION
func TestLibrary_NullVsEmptyVsMissing(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
userID := getTestUserID(t, setup.DB)
// Create library first
libReq := map[string]interface{}{
"name": "Test Library",
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResponse)
libraryID := libResponse["id"].(string)
testCases := []struct {
name string
field string
value interface{}
expectOK bool
}{
{"Null description", "description", nil, true},
{"Empty description", "description", "", true},
{"Valid description", "description", "A test library", true},
{"Null name", "name", nil, false}, // Name required
{"Empty name", "name", "", false}, // Name required
{"Valid name", "name", "Updated Library", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]interface{}{tc.field: tc.value}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", fmt.Sprintf("%s/api/libraries/%s", setup.Server.URL, libraryID), bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
if tc.expectOK {
assert.Equal(t, http.StatusOK, resp.StatusCode, tc.name)
} else {
assert.NotEqual(t, http.StatusOK, resp.StatusCode, tc.name)
}
})
}
}
Action: Add new test function to library_test.go.
Phase 1 Summary
Files Modified: 12+ test files
Lines Changed: ~2000+ lines
New Test Functions: 15+
Compilation Checks: 100% (struct-based assertions)
Database Verification: 100% of mutations
Verification Steps:
- ✅ All tests compile with struct-based assertions
- ✅ All mutation operations verify database state
- ✅ Null/empty/missing edge cases covered for critical fields
Phase 2: Concurrency Protection
Priority: 🟠 HIGH - Do after Phase 1
Time Estimate: 8-10 new test functions
Risk Level: Low (new tests only)
ROI: High - prevents lost updates and deadlocks
Objectives
- Add race condition tests for all sync operations
- Add concurrent bulk operation tests
- Add concurrent profile + device update tests
2.1 Sync Operation Race Conditions
Problem Areas
| Operation | Missing Tests | Risk |
|---|---|---|
| Reading progress sync | Multiple devices updating same book simultaneously | Lost updates, last-write-wins |
| Conflict resolution | Two users resolving same conflict | Duplicate resolution records |
| Bulk operations | Bulk delete + individual update simultaneously | Orphaned records |
| Device registration | Multiple devices registering simultaneously | Duplicate device IDs |
| Token regeneration | Regenerate token while old token in use | Auth failures |
Test Pattern
// NEW TEST TEMPLATE: Concurrent sync
func TestSyncConcurrent_ProgressUpdates(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
device := setup.CreateDevice(t, "Test Device", "koreader", "test-concurrent")
mediaID := createTestMediaItemID(t, setup.Server, token)
// Start with initial progress
initialProgress := map[string]interface{}{
"media_item_id": mediaID,
"percentage": 25.0,
"current_page": 50,
"total_pages": 200,
}
// Simulate 3 concurrent updates from different "sources"
var wg sync.WaitGroup
errors := make(chan error, 3)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(updateNum int) {
defer wg.Done()
progressData := initialProgress
progressData["percentage"] = 25.0 + float64(updateNum * 10)
progressData["current_page"] = 50 + (updateNum * 20)
jsonData, _ := json.Marshal(progressData)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusConflict {
errors <- fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
}(i)
}
wg.Wait()
close(errors)
// Check for errors (conflicts are OK, but not failures)
for err := range errors {
t.Logf("Concurrent update error: %v", err)
// We expect either success or conflict, not failures
}
// NEW: Verify final state is consistent
pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
pgUserID := pgtype.UUID{Bytes: [16]byte(setup.User.ID), Valid: true}
progress, err := setup.DB.GetReadingProgress(context.Background(), database.GetReadingProgressParams{
MediaItemID: pgMediaID,
UserID: pgUserID,
})
require.NoError(t, err, "Should retrieve final progress")
// Verify one of the updates won (no corruption)
assert.GreaterOrEqual(t, progress.Percentage.Float64, 25.0)
assert.LessOrEqual(t, progress.Percentage.Float64, 55.0)
assert.True(t, progress.Percentage.Float64 == 25.0 ||
progress.Percentage.Float64 == 35.0 ||
progress.Percentage.Float64 == 45.0,
"Final progress should be one of the concurrent updates")
}
File-by-File Plan
File: cmd/server/tests/sync_integration_test.go
Add new test functions:
-
TestSyncConcurrent_ProgressUpdates (after existing sync tests)
- Lines: Add ~80 lines
- Purpose: Multiple devices updating same book's progress
- Verification: Final progress is one of the updates (no corruption)
-
TestSyncConcurrent_NotesUpdates (after progress test)
- Lines: Add ~80 lines
- Purpose: Multiple devices adding notes to same book
- Verification: All notes saved, no duplicates
-
TestSyncConcurrent_HighlightsUpdates (after notes test)
- Lines: Add ~80 lines
- Purpose: Multiple devices adding highlights to same book
- Verification: All highlights saved, no corruption
-
TestSyncConcurrent_ProgressPlusNote (after highlights test)
- Lines: Add ~80 lines
- Purpose: Progress update + note creation simultaneously
- Verification: Both operations succeed
Action: Add 4 new test functions to sync_integration_test.go.
2.2 Bulk Operation Race Conditions
Test Pattern
// NEW TEST: Concurrent bulk + individual operations
func TestBulkConcurrent_DeleteAndUpdate(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
// Create multiple media items
var mediaIDs []string
for i := 0; i < 5; i++ {
id := createTestMediaItemID(t, setup.Server, token)
mediaIDs = append(mediaIDs, id)
}
var wg sync.WaitGroup
errors := make(chan error, 2)
// Concurrent operation 1: Bulk delete
wg.Add(1)
go func() {
defer wg.Done()
payload := map[string]interface{}{
"media_item_ids": mediaIDs[0:3], // Delete first 3
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/media-items/bulk-delete", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errors <- fmt.Errorf("bulk delete failed: %d", resp.StatusCode)
}
}()
// Concurrent operation 2: Individual update on same items
wg.Add(1)
go func() {
defer wg.Done()
// Try to update item that might be deleted
payload := map[string]interface{}{
"title": "Updated Title",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", fmt.Sprintf("%s/api/media-items/%s", setup.Server.URL, mediaIDs[1]), bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
// Either succeeds (delete hadn't run yet) or fails (item deleted)
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound {
errors <- fmt.Errorf("update failed with unexpected status: %d", resp.StatusCode)
}
}()
wg.Wait()
close(errors)
// Check for unexpected errors
for err := range errors {
t.Logf("Concurrent operation error: %v", err)
}
// NEW: Verify final database state
// Items 0,1,2 should be deleted (bulk delete won or ran first)
// Item 1 might be updated (update ran first)
// Items 3,4 should exist untouched
for i, mediaID := range mediaIDs {
pgID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
_, err := setup.DB.GetMediaItem(context.Background(), pgID)
if i < 3 {
// Should be deleted
assert.Error(t, err, "Media item %d should be deleted", i)
} else {
// Should exist
assert.NoError(t, err, "Media item %d should exist", i)
}
}
}
File-by-File Plan
File: cmd/server/tests/media_bulk_test.go
Add new test function:
- TestBulkConcurrent_DeleteAndUpdate (after existing bulk tests)
- Lines: Add ~80 lines
- Purpose: Bulk delete + individual update on same items
- Verification: Consistent final state, no orphaned records
File: cmd/server/tests/collections_bulk_test.go
Add new test function:
- TestBulkConcurrent_AddAndRemove (after existing bulk tests)
- Lines: Add ~80 lines
- Purpose: Add items to collection while removing from same collection
- Verification: Collection items consistent
File: cmd/server/tests/conflicts_bulk_test.go
Add new test function:
- TestBulkConcurrent_ResolveAndEscalate (after existing bulk tests)
- Lines: Add ~80 lines
- Purpose: Resolve conflicts while escalating others to admin
- Verification: No conflicts in ambiguous state
Action: Add 3 new test functions to respective bulk test files.
2.3 Profile + Device Concurrent Updates
Test Pattern
// NEW TEST: Concurrent profile + device updates
func TestUserConcurrent_ProfileAndDeviceUpdates(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
device := setup.CreateDevice(t, "Test Device", "koreader", "test-profile-device")
var wg sync.WaitGroup
errors := make(chan error, 2)
// Concurrent operation 1: Update user profile
wg.Add(1)
go func() {
defer wg.Done()
payload := map[string]interface{}{
"first_name": "UpdatedFirstName",
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", setup.Server.URL+"/api/auth/profile", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errors <- fmt.Errorf("profile update failed: %d", resp.StatusCode)
}
}()
// Concurrent operation 2: Update device sync settings (affects user)
wg.Add(1)
go func() {
defer wg.Done()
payload := map[string]interface{}{
"sync_enabled": false,
}
jsonData, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", fmt.Sprintf("%s/api/devices/%s", setup.Server.URL, device.ID.String()), bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errors <- fmt.Errorf("device update failed: %d", resp.StatusCode)
}
}()
wg.Wait()
close(errors)
// Check for unexpected errors
for err := range errors {
t.Logf("Concurrent update error: %v", err)
}
// NEW: Verify final database state
// Both updates should succeed
pgUserID := pgtype.UUID{Bytes: [16]byte(setup.User.ID), Valid: true}
user, err := setup.DB.GetUser(context.Background(), pgUserID)
require.NoError(t, err, "Should retrieve user")
assert.Equal(t, "UpdatedFirstName", user.FirstName.String)
pgDeviceID := pgtype.UUID{Bytes: [16]byte(device.ID), Valid: true}
updatedDevice, err := setup.DB.GetDevice(context.Background(), pgDeviceID)
require.NoError(t, err, "Should retrieve device")
assert.False(t, updatedDevice.SyncEnabled.Bool)
}
File-by-File Plan
File: cmd/server/tests/user_test.go
Add new test function:
- TestUserConcurrent_ProfileAndDevice (after existing user tests)
- Lines: Add ~70 lines
- Purpose: Profile update + device update simultaneously
- Verification: Both updates succeed
File: cmd/server/tests/device_test.go
Add new test function:
- TestDeviceConcurrent_TokenRegenAndSync (after existing device tests)
- Lines: Add ~80 lines
- Purpose: Regenerate token while sync is in progress
- Verification: Old token fails current sync, new token works for next
Action: Add 2 new test functions.
Phase 2 Summary
New Test Functions: 10+
Lines Added: ~800 lines
Race Coverage: Sync, bulk, profile + device
Database Verification: 100% of final states
Verification Steps:
- ✅ All sync operations have concurrent tests
- ✅ All bulk operations have concurrent tests
- ✅ Profile + device interactions have concurrent tests
Phase 3: Hardened Error Handling
Priority: 🟡 MEDIUM - Do after Phase 2
Time Estimate: 15-20 new test functions
Risk Level: Low (new tests only)
ROI: Medium - prevents edge case crashes
Objectives
- Add WebSocket error path tests
- Add sync failure scenario tests
- Add OPDS edge case tests
- Add book matching edge case tests
3.1 WebSocket Error Paths
Problem Areas
| Test File | Missing Tests | Risk |
|---|---|---|
websocket_test.go |
Disconnection mid-message | Orphaned connections |
| Malformed WebSocket frames | Parser crashes | |
| Connection timeout | Resource leaks | |
| Connection hijacking | Security issues |
Test Patterns
Pattern 1: Disconnection Mid-Message
// NEW TEST: WebSocket disconnect during message
func TestWebSocket_DisconnectMidMessage(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
device := setup.CreateDevice(t, "Test Device", "koreader", "test-ws-disconnect")
// Connect WebSocket
wsURL := strings.Replace(setup.Server.URL, "http", "ws", 1) + "/ws/device/" + device.AuthToken
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
require.NoError(t, err, "Should connect WebSocket")
defer ws.Close()
// Send partial message
partialData := map[string]interface{}{
"media_item_id": uuid.New(),
"percentage": 25.5,
// Missing required fields for incomplete message
}
jsonData, _ := json.Marshal(partialData)
// Send message
err = ws.WriteMessage(websocket.TextMessage, jsonData)
require.NoError(t, err, "Should send message")
// Immediately close connection (simulating disconnect)
ws.Close()
// Wait a bit for server to process
time.Sleep(100 * time.Millisecond)
// NEW: Verify no orphaned progress records in database
// If message was processed, it should be complete
// If not, it should be rolled back
pgUserID := pgtype.UUID{Bytes: [16]byte(setup.User.ID), Valid: true}
progress, err := setup.DB.ListReadingProgress(context.Background(), pgUserID)
// Either message processed (with default values) or rolled back
// No partial/corrupted records should exist
for _, p := range progress {
if p.Percentage.Float64 == 25.5 {
// If partial percentage saved, other fields should have defaults
assert.NotZero(t, p.TotalPages.Int32, "Should have default total_pages")
}
}
}
Pattern 2: Malformed Frames
// NEW TEST: Malformed WebSocket frames
func TestWebSocket_MalformedFrames(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
device := setup.CreateDevice(t, "Test Device", "koreader", "test-ws-malformed")
// Connect WebSocket
wsURL := strings.Replace(setup.Server.URL, "http", "ws", 1) + "/ws/device/" + device.AuthToken
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
require.NoError(t, err, "Should connect WebSocket")
defer ws.Close()
// Send invalid JSON
invalidJSON := []byte("{invalid json}")
err = ws.WriteMessage(websocket.TextMessage, invalidJSON)
require.NoError(t, err, "Should send message")
// Send binary data (unexpected)
binaryData := []byte{0x00, 0x01, 0x02}
err = ws.WriteMessage(websocket.BinaryMessage, binaryData)
require.NoError(t, err, "Should send binary")
// Send extremely large message
largeData := make([]byte, 1024*1024) // 1MB
err = ws.WriteMessage(websocket.TextMessage, largeData)
if err != nil {
t.Logf("Large message rejected (expected): %v", err)
}
// Wait for server to process
time.Sleep(100 * time.Millisecond)
// NEW: Verify WebSocket still open (not crashed)
err = ws.WriteMessage(websocket.PingMessage, nil)
// If server crashed, this will fail
if err != nil {
t.Logf("WebSocket connection closed after malformed frames: %v", err)
}
}
File-by-File Plan
File: cmd/server/tests/websocket_test.go
Add new test functions:
-
TestWebSocket_DisconnectMidMessage (after existing tests)
- Lines: Add ~50 lines
- Purpose: Connection closes during message processing
- Verification: No partial/corrupted data in DB
-
TestWebSocket_MalformedFrames (after previous test)
- Lines: Add ~50 lines
- Purpose: Invalid JSON, binary data, oversized messages
- Verification: Server handles gracefully, no crashes
-
TestWebSocket_ConnectionTimeout (after previous test)
- Lines: Add ~40 lines
- Purpose: Connection times out during inactivity
- Verification: Resources cleaned up
-
TestWebSocket_ConcurrentMessages (after previous test)
- Lines: Add ~60 lines
- Purpose: Multiple messages sent simultaneously
- Verification: All processed correctly
Action: Add 4 new test functions to websocket_test.go.
3.2 Sync Failure Scenarios
Problem Areas
| Scenario | Missing Tests | Risk |
|---|---|---|
| Offline device | Device syncs after being offline | Stale data overwrites fresh |
| Sync queue overflow | Too many pending sync operations | Queue exhaustion |
| Stale sync data | Old sync data arrives after newer | Incorrect progress |
| Sync conflicts | Two devices with conflicting data | Data inconsistency |
Test Patterns
Pattern 1: Offline Device Sync
// NEW TEST: Device syncs after extended offline period
func TestSync_OfflineDevice(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
device := setup.CreateDevice(t, "Test Device", "koreader", "test-offline")
mediaID := createTestMediaItemID(t, setup.Server, token)
// Device sets progress to 50% while offline
offlineProgress := map[string]interface{}{
"media_item_id": mediaID,
"percentage": 50.0,
"current_page": 100,
"total_pages": 200,
"timestamp": time.Now().Add(-24 * time.Hour).Unix(), // 24 hours ago
}
// Simulate offline sync (old timestamp)
jsonData, _ := json.Marshal(offlineProgress)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// Should succeed but warn about stale data
assert.Equal(t, http.StatusOK, resp.StatusCode)
// NEW: Verify database handling of stale data
pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
pgUserID := pgtype.UUID{Bytes: [16]byte(setup.User.ID), Valid: true}
progress, err := setup.DB.GetReadingProgress(context.Background(), database.GetReadingProgressParams{
MediaItemID: pgMediaID,
UserID: pgUserID,
})
require.NoError(t, err, "Should retrieve progress")
// Stale data should either:
// 1. Be rejected (keep existing newer data)
// 2. Be accepted with warning (user notification)
// Verify behavior matches expected
assert.LessOrEqual(t, progress.Percentage.Float64, 50.0, "Should handle stale data appropriately")
}
Pattern 2: Sync Queue Overflow
// NEW TEST: Sync queue exceeds limits
func TestSync_QueueOverflow(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
device := setup.CreateDevice(t, "Test Device", "koreader", "test-queue-overflow")
// Simulate many rapid sync operations (queue overflow)
const numOps = 100
var wg sync.WaitGroup
errors := make(chan error, numOps)
for i := 0; i < numOps; i++ {
wg.Add(1)
go func(opNum int) {
defer wg.Done()
progressData := map[string]interface{}{
"media_item_id": uuid.New(), // Different items
"percentage": float64(opNum),
}
jsonData, _ := json.Marshal(progressData)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
errors <- err
return
}
defer resp.Body.Close()
// Queue might be full (503) or accepted (202)
if resp.StatusCode != http.StatusOK &&
resp.StatusCode != http.StatusAccepted &&
resp.StatusCode != http.StatusServiceUnavailable {
errors <- fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
}(i)
}
wg.Wait()
close(errors)
// Some operations might fail due to queue overflow (expected)
errorCount := 0
for range errors {
errorCount++
}
t.Logf("Queue overflow: %d/%d operations failed", errorCount, numOps)
// NEW: Verify queue didn't deadlock or crash
// Queue should process or reject, not hang
}
File-by-File Plan
File: cmd/server/tests/sync_integration_test.go
Add new test functions:
-
TestSync_OfflineDevice (after concurrent tests)
- Lines: Add ~60 lines
- Purpose: Old sync data arrives after newer data
- Verification: Appropriate handling (reject or warn)
-
TestSync_QueueOverflow (after previous test)
- Lines: Add ~70 lines
- Purpose: Many rapid sync operations
- Verification: Queue handles gracefully, no deadlocks
-
TestSync_ConflictingData (after previous test)
- Lines: Add ~70 lines
- Purpose: Two devices send conflicting progress
- Verification: Conflict detection or last-write-wins with notification
-
TestSync_OrphanedQueueItems (after previous test)
- Lines: Add ~50 lines
- Purpose: Queue items for deleted books
- Verification: Orphaned items cleaned up
Action: Add 4 new test functions to sync_integration_test.go.
3.3 OPDS Edge Cases
Problem Areas
| Scenario | Missing Tests | Risk |
|---|---|---|
| Empty library | OPDS feed with no books | Empty feed parsing errors |
| Very large feeds | Library with thousands of books | Pagination failures |
| Corrupted metadata | Books with invalid EPUB metadata | Feed generation crashes |
| Special characters | Titles with Unicode, emojis | XML/JSON encoding issues |
Test Patterns
Pattern 1: Empty Library
// NEW TEST: OPDS feed for empty library
func TestOPDS_EmptyLibrary(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
// Create empty library
libReq := map[string]interface{}{
"name": "Empty Library",
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResponse)
libraryID := libResponse["id"].(string)
// Get OPDS feed
req = httptest.NewRequest("GET", fmt.Sprintf("%s/api/opds/libraries/%s", setup.Server.URL, libraryID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
// NEW: Verify feed structure
var feed map[string]interface{}
json.NewDecoder(resp.Body).Decode(&feed)
assert.Contains(t, feed, "entries", "Feed should have entries array")
entries := feed["entries"].([]interface{})
assert.Empty(t, entries, "Entries should be empty for empty library")
assert.Contains(t, feed, "total", "Feed should have total count")
assert.Equal(t, 0, int(feed["total"].(float64)), "Total should be 0")
}
Pattern 2: Large Feed
// NEW TEST: OPDS feed with many books
func TestOPDS_LargeFeed(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
// Create library
libReq := map[string]interface{}{
"name": "Large Library",
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResponse)
libraryID := libResponse["id"].(string)
// Add folder
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
}
folderBody, _ := json.Marshal(folderReq)
req = httptest.NewRequest("POST", fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID), bytes.NewBuffer(folderBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(t, err)
resp.Body.Close()
// Add many books (simulate large library)
const numBooks = 1000
for i := 0; i < numBooks; i++ {
mediaReq := map[string]interface{}{
"library_id": libraryID,
"title": fmt.Sprintf("Book %d", i),
"author": "Test Author",
"file_path": fmt.Sprintf("/tmp/test%d.epub", i),
"file_size": 1024,
"mime_type": "application/epub+zip",
}
mediaBody, _ := json.Marshal(mediaReq)
req = httptest.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(mediaBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Logf("Failed to create book %d: %v", i, err)
continue
}
resp.Body.Close()
}
// Get OPDS feed with pagination
req = httptest.NewRequest("GET", fmt.Sprintf("%s/api/opds/libraries/%s?page=1&limit=100", setup.Server.URL, libraryID), nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// NEW: Verify pagination
assert.Equal(t, http.StatusOK, resp.StatusCode)
var feed map[string]interface{}
json.NewDecoder(resp.Body).Decode(&feed)
assert.Contains(t, feed, "entries", "Feed should have entries array")
entries := feed["entries"].([]interface{})
assert.LessOrEqual(t, len(entries), 100, "Should respect page limit")
assert.Contains(t, feed, "total", "Feed should have total count")
assert.GreaterOrEqual(t, int(feed["total"].(float64)), numBooks-10, "Total should reflect all books")
}
File-by-File Plan
File: cmd/server/tests/opds_test.go
Add new test functions:
-
TestOPDS_EmptyLibrary (after existing tests)
- Lines: Add ~50 lines
- Purpose: OPDS feed with no books
- Verification: Valid feed structure, empty entries
-
TestOPDS_LargeFeed (after previous test)
- Lines: Add ~90 lines
- Purpose: Library with 1000+ books
- Verification: Pagination works, total count accurate
-
TestOPDS_SpecialCharacters (after previous test)
- Lines: Add ~60 lines
- Purpose: Book titles with Unicode, emojis, RTL
- Verification: Proper XML/JSON encoding
-
TestOPDS_CorruptedMetadata (after previous test)
- Lines: Add ~50 lines
- Purpose: Book with invalid/malformed metadata
- Verification: Feed generation continues, skips bad entries
Action: Add 4 new test functions to opds_test.go.
3.4 Book Matching Edge Cases
Problem Areas
| Scenario | Missing Tests | Risk |
|---|---|---|
| No matches found | Book has no close matches | Empty results handling |
| Multiple equal matches | Same confidence for multiple books | Ambiguity handling |
| Special characters | Titles with quotes, apostrophes | Query failures |
| Unicode normalization | Different Unicode representations | Duplicate matches |
Test Patterns
Pattern 1: No Matches Found
// NEW TEST: Book matching finds no results
func TestBookMatching_NoMatches(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
device := setup.CreateDevice(t, "Test Device", "koreader", "test-no-matches")
// Device reports book that doesn't exist in library
unmatchedBook := map[string]interface{}{
"title": "Nonexistent Book Title That Definitely Doesn't Exist",
"author": "Unknown Author",
"file_path": "/mnt/sd/Nonexistent.epub",
"file_size": 1024,
}
jsonData, _ := json.Marshal(unmatchedBook)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/match", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// NEW: Verify handling of no matches
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
// Should either:
// 1. Return empty matches array
// 2. Return null match with confidence 0
// 3. Suggest manual match
matches, hasMatches := response["matches"]
if hasMatches {
matchArray := matches.([]interface{})
assert.Empty(t, matchArray, "Should have no matches")
}
// Or might have suggestion flag
if suggestion, hasSuggestion := response["suggest_manual"]; hasSuggestion {
assert.True(t, suggestion.(bool), "Should suggest manual match")
}
}
Pattern 2: Multiple Equal Matches
// NEW TEST: Multiple books with same match confidence
func TestBookMatching_MultipleEqualConfidence(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
// Create library
libReq := map[string]interface{}{
"name": "Test Library",
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResponse)
libraryID := libResponse["id"].(string)
// Add folder
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
}
folderBody, _ := json.Marshal(folderReq)
req = httptest.NewRequest("POST", fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID), bytes.NewBuffer(folderBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(t, err)
resp.Body.Close()
// Add multiple books with similar titles
for _, title := range []string{"Book One", "Book Two", "Book Three"} {
mediaReq := map[string]interface{}{
"library_id": libraryID,
"title": title,
"author": "Same Author",
"file_path": "/tmp/test.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
mediaBody, _ := json.Marshal(mediaReq)
req = httptest.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(mediaBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(t, err)
resp.Body.Close()
}
// Device reports book with ambiguous title
device := setup.CreateDevice(t, "Test Device", "koreader", "test-ambiguous")
ambiguousBook := map[string]interface{}{
"title": "Book", // Could match any of the three
"author": "Same Author",
}
jsonData, _ := json.Marshal(ambiguousBook)
req = httptest.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/match", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
// NEW: Verify ambiguous match handling
assert.Equal(t, http.StatusOK, resp.StatusCode)
var response map[string]interface{}
json.NewDecoder(resp.Body).Decode(&response)
// Should return multiple matches with similar confidence
matches, hasMatches := response["matches"]
require.True(t, hasMatches, "Should have matches")
matchArray := matches.([]interface{})
assert.Greater(t, len(matchArray), 1, "Should have multiple matches for ambiguous title")
// All matches should have similar confidence scores
if len(matchArray) > 1 {
firstMatch := matchArray[0].(map[string]interface{})
secondMatch := matchArray[1].(map[string]interface{})
firstConfidence := firstMatch["confidence"].(float64)
secondConfidence := secondMatch["confidence"].(float64)
// Confidence scores should be similar (within 10%)
confidenceDiff := firstConfidence - secondConfidence
assert.Less(t, confidenceDiff, 0.1, "Similar matches should have close confidence")
}
}
File-by-File Plan
File: cmd/server/tests/book_matching_test.go
Add new test functions:
-
TestBookMatching_NoMatches (after existing tests)
- Lines: Add ~50 lines
- Purpose: Book with no close matches
- Verification: Empty matches or manual match suggestion
-
TestBookMatching_MultipleEqualConfidence (after previous test)
- Lines: Add ~80 lines
- Purpose: Multiple books with similar titles
- Verification: Returns multiple matches, suggests disambiguation
-
TestBookMatching_SpecialCharacters (after previous test)
- Lines: Add ~60 lines
- Purpose: Titles with quotes, apostrophes, emojis
- Verification: Proper query escaping, correct matches
-
TestBookMatching_UnicodeNormalization (after previous test)
- Lines: Add ~60 lines
- Purpose: Same title in different Unicode forms (NFD vs NFC)
- Verification: Correct match despite Unicode differences
Action: Add 4 new test functions to book_matching_test.go.
Phase 3 Summary
New Test Functions: 16+
Lines Added: ~1000 lines
Error Path Coverage: WebSocket, sync, OPDS, matching
Edge Case Coverage: Empty, large, corrupted, special characters
Verification Steps:
- ✅ WebSocket has error path tests
- ✅ Sync has failure scenario tests
- ✅ OPDS has edge case tests
- ✅ Book matching has ambiguity tests
Phase 4: Load and Security
Priority: 🟢 LOW - Do after Phase 3
Time Estimate: 10-12 new test functions
Risk Level: Low (new tests only)
ROI: Medium - prevents DoS and encoding issues
Objectives
- Add very large payload tests
- Add comprehensive Unicode/special character tests
- Add max boundary tests
4.1 Very Large Payloads
Problem Areas
| Area | Current | Missing | Risk |
|---|---|---|---|
| Single test | edge_cases_test.go:327 - 100KB string |
Bulk operations with 1000+ items | Memory exhaustion |
| Response size | Not tested | User with 10,000 books | Timeouts |
| WebSocket messages | Not tested | Very large sync payloads | Frame size limits |
| Upload limits | Not tested | Huge book files | Disk space exhaustion |
Test Patterns
Pattern 1: Bulk Operation Limits
// NEW TEST: Bulk operation with maximum items
func TestBulk_MaximumItems(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
device := setup.CreateDevice(t, "Test Device", "koreader", "test-bulk-max")
// Create library with folder
libReq := map[string]interface{}{
"name": "Bulk Test Library",
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResponse)
libraryID := libResponse["id"].(string)
// Add folder
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
}
folderBody, _ := json.Marshal(folderReq)
req = httptest.NewRequest("POST", fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID), bytes.NewBuffer(folderBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(t, err)
resp.Body.Close()
// Create many books (1000 items)
const numBooks = 1000
var mediaIDs []string
for i := 0; i < numBooks; i++ {
mediaReq := map[string]interface{}{
"library_id": libraryID,
"title": fmt.Sprintf("Book %d", i),
"author": "Test Author",
"file_path": fmt.Sprintf("/tmp/test%d.epub", i),
"file_size": 1024,
"mime_type": "application/epub+zip",
}
mediaBody, _ := json.Marshal(mediaReq)
req = httptest.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(mediaBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
if err != nil {
t.Logf("Failed to create book %d: %v", i, err)
continue
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusCreated {
var mediaResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&mediaResponse)
mediaIDs = append(mediaIDs, mediaResponse["id"].(string))
}
}
// Test bulk update with all 1000 items
bulkUpdateReq := map[string]interface{}{
"media_item_ids": mediaIDs,
"tags": []string{"bulk-updated"},
}
bulkBody, _ := json.Marshal(bulkUpdateReq)
req = httptest.NewRequest("POST", setup.Server.URL+"/api/media-items/bulk-update", bytes.NewBuffer(bulkBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
start := time.Now()
resp, err = client.Do(req)
duration := time.Since(start)
if err != nil {
t.Logf("Bulk update with %d items failed: %v", numBooks, err)
return
}
defer resp.Body.Close()
// NEW: Verify behavior under load
t.Logf("Bulk update of %d items took %v", numBooks, duration)
// Should succeed but might take time
if resp.StatusCode == http.StatusOK {
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
// Verify count
if total, ok := result["total"]; ok {
assert.Equal(t, numBooks, int(total.(float64)), "Should process all items")
}
// Verify DB state for sample
if len(mediaIDs) > 0 {
pgID := pgtype.UUID{Bytes: [16]byte(mediaIDs[0]), Valid: true}
media, err := setup.DB.GetMediaItem(context.Background(), pgID)
require.NoError(t, err)
assert.Contains(t, media.Tags, "bulk-updated", "Should have updated tag")
}
} else if resp.StatusCode == http.StatusRequestEntityTooLarge {
t.Logf("Bulk update rejected as too large (acceptable)")
} else {
t.Logf("Unexpected status: %d", resp.StatusCode)
}
}
Pattern 2: Response Size Limits
// NEW TEST: API response with very large payload
func TestResponse_VeryLargePayload(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
// Create library with many books (from previous test or reuse)
// Assuming we have 1000 books from previous test
// Request list of all books (large response)
req := httptest.NewRequest("GET", setup.Server.URL+"/api/media-items?limit=10000", nil)
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 30 * time.Second}
start := time.Now()
resp, err := client.Do(req)
duration := time.Since(start)
if err != nil {
t.Logf("Large payload request failed: %v", err)
return
}
defer resp.Body.Close()
// NEW: Verify large response handling
t.Logf("Large payload response (%d status) took %v", resp.StatusCode, duration)
if resp.StatusCode == http.StatusOK {
// Read body (might be large)
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Logf("Failed to read large response: %v", err)
return
}
t.Logf("Response size: %d bytes", len(body))
// Should have reasonable response size (< 10MB)
assert.Less(t, len(body), 10*1024*1024, "Response should be < 10MB")
// Verify response is valid JSON
var response map[string]interface{}
err = json.Unmarshal(body, &response)
assert.NoError(t, err, "Large response should be valid JSON")
// Verify pagination
if items, ok := response["items"]; ok {
itemArray := items.([]interface{})
t.Logf("Returned %d items", len(itemArray))
}
} else if resp.StatusCode == http.StatusRequestEntityTooLarge {
t.Logf("Large payload rejected (acceptable)")
}
}
File-by-File Plan
File: cmd/server/tests/edge_cases_test.go
Add new test functions:
-
TestBulk_MaximumItems (after existing edge cases)
- Lines: Add ~120 lines
- Purpose: Bulk operation with 1000 items
- Verification: Succeeds or rejected gracefully, no crashes
-
TestResponse_VeryLargePayload (after previous test)
- Lines: Add ~80 lines
- Purpose: Request all items (large response)
- Verification: Reasonable size, valid JSON, proper pagination
-
TestWebSocket_LargeMessage (after previous test)
- Lines: Add ~60 lines
- Purpose: Send very large WebSocket message
- Verification: Rejected or handled gracefully
Action: Add 3 new test functions to edge_cases_test.go.
4.2 Comprehensive Unicode Tests
Problem Areas
| Character Type | Missing Tests | Risk |
|---|---|---|
| Emojis | Only 1 test | Database encoding issues |
| RTL languages | None | Display corruption |
| Zero-width characters | None | Silent duplicates |
| Unicode normalization | None | Same text appears different |
| Combining characters | None | Search failures |
Test Pattern
// NEW TEST: Comprehensive Unicode handling
func TestUnicode_Comprehensive(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
testCases := []struct {
name string
title string
author string
expectOK bool
}{
{
name: "Emojis in title",
title: "The Great Adventure 🚀📚✨",
author: "Jane Doe",
expectOK: true,
},
{
name: "Multiple emojis",
title: "📖 Book 🌟 of 🎉 Joy 😊",
author: "Happy Author 👩🚀",
expectOK: true,
},
{
name: "RTL text (Arabic)",
title: "كتاب عظيم",
author: "مؤلف بارز",
expectOK: true,
},
{
name: "RTL text (Hebrew)",
title: "ספר נהדר",
author: "סופר מפורסם",
expectOK: true,
},
{
name: "Mixed RTL and LTR",
title: "Hello שלום Bonjour",
author: "Author نبي",
expectOK: true,
},
{
name: "Zero-width characters",
title: "Book\u200BTitle", // Zero-width space
author: "Author\u200CName", // Zero-width non-joiner
expectOK: true,
},
{
name: "Combining diacritics",
title: "Café", // Precomposed é
author: "cafe\u0301", // Combining acute
expectOK: true,
},
{
name: "Multiple combining marks",
title: "ñaïve", // Precomposed ï
author: "nai\u0308ve", // Combining diaeresis
expectOK: true,
},
{
name: "CJK characters",
title: "伟大的书",
author: "作者",
expectOK: true,
},
{
name: "Special punctuation",
title: "Book: A Story; Of—Things—Like—This",
author: "O'Brien, St. John",
expectOK: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create library
libReq := map[string]interface{}{
"name": fmt.Sprintf("Unicode Library %s", tc.name),
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
req := httptest.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var libResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&libResponse)
libraryID := libResponse["id"].(string)
// Add folder
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
}
folderBody, _ := json.Marshal(folderReq)
req = httptest.NewRequest("POST", fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID), bytes.NewBuffer(folderBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(t, err)
resp.Body.Close()
// Create book with Unicode title/author
mediaReq := map[string]interface{}{
"library_id": libraryID,
"title": tc.title,
"author": tc.author,
"file_path": "/tmp/test.epub",
"file_size": 1024,
"mime_type": "application/epub+zip",
}
mediaBody, _ := json.Marshal(mediaReq)
req = httptest.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(mediaBody))
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+token)
resp, err = client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
if tc.expectOK {
assert.Equal(t, http.StatusCreated, resp.StatusCode, tc.name)
// NEW: Verify database stores Unicode correctly
var mediaResponse map[string]interface{}
json.NewDecoder(resp.Body).Decode(&mediaResponse)
mediaID := mediaResponse["id"].(string)
pgID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
media, err := setup.DB.GetMediaItem(context.Background(), pgID)
require.NoError(t, err, "Should retrieve media item")
// Verify Unicode preserved
assert.Equal(t, tc.title, media.Title, "Title should preserve Unicode")
assert.Equal(t, tc.author, media.Author.String, "Author should preserve Unicode")
// Test search with Unicode
searchReq := httptest.NewRequest("GET", fmt.Sprintf("%s/api/search?q=%s", setup.Server.URL, url.QueryEscape(tc.title)), nil)
searchReq.Header.Set("Authorization", "Bearer "+token)
searchResp, err := client.Do(searchReq)
require.NoError(t, err)
defer searchResp.Body.Close()
assert.Equal(t, http.StatusOK, searchResp.StatusCode, "Search should work with Unicode")
} else {
assert.NotEqual(t, http.StatusCreated, resp.StatusCode, tc.name)
}
})
}
}
File-by-File Plan
File: cmd/server/tests/edge_cases_test.go
Add new test function:
- TestUnicode_Comprehensive (after existing edge cases)
- Lines: Add ~150 lines
- Purpose: Emojis, RTL, zero-width, combining marks, CJK
- Verification: Stored correctly, searchable
Action: Add new test function to edge_cases_test.go.
4.3 Max Boundary Tests
Problem Areas
| Field Type | Missing | Risk |
|---|---|---|
| Exact boundary (254, 255, 256) | Truncation | |
| Username | Boundary (49, 50, 51, 500, 5000) | Validation inconsistency |
| Device name | Max length | DB constraint violation |
| Book title | Max length | Display issues |
| Array items | Max items in bulk | Performance degradation |
Test Pattern
// NEW TEST: Boundary value testing
func TestBoundaries_MaxLengths(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
t.Run("Email at exact boundaries", func(t *testing.T) {
testCases := []struct {
name string
email string
expectOK bool
}{
{"254 chars (one under limit)", strings.Repeat("a", 250) + "@example.com", true},
{"255 chars (exact limit)", strings.Repeat("a", 251) + "@example.com", true},
{"256 chars (one over limit)", strings.Repeat("a", 252) + "@example.com", false},
{"Very long (5000 chars)", strings.Repeat("a", 4995) + "@example.com", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create user with boundary email
// ... test code ...
})
}
})
t.Run("Username at exact boundaries", func(t *testing.T) {
testCases := []struct {
name string
username string
expectOK bool
}{
{"2 chars (one under min)", "ab", false},
{"3 chars (exact min)", "abc", true},
{"50 chars (exact max)", strings.Repeat("a", 50), true},
{"51 chars (one over max)", strings.Repeat("a", 51), false},
{"500 chars (way over)", strings.Repeat("a", 500), false},
{"5000 chars (extreme)", strings.Repeat("a", 5000), false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Create user with boundary username
// ... test code ...
})
}
})
t.Run("Device name at max length", func(t *testing.T) {
// Test device name at database column limit (likely 255)
// ... test code ...
})
t.Run("Book title at max length", func(t *testing.T) {
// Test title at database column limit
// ... test code ...
})
t.Run("Bulk array size limits", func(t *testing.T) {
// Test bulk operations with arrays at limits
// ... test code ...
})
}
File-by-File Plan
File: cmd/server/tests/edge_cases_test.go
Add new test function:
- TestBoundaries_MaxLengths (after Unicode tests)
- Lines: Add ~200 lines
- Purpose: Exact boundary testing for all text fields
- Verification: Consistent validation, no truncation
Action: Add new test function to edge_cases_test.go.
Phase 4 Summary
New Test Functions: 5+
Lines Added: ~600 lines
Load Testing: Bulk operations, large responses
Unicode Coverage: Emojis, RTL, zero-width, combining, CJK
Boundary Testing: Exact limits for all text fields
Verification Steps:
- ✅ Large payloads handled gracefully
- ✅ Unicode characters work end-to-end
- ✅ Boundary values consistent
Implementation Order
Phase 1: Stop Silent Failures (HIGHEST ROI)
Week 1-2:
- Replace map-based assertions with structs (12 files)
- Add database verification (8 files)
- Add null/empty/missing tests (3 files)
Deliverables:
- All tests use struct-based assertions
- All mutations verify database state
- Critical edge cases covered
Verification:
# All tests compile
go test ./cmd/server/tests/... -v
# No map[string]interface{} in assertions
grep -r "map\[string\]interface" cmd/server/tests/ | wc -l # Should be 0
# All mutations have DB verification
# (Manual review of test files)
Phase 2: Concurrency Protection (HIGH)
Week 3:
- Add sync race condition tests (4 tests)
- Add bulk concurrent tests (3 tests)
- Add profile + device tests (2 tests)
Deliverables:
- All sync operations have concurrent tests
- All bulk operations have concurrent tests
- Profile + device interactions tested
Verification:
# Run with race detector
go test ./cmd/server/tests/... -race -v
# All concurrent tests pass
Phase 3: Hardened Error Handling (MEDIUM)
Week 4:
- Add WebSocket error paths (4 tests)
- Add sync failures (4 tests)
- Add OPDS edge cases (4 tests)
- Add book matching edge cases (4 tests)
Deliverables:
- WebSocket has comprehensive error tests
- Sync has failure scenario tests
- OPDS handles edge cases
- Book matching handles ambiguity
Verification:
# All error path tests pass
go test ./cmd/server/tests/... -v -run "TestWebSocket|TestSync|TestOPDS|TestBookMatching"
Phase 4: Load and Security (LOW)
Week 5:
- Add large payload tests (3 tests)
- Add Unicode tests (1 comprehensive test)
- Add boundary tests (1 comprehensive test)
Deliverables:
- Large payloads handled gracefully
- Unicode works end-to-end
- Boundary values consistent
Verification:
# All load/security tests pass
go test ./cmd/server/tests/edge_cases_test.go -v
Verification Checklist
Pre-Implementation
- All current tests pass
go test ./cmd/server/tests/... -v - No compilation errors
go build ./... - Guidelines verification passes
bash scripts/verify-guidelines.sh
Phase 1 Verification
- All
map[string]interface{}replaced with structsgrep -r "map\[string\]interface" cmd/server/tests/*.go | wc -l # Should be 0 - All mutation operations have DB verification
# Manual review: Every POST/PUT/DELETE test should query DB after - Null/empty/missing tests added for critical fields
grep -r "NullVsEmpty" cmd/server/tests/*.go | wc -l # Should be > 0
Phase 2 Verification
- All sync operations have concurrent tests
grep -r "Concurrent" cmd/server/tests/sync_integration_test.go | wc -l # Should be > 0 - All bulk operations have concurrent tests
grep -r "Concurrent" cmd/server/tests/*_bulk_test.go | wc -l # Should be > 0 - Race detector passes
go test ./cmd/server/tests/... -race -v
Phase 3 Verification
- WebSocket has error path tests
grep -r "DisconnectMid\|MalformedFrames\|Timeout" cmd/server/tests/websocket_test.go | wc -l # Should be > 0 - Sync has failure tests
grep -r "Offline\|QueueOverflow\|Conflicting" cmd/server/tests/sync_integration_test.go | wc -l # Should be > 0 - OPDS has edge case tests
grep -r "Empty\|Large\|Special" cmd/server/tests/opds_test.go | wc -l # Should be > 0 - Book matching has ambiguity tests
grep -r "NoMatches\|MultipleEqual\|Unicode" cmd/server/tests/book_matching_test.go | wc -l # Should be > 0
Phase 4 Verification
- Large payload tests exist
grep -r "Maximum\|VeryLarge" cmd/server/tests/edge_cases_test.go | wc -l # Should be > 0 - Unicode tests exist
grep -r "Emoji\|RTL\|Zero-width\|Combining" cmd/server/tests/edge_cases_test.go | wc -l # Should be > 0 - Boundary tests exist
grep -r "Boundaries\|254\|255\|256" cmd/server/tests/edge_cases_test.go | wc -l # Should be > 0
Post-Implementation
- All tests pass
go test ./cmd/server/tests/... -v - No compilation errors
go build ./... - Guidelines verification passes
bash scripts/verify-guidelines.sh - Test coverage increased
go test ./cmd/server/tests/... -cover
Success Metrics
Before
- 507+ map-based assertions (brittle)
- 0 concurrency tests
- 0 database verification for mutations
- 0 null/empty/missing edge cases
- Happy path only for WebSocket, sync, OPDS, matching
After (All Phases Complete)
- 0 map-based assertions (100% struct-based)
- 10+ concurrency tests
- 100% database verification for mutations
- 15+ null/empty/missing edge case tests
- Comprehensive error paths for WebSocket, sync, OPDS, matching
- Load testing for bulk operations
- Unicode coverage for emojis, RTL, zero-width, combining marks
- Boundary testing for all text fields
Reliability Score
| Area | Before | After | Improvement |
|---|---|---|---|
| API contract safety | ❌ 0% | ✅ 100% | Compile-time detection |
| Data integrity | ⚠️ 20% | ✅ 100% | DB verification |
| Concurrency | ❌ 0% | ✅ 80% | Race tests |
| Edge cases | ⚠️ 10% | ✅ 90% | Comprehensive |
| Error handling | ⚠️ 30% | ✅ 90% | Failure scenarios |
| Load handling | ⚠️ 5% | ✅ 70% | Large payloads |
| Overall | 13% | 90% | +77% |
Notes
-
Follow PROJECT_GUIDELINES.md:
- Use
setupTestServer()from test_helpers.go - Share one test setup across subtests (call once at function level)
- Use table-driven tests with
t.Run() - Configure database with
max_conns=1viapgxpool.ParseConfig()
- Use
-
No cascading fixes: Stop on compilation error, review
git diff, recover deliberately -
Post-edit verification: Run
go buildfor affected packages after each edit -
Multiple logical commits: Commit changes in logical steps with clear messages
-
Test execution speed: If tests become too slow, consider:
- Parallel test execution (
t.Parallel()) - Test-specific database fixtures
- Reduced dataset sizes for load tests
- Parallel test execution (
Appendix: Conversion Examples
Example 1: Simple Response
BEFORE:
var response map[string]interface{}
json.Unmarshal(body, &response)
deviceName := response["device"].(map[string]interface{})["device_name"].(string)
assert.Equal(t, "Test Device", deviceName)
AFTER:
import "bookhoard/internal/handlers"
var response handlers.DeviceListResponse
err := json.Unmarshal(body, &response)
require.NoError(t, err)
assert.Equal(t, "Test Device", response.Devices[0].DeviceName)
Example 2: Array Response
BEFORE:
var response map[string]interface{}
json.Unmarshal(body, &response)
devices := response["devices"].([]interface{})
for _, d := range devices {
device := d.(map[string]interface{})
name := device["device_name"].(string)
// ...
}
AFTER:
import "bookhoard/internal/handlers"
var response handlers.DeviceListResponse
err := json.Unmarshal(body, &response)
require.NoError(t, err)
for _, device := range response.Devices {
name := device.DeviceName
// ...
}
Example 3: Database Verification
BEFORE:
assert.Equal(t, http.StatusNoContent, rec.Code)
AFTER:
assert.Equal(t, http.StatusNoContent, rec.Code)
pgID := pgtype.UUID{Bytes: [16]byte(id), Valid: true}
_, err := setup.DB.GetDevice(context.Background(), pgID)
assert.Error(t, err, "Device should be deleted")
Appendix: test_helpers.go Improvements
Critical Bug Fixes
Issue 1: Return Type Mismatch (Line 210)
Problem:
// Line 210 - WRONG: Returns UserTestData instead of error
return fmt.Errorf("user already exists: %s", existingUser.Email)
Fix:
// Line 210 - CORRECT: Return error
return fmt.Errorf("user already exists: %s", existingUser.Email), UserTestData{}
// OR remove the early return entirely since user exists check should delete and recreate
Impact: This causes compilation errors and prevents createTestUserOnce from working correctly.
Issue 2: Dead Code (Lines 207-214)
Problem:
// Lines 207-214: Early return makes code below unreachable
if err == nil {
return fmt.Errorf("user already exists: %s", existingUser.Email)
}
return UserTestData{} // NEVER REACHED
// Lines 216-236: Never executed due to early return
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
user, err := db.CreateUser(ctx, database.CreateUserParams{
// ...
})
Fix:
// Remove early return and always delete + recreate
ctx := context.Background()
// Check if user exists and delete for fresh state
user, err := db.GetUserByEmail(ctx, "testuser@example.com")
if err == nil {
// User exists, delete them to ensure fresh password
err = db.DeleteUser(ctx, user.ID)
if err != nil {
// If delete fails (user might be referenced elsewhere), log and continue
t.Logf("Warning: Could not delete existing test user: %v", err)
}
}
// Create a fresh test user with a valid password
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
Email: "testuser@example.com",
Username: "testuser",
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Test", Valid: true},
LastName: pgtype.Text{String: "User", Valid: true},
Role: "admin",
})
require.NoError(t, err, "Failed to create test user")
userUUID, err := uuid.FromBytes(newUser.ID.Bytes[:])
require.NoError(t, err, "Failed to parse user UUID")
return UserTestData{
ID: userUUID,
Email: "testuser@example.com",
Username: "testuser",
Password: "Test@Pass123!",
}
Impact: Dead code prevents test user creation from working properly.
New Helper Functions
Add these helpers to test_helpers.go to reduce code duplication across tests and provide consistent database verification.
Helper 1: Database Verification Functions
// Add to test_helpers.go after line 562
// verifyDeviceCreated verifies a device exists in database with expected values
func verifyDeviceCreated(t *testing.T, db *database.Queries, deviceID uuid.UUID, expected DeviceTestData) {
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
device, err := db.GetDevice(context.Background(), pgDeviceID)
require.NoError(t, err, "Device should exist in database")
assert.Equal(t, expected.Name, device.DeviceName, "Device name should match")
assert.Equal(t, expected.Type, device.DeviceType, "Device type should match")
assert.Equal(t, expected.Identifier, device.DeviceIdentifier, "Device identifier should match")
assert.NotEmpty(t, device.AuthToken, "Device should have auth token")
}
// verifyDeviceDeleted verifies a device does not exist in database
func verifyDeviceDeleted(t *testing.T, db *database.Queries, deviceID uuid.UUID) {
pgDeviceID := pgtype.UUID{Bytes: [16]byte(deviceID), Valid: true}
_, err := db.GetDevice(context.Background(), pgDeviceID)
assert.Error(t, err, "Device should be deleted from database")
}
// verifyUserUpdated verifies a user has expected field values in database
func verifyUserUpdated(t *testing.T, db *database.Queries, userID uuid.UUID, expected map[string]interface{}) {
pgUserID := pgtype.UUID{Bytes: [16]byte(userID), Valid: true}
user, err := db.GetUser(context.Background(), pgUserID)
require.NoError(t, err, "User should exist in database")
if firstName, ok := expected["first_name"]; ok {
if fn, ok := firstName.(string); ok {
assert.Equal(t, fn, user.FirstName.String, "First name should match")
}
}
if lastName, ok := expected["last_name"]; ok {
if ln, ok := lastName.(string); ok {
assert.Equal(t, ln, user.LastName.String, "Last name should match")
}
}
if email, ok := expected["email"]; ok {
if em, ok := email.(string); ok {
assert.Equal(t, em, user.Email, "Email should match")
}
}
}
// verifyMediaItemUpdated verifies a media item has expected values in database
func verifyMediaItemUpdated(t *testing.T, db *database.Queries, mediaID uuid.UUID, expected map[string]interface{}) {
pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
media, err := db.GetMediaItem(context.Background(), pgMediaID)
require.NoError(t, err, "Media item should exist in database")
if title, ok := expected["title"]; ok {
if t, ok := title.(string); ok {
assert.Equal(t, t, media.Title, "Title should match")
}
}
if tags, ok := expected["tags"]; ok {
if tagArray, ok := tags.([]string); ok {
assert.ElementsMatch(t, tagArray, media.Tags, "Tags should match")
}
}
// Add more fields as needed
}
Usage in tests:
// Instead of manual DB queries:
device := setup.CreateDevice(t, "Test Device", "koreader", "test-123")
// NEW: Use helper
verifyDeviceCreated(t, setup.DB, device.ID, DeviceTestData{
Name: "Test Device",
Type: "koreader",
Identifier: "test-123",
})
Helper 2: Request Builder Functions
// Add to test_helpers.go after database verification functions
// buildLoginRequest creates a login request map
func buildLoginRequest(email, password string) map[string]interface{} {
return map[string]interface{}{
"login": email,
"password": password,
}
}
// buildDeviceUpdateRequest creates a device update request map
func buildDeviceUpdateRequest(name string, syncEnabled *bool, syncFreq *int32) map[string]interface{} {
req := map[string]interface{}{}
if name != "" {
req["device_name"] = name
}
if syncEnabled != nil {
req["sync_enabled"] = *syncEnabled
}
if syncFreq != nil {
req["sync_frequency_minutes"] = *syncFreq
}
return req
}
// buildMediaItemRequest creates a media item request map
func buildMediaItemRequest(libraryID, title, author, filePath, mimeType string, fileSize int64) map[string]interface{} {
return map[string]interface{}{
"library_id": libraryID,
"title": title,
"author": author,
"file_path": filePath,
"file_size": fileSize,
"mime_type": mimeType,
}
}
// buildUserUpdateRequest creates a user profile update request map
func buildUserUpdateRequest(firstName, lastName, email, theme string) map[string]interface{} {
req := map[string]interface{}{}
if firstName != "" {
req["first_name"] = firstName
}
if lastName != "" {
req["last_name"] = lastName
}
if email != "" {
req["email"] = email
}
if theme != "" {
req["theme"] = theme
}
return req
}
Usage in tests:
// BEFORE: Manually build maps
loginRequest := map[string]interface{}{
"login": email,
"password": password,
}
// AFTER: Use helper
loginRequest := buildLoginRequest(email, password)
Helper 3: Struct-Based Response Assertions
// Add to test_helpers.go after request builder functions
import (
"bookhoard/internal/handlers"
// ... existing imports
)
// assertDeviceListResponse parses and asserts DeviceListResponse
func assertDeviceListResponse(t *testing.T, body []byte) handlers.DeviceListResponse {
var response handlers.DeviceListResponse
err := json.Unmarshal(body, &response)
require.NoError(t, err, "Response should match DeviceListResponse schema")
return response
}
// assertDeviceUpdateResponse parses and asserts DeviceUpdateResponse
func assertDeviceUpdateResponse(t *testing.T, body []byte) handlers.DeviceUpdateResponse {
var response handlers.DeviceUpdateResponse
err := json.Unmarshal(body, &response)
require.NoError(t, err, "Response should match DeviceUpdateResponse schema")
return response
}
// assertLoginResponse parses and asserts LoginResponse
func assertLoginResponse(t *testing.T, body []byte) handlers.LoginResponse {
var response handlers.LoginResponse
err := json.Unmarshal(body, &response)
require.NoError(t, err, "Response should match LoginResponse schema")
return response
}
// assertMediaListResponse parses and asserts MediaListResponse
func assertMediaListResponse(t *testing.T, body []byte) handlers.MediaListResponse {
var response handlers.MediaListResponse
err := json.Unmarshal(body, &response)
require.NoError(t, err, "Response should match MediaListResponse schema")
return response
}
// assertLibraryResponse parses and asserts LibraryResponse
func assertLibraryResponse(t *testing.T, body []byte) handlers.LibraryResponse {
var response handlers.LibraryResponse
err := json.Unmarshal(body, &response)
require.NoError(t, err, "Response should match LibraryResponse schema")
return response
}
Usage in tests:
// BEFORE: Map-based parsing
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
deviceName := response["device"].(map[string]interface{})["device_name"].(string)
assert.Equal(t, "Test Device", deviceName)
// AFTER: Struct-based parsing
response := assertDeviceListResponse(t, rec.Body.Bytes())
assert.Equal(t, "Test Device", response.Devices[0].DeviceName)
Helper 4: Concurrent Test Utilities
// Add to test_helpers.go after assertion helpers
// runConcurrent executes functions concurrently and waits for completion
func runConcurrent(t *testing.T, numWorkers int, fns []func() error) []error {
errors := make(chan error, len(fns))
var wg sync.WaitGroup
for i := 0; i < len(fns); i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
if err := fns[idx](); err != nil {
errors <- err
}
}(i)
}
wg.Wait()
close(errors)
var allErrors []error
for err := range errors {
allErrors = append(allErrors, err)
}
return allErrors
}
// retryOperation retries a function with exponential backoff
func retryOperation(maxRetries int, initialDelay time.Duration, fn func() error) error {
var err error
delay := initialDelay
for attempt := 0; attempt < maxRetries; attempt++ {
if err = fn(); err == nil {
return nil
}
if attempt < maxRetries-1 {
time.Sleep(delay)
delay *= 2 // Exponential backoff
}
}
return err
}
Usage in tests:
// BEFORE: Manual goroutine management
var wg sync.WaitGroup
errors := make(chan error, 3)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
// ... operation ...
}(i)
}
wg.Wait()
close(errors)
// AFTER: Helper function
fns := []func() error{
func() error { /* operation 1 */ return nil },
func() error { /* operation 2 */ return nil },
func() error { /* operation 3 */ return nil },
}
errors := runConcurrent(t, len(fns), fns)
Updated test_helpers.go Structure
// Line 1: Add handlers import
import (
"bookhoard/internal/config"
"bookhoard/internal/database"
"bookhoard/internal/handlers" // NEW
"bookhoard/internal/middleware"
// ... rest of imports
)
// Line 210: FIX return type
// BEFORE:
return fmt.Errorf("user already exists: %s", existingUser.Email)
// AFTER:
// Remove early return, let code continue to delete + recreate
// Line 213-236: FIX dead code
// These lines are now reachable after removing early return
// Keep the CreateUser logic
// After line 562: ADD new helper functions
// (See Helper Functions section above)
File-by-File Changes: test_helpers.go
| Lines | Change | Impact |
|---|---|---|
| 4-31 | Add "bookhoard/internal/handlers" import |
Enables struct-based assertions |
| 210 | Fix return type from UserTestData to error, or remove early return |
Fixes compilation error |
| 207-214 | Remove early return or fix dead code | Makes user creation code reachable |
| 562+ | Add verification helpers (5 functions) | Reduces duplication, ensures DB verification |
| 562+ | Add request builders (4 functions) | Reduces map duplication |
| 562+ | Add struct assertion helpers (5 functions) | Enables compile-time safety |
| 562+ | Add concurrent utilities (2 functions) | Simplifies concurrent tests |
Total Lines Added: ~300 lines
Total Lines Modified: ~10 lines
Functions Added: 16 new helper functions
Summary of test_helpers.go Improvements
| Category | Before | After | Improvement |
|---|---|---|---|
| Critical Bugs | 2 compilation errors | 0 | All tests compile |
| Code Duplication | Map-based requests repeated everywhere | Shared builder functions | Consistent request building |
| Database Verification | Manual queries scattered | Helper functions | Guaranteed verification |
| Type Safety | map[string]interface{} | Struct-based assertions | Compile-time error detection |
| Concurrent Tests | Manual goroutine management | Utility functions | Consistent patterns |
| Maintainability | Logic duplicated in tests | Centralized helpers | Easier to update |
END OF PLAN
END OF COMPREHENSIVE REVIEW REPORT
Date: 2025-02-13
Scope: Complete review of entire test system (30 test files, ~10,941 lines)
Purpose: Identify ALL gaps in TEST_RELIABILITY_PLAN.md and ensure complete coverage
Executive Summary
The comprehensive review identified 69 individual issues across 30 test files. Critical findings:
Most Critical Gaps
| Gap | Impact | Priority |
|---|---|---|
| test_helpers.go has 2 critical bugs | Tests fail compilation, user creation broken | 🔴 CRITICAL |
| 69% of tests use map-based assertions | Silent API changes, type errors | 🔴 CRITICAL |
| 85% of tests lack DB verification | Data corruption, silent failures | 🔴 CRITICAL |
| 96% of tests lack concurrency | Race conditions in production | 🟠 HIGH |
| 10 test files not in plan | Missing coverage, unknown gaps | 🟡 MEDIUM |
| Zero Unicode edge cases | Encoding failures, search issues | 🟠 HIGH |
| Unit tests give false confidence | Mock handlers != real integration | 🟡 MEDIUM |
Recommended Priority Change
Original: Phase 1 → 2 → 3 → 4
Revised: Phase 0 (NEW) → 1 → 2 → 3
Phase 0: Fix Test Infrastructure (NEW - 2-3 hours)
- Fix 2 critical bugs in test_helpers.go
- Add 6 missing helper functions
- Move Unicode tests from Phase 4 → Phase 1
- Create test isolation improvements
Rationale: Tests depend on test_helpers.go. Bugs there block all other work. Unicode too important to delay.