Files
bookhoard/cmd/server/tests/test_helpers_db_test.go
T
John O'Keefe 843f35bbfc test(server): runs leave no residue — auto-cleanup for every resource the suite creates
Symptom: repeated full-suite runs poisoned the dev database with
leaked rows (duplicate Prefs Scope libraries, colliding device names,
saved-filter name conflicts on rerun) because tests created rows but
never deleted them, and stale-binary runs left rows the new cleanup
could not match.

- test_helpers: library creation now registers t.Cleanup DELETE (the
  leak root cause — createTestLibraryWithFolder never cleaned up).
  Suite-wide cleanup passes for libraries/collections/devices/
  saved-filters run both at setup (pre-pass against legacy residue)
  and in t.Cleanup (no per-run residue). Preserve the admin's real
  libraries; delete only convention-named test rows.
- setup seeds canonical system collections every run keyed on
  query_type (idempotent — heals renames/stale tombstones), moved out
  of the preserved-admin branch so it runs for every suite start.
- dashboard_integration: scope-testing library renamed into the
  test-row convention; ordering assertions on the restored
  "Want to Read" collection preserved.
- filters_test: filter names moved into the convention (rerun-safe —
  name-uniqueness 409 no longer trips second runs); five  .(string)
  type assertions panic-proofed with require.True guards (a panic here
  used to kill the whole test process mid-run, which is why a stack of
  "never ran" failures surfaced at once later).
- device_test: device names moved into the convention (ILIKE %test%
  hit them; duplicate-device-name conflicts gone).
2026-10-09 11:04:34 -04:00

177 lines
5.7 KiB
Go

package main
import (
"bookhoard/internal/database"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper functions for database verification and test utilities
// These functions reduce code duplication and ensure consistent database state verification
// verifyDeviceCreated verifies a device exists in database with expected values
func verifyDeviceCreated(t *testing.T, db *database.Queries, deviceID uuid.UUID, expectedName, expectedType, expectedIdentifier string) {
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, expectedName, device.DeviceName, "Device name should match")
assert.Equal(t, expectedType, device.DeviceType, "Device type should match")
assert.Equal(t, expectedIdentifier, 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")
}
// verifyUserField verifies a user has expected field value in database
func verifyUserField(t *testing.T, db *database.Queries, userID uuid.UUID, field string, expected 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")
switch field {
case "email":
if em, ok := expected.(string); ok {
assert.Equal(t, em, user.Email, "Email should match")
}
case "first_name":
if fn, ok := expected.(string); ok {
assert.Equal(t, fn, user.FirstName.String, "First name should match")
}
case "last_name":
if ln, ok := expected.(string); ok {
assert.Equal(t, ln, user.LastName.String, "Last name should match")
}
case "username":
if un, ok := expected.(string); ok {
assert.Equal(t, un, user.Username, "Username should match")
}
case "theme":
if th, ok := expected.(string); ok {
assert.Equal(t, th, user.Theme.String, "Theme should match")
}
}
}
// verifyMediaItemInDB verifies a media item exists in database
func verifyMediaItemInDB(t *testing.T, db *database.Queries, mediaID uuid.UUID) {
pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
_, err := db.GetMediaItem(context.Background(), pgMediaID)
require.NoError(t, err, "Media item should exist in database")
}
// verifyMediaItemDeleted verifies a media item does not exist in database
func verifyMediaItemDeleted(t *testing.T, db *database.Queries, mediaID uuid.UUID) {
pgMediaID := pgtype.UUID{Bytes: [16]byte(mediaID), Valid: true}
_, err := db.GetMediaItem(context.Background(), pgMediaID)
assert.Error(t, err, "Media item should be deleted from database")
}
// createTestLibraryWithFolder creates a test library with optional folder
func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name string, withFolder bool) string {
libReq := map[string]interface{}{
"name": name,
"type": "ebooks",
}
libBody, _ := json.Marshal(libReq)
libHTTP, _ := http.NewRequest("POST", ts.URL+"/api/libraries", bytes.NewBuffer(libBody))
libHTTP.Header.Set("Content-Type", "application/json")
libHTTP.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(libHTTP)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(resp.Body)
require.Equal(t, http.StatusCreated, resp.StatusCode, "Library creation should succeed")
var libResponse map[string]interface{}
err = json.NewDecoder(resp.Body).Decode(&libResponse)
require.NoError(t, err)
libraryID := libResponse["id"].(string)
// Every library a test creates must not outlive that test — delete
// it on cleanup so runs leave no residue in the dev database.
t.Cleanup(func() {
delHTTP, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/libraries/%s", ts.URL, libraryID), nil)
delHTTP.Header.Set("Authorization", "Bearer "+token)
delResp, err := client.Do(delHTTP)
if err == nil {
_, _ = io.Copy(io.Discard, delResp.Body)
_ = delResp.Body.Close()
}
})
if withFolder {
folderReq := map[string]interface{}{
"folder_path": "/app/uploads",
}
folderBody, _ := json.Marshal(folderReq)
folderHTTP, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/libraries/%s/folders", ts.URL, libraryID), bytes.NewBuffer(folderBody))
folderHTTP.Header.Set("Content-Type", "application/json")
folderHTTP.Header.Set("Authorization", "Bearer "+token)
folderResp, err := client.Do(folderHTTP)
require.NoError(t, err)
defer func(Body io.ReadCloser) {
_ = Body.Close()
}(folderResp.Body)
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder creation should succeed")
}
return libraryID
}
// runConcurrent executes functions concurrently and waits for all to complete
func runConcurrent(t *testing.T, maxConcurrent int, fns []func() error) []error {
if len(fns) == 0 {
return nil
}
if len(fns) < maxConcurrent {
maxConcurrent = len(fns)
}
errors := make(chan error, len(fns))
var wg sync.WaitGroup
for i := 0; i < maxConcurrent; 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
}