From de4bc6fa70d6bda5d3ce1fc42f6cd784d9c774d4 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 4 Oct 2025 08:22:49 -0500 Subject: [PATCH] feat(wip): error handling test utilities --- internal/handlers/test_utilities.go | 328 +++++++++++++++-- internal/repo/task_repository_test.go | 247 +++++++++++++ internal/repo/test_utilities.go | 241 ++++++++++++ internal/store/database_test.go | 203 +++++++++++ internal/store/test_utilities.go | 506 ++++++++++++++++++++++++++ 5 files changed, 1504 insertions(+), 21 deletions(-) create mode 100644 internal/store/test_utilities.go diff --git a/internal/handlers/test_utilities.go b/internal/handlers/test_utilities.go index b533532..2f86ea1 100644 --- a/internal/handlers/test_utilities.go +++ b/internal/handlers/test_utilities.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -460,6 +461,306 @@ func (ath *ArticleTestHelper) AddTestRule(domain string, rule *articles.ParsingR var Expect = AssertionHelpers{} +// Error Testing Helpers for Handlers +// +// These utilities provide systematic error testing for handler-specific scenarios +// including constructor failures, repository errors, and file system operations. + +// HandlerErrorTester provides utilities for testing handler error scenarios +type HandlerErrorTester struct { + t *testing.T +} + +// NewHandlerErrorTester creates a handler error tester +func NewHandlerErrorTester(t *testing.T) *HandlerErrorTester { + t.Helper() + return &HandlerErrorTester{t: t} +} + +// TestConstructorDatabaseFailure tests handler constructor with database initialization failure +// +// Use this to verify handlers handle database connection errors gracefully. +// Example: +// +// tester := NewHandlerErrorTester(t) +// tester.TestConstructorDatabaseFailure(func() error { +// // Close global database to simulate failure +// _, err := NewTaskHandler() +// return err +// }) +func (het *HandlerErrorTester) TestConstructorDatabaseFailure(constructor func() error) { + het.t.Helper() + + err := constructor() + if err == nil { + het.t.Error("Handler constructor should fail when database initialization fails") + } +} + +// TestConstructorConfigFailure tests handler constructor with config loading failure +// +// Use this to verify handlers handle config errors properly. +func (het *HandlerErrorTester) TestConstructorConfigFailure(constructor func() error) { + het.t.Helper() + + err := constructor() + if err == nil { + het.t.Error("Handler constructor should fail when config loading fails") + } +} + +// TestRepositoryMethodError tests handler methods when repository operations fail +// +// Use this to verify handlers propagate repository errors correctly. +// Example: +// +// tester := NewHandlerErrorTester(t) +// tester.TestRepositoryMethodError("Create", func() error { +// // Corrupt database to cause repository error +// dbHelper.CorruptTable("tasks") +// return handler.Create(ctx, "description", ...) +// }) +func (het *HandlerErrorTester) TestRepositoryMethodError(methodName string, operation func() error) { + het.t.Helper() + + err := operation() + if err == nil { + het.t.Errorf("Handler %s should propagate repository errors", methodName) + } +} + +// FileSystemErrorHelper provides utilities for testing file system errors in handlers +type FileSystemErrorHelper struct { + t *testing.T + tempDirs []string +} + +// NewFileSystemErrorHelper creates a file system error helper +func NewFileSystemErrorHelper(t *testing.T) *FileSystemErrorHelper { + t.Helper() + return &FileSystemErrorHelper{ + t: t, + tempDirs: make([]string, 0), + } +} + +// CreateReadOnlyDirectory creates a read-only directory for testing permission errors +// +// Use this to test handler behavior when unable to write files. +// Example: +// +// helper := NewFileSystemErrorHelper(t) +// defer helper.Cleanup() +// readOnlyDir := helper.CreateReadOnlyDirectory() +// // Test operations that should fail with permission errors +func (fseh *FileSystemErrorHelper) CreateReadOnlyDirectory() string { + fseh.t.Helper() + + if runtime.GOOS == "windows" { + fseh.t.Skip("Permission test not reliable on Windows") + } + + tempDir, err := os.MkdirTemp("", "noteleaf-readonly-*") + if err != nil { + fseh.t.Fatalf("Failed to create temp directory: %v", err) + } + + fseh.tempDirs = append(fseh.tempDirs, tempDir) + + if err := os.Chmod(tempDir, 0555); err != nil { + fseh.t.Fatalf("Failed to make directory read-only: %v", err) + } + + return tempDir +} + +// CreateUnwritableFile creates a file that cannot be written to +// +// Use this to test error handling when files cannot be modified. +func (fseh *FileSystemErrorHelper) CreateUnwritableFile(content string) string { + fseh.t.Helper() + + if runtime.GOOS == "windows" { + fseh.t.Skip("Permission test not reliable on Windows") + } + + tempDir, err := os.MkdirTemp("", "noteleaf-unwritable-*") + if err != nil { + fseh.t.Fatalf("Failed to create temp directory: %v", err) + } + + fseh.tempDirs = append(fseh.tempDirs, tempDir) + + filePath := filepath.Join(tempDir, "test-file.txt") + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + fseh.t.Fatalf("Failed to create file: %v", err) + } + + if err := os.Chmod(filePath, 0444); err != nil { + fseh.t.Fatalf("Failed to make file read-only: %v", err) + } + + return filePath +} + +// SimulateDiskFull simulates disk full errors using /dev/full on Unix systems +// +// Use this to test error handling when disk space is exhausted. +// Note: This only works on Unix systems with /dev/full +func (fseh *FileSystemErrorHelper) SimulateDiskFull() string { + fseh.t.Helper() + + if runtime.GOOS == "windows" { + fseh.t.Skip("Disk full simulation not available on Windows") + } + + if _, err := os.Stat("/dev/full"); os.IsNotExist(err) { + fseh.t.Skip("/dev/full not available on this system") + } + + return "/dev/full" +} + +// Cleanup removes temporary directories and restores permissions +func (fseh *FileSystemErrorHelper) Cleanup() { + for _, dir := range fseh.tempDirs { + os.Chmod(dir, 0755) + os.RemoveAll(dir) + } +} + +// RepositoryErrorSimulator simulates repository errors for handler testing +type RepositoryErrorSimulator struct { + t *testing.T + dbHelper *DatabaseTestHelper +} + +// NewRepositoryErrorSimulator creates a repository error simulator +func NewRepositoryErrorSimulator(t *testing.T, dbHelper *DatabaseTestHelper) *RepositoryErrorSimulator { + t.Helper() + return &RepositoryErrorSimulator{ + t: t, + dbHelper: dbHelper, + } +} + +// SimulateCreateError simulates repository Create method failure +// +// Use this to test handler behavior when repository create operations fail. +func (res *RepositoryErrorSimulator) SimulateCreateError(tableName string) { + res.t.Helper() + res.dbHelper.DropNotesTable() +} + +// SimulateGetError simulates repository Get method failure +// +// Use this to test handler behavior when repository get operations fail. +func (res *RepositoryErrorSimulator) SimulateGetError() { + res.t.Helper() + res.dbHelper.CloseDatabase() +} + +// SimulateUpdateError simulates repository Update method failure +// +// Use this to test handler behavior when repository update operations fail. +func (res *RepositoryErrorSimulator) SimulateUpdateError() { + res.t.Helper() + res.dbHelper.CloseDatabase() +} + +// SimulateDeleteError simulates repository Delete method failure +// +// Use this to test handler behavior when repository delete operations fail. +func (res *RepositoryErrorSimulator) SimulateDeleteError() { + res.t.Helper() + res.dbHelper.CloseDatabase() +} + +// ValidationErrorHelper provides utilities for testing validation errors +type ValidationErrorHelper struct { + t *testing.T +} + +// NewValidationErrorHelper creates a validation error helper +func NewValidationErrorHelper(t *testing.T) *ValidationErrorHelper { + t.Helper() + return &ValidationErrorHelper{t: t} +} + +// AssertValidationError verifies that an operation fails with validation error +// +// Use this to test input validation in handlers. +// Example: +// +// helper := NewValidationErrorHelper(t) +// helper.AssertValidationError(func() error { +// return handler.Create(ctx, "", ...) // Empty description +// }, "description") +func (veh *ValidationErrorHelper) AssertValidationError(operation func() error, expectedField string) { + veh.t.Helper() + + err := operation() + if err == nil { + veh.t.Errorf("Expected validation error for field %s", expectedField) + return + } + + if expectedField != "" && !containsString(err.Error(), expectedField) { + veh.t.Logf("Validation error does not mention field %s: %v", expectedField, err) + } +} + +// HandlerErrorScenario defines a common error testing scenario for handlers +type HandlerErrorScenario struct { + Name string + Setup func(*testing.T) func() + Operation func(*testing.T) error + ExpectError bool + ErrorCheck func(*testing.T, error) +} + +// RunHandlerErrorScenarios executes a set of error testing scenarios for handlers +// +// Use this to systematically test all error paths in a handler. +// Example: +// +// scenarios := []HandlerErrorScenario{ +// { +// Name: "database connection failure", +// Setup: func(t *testing.T) func() { +// dbHelper.CloseDatabase() +// return func() { dbHelper.RestoreDatabase(t) } +// }, +// Operation: func(t *testing.T) error { +// return handler.Create(ctx, "task") +// }, +// ExpectError: true, +// }, +// } +// RunHandlerErrorScenarios(t, scenarios) +func RunHandlerErrorScenarios(t *testing.T, scenarios []HandlerErrorScenario) { + t.Helper() + + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + cleanup := scenario.Setup(t) + defer cleanup() + + err := scenario.Operation(t) + + if scenario.ExpectError && err == nil { + t.Errorf("Expected error in scenario %s but got none", scenario.Name) + } else if !scenario.ExpectError && err != nil { + t.Errorf("Unexpected error in scenario %s: %v", scenario.Name, err) + } + + if scenario.ErrorCheck != nil && err != nil { + scenario.ErrorCheck(t, err) + } + }) + } +} + // HTTPMockServer provides utilities for mocking HTTP services in tests type HTTPMockServer struct { server *httptest.Server @@ -728,7 +1029,8 @@ func CreateMockTVSearchResults() []services.Media { } // InputSimulator provides controlled input simulation for testing [fmt.Scanf] interactions -// It implements [io.Reader] to provide predictable input sequences for interactive components +// +// It implements [io.Reader] to provide predictable input sequences for interactive components type InputSimulator struct { inputs []string position int @@ -1169,25 +1471,9 @@ func RunTUIScenarios(t *testing.T, suite *ui.TUITestSuite, scenarios []TUITestSc // CommonTUIScenarios returns standard TUI testing scenarios func CommonTUIScenarios() []TUITestScenario { return []TUITestScenario{ - { - Name: "help_toggle", - KeySequence: []tea.KeyType{tea.KeyRunes}, - Timeout: 500 * time.Millisecond, - }, - { - Name: "navigation_down", - KeySequence: []tea.KeyType{tea.KeyDown, tea.KeyDown, tea.KeyUp}, - Timeout: 500 * time.Millisecond, - }, - { - Name: "page_navigation", - KeySequence: []tea.KeyType{tea.KeyPgDown, tea.KeyPgUp}, - Timeout: 500 * time.Millisecond, - }, - { - Name: "quit_sequence", - KeySequence: []tea.KeyType{tea.KeyCtrlC}, - Timeout: 500 * time.Millisecond, - }, + {Name: "help_toggle", KeySequence: []tea.KeyType{tea.KeyRunes}, Timeout: 500 * time.Millisecond}, + {Name: "navigation_down", KeySequence: []tea.KeyType{tea.KeyDown, tea.KeyDown, tea.KeyUp}, Timeout: 500 * time.Millisecond}, + {Name: "page_navigation", KeySequence: []tea.KeyType{tea.KeyPgDown, tea.KeyPgUp}, Timeout: 500 * time.Millisecond}, + {Name: "quit_sequence", KeySequence: []tea.KeyType{tea.KeyCtrlC}, Timeout: 500 * time.Millisecond}, } } diff --git a/internal/repo/task_repository_test.go b/internal/repo/task_repository_test.go index 72237bf..3c857be 100644 --- a/internal/repo/task_repository_test.go +++ b/internal/repo/task_repository_test.go @@ -2,7 +2,9 @@ package repo import ( "context" + "fmt" "slices" + "strings" "testing" "time" @@ -1051,3 +1053,248 @@ func TestTaskRepository_GetByContext(t *testing.T) { t.Errorf("Expected context 'home', got '%s'", homeTasks[0].Context) } } + +func TestTaskRepositoryConstraintViolations(t *testing.T) { + db := CreateTestDB(t) + repo := NewTaskRepository(db) + ctx := context.Background() + + t.Run("UniqueConstraintViolation", func(t *testing.T) { + tester := NewRepositoryErrorTester(t, db) + uniqueUUID := newUUID() + + tester.TestUniqueConstraintViolation("task", func() error { + task := CreateSampleTask() + task.UUID = uniqueUUID + _, err := repo.Create(ctx, task) + return err + }) + }) + + t.Run("ForeignKeyViolation", func(t *testing.T) { + tester := NewRepositoryErrorTester(t, db) + + tester.TestForeignKeyViolation("task dependency", func() error { + task := CreateSampleTask() + task.DependsOn = []string{"non-existent-task-uuid"} + _, err := repo.Create(ctx, task) + return err + }) + }) + + t.Run("ContextCancellation", func(t *testing.T) { + tester := NewRepositoryErrorTester(t, db) + + tester.TestContextCancellation("Create", func(ctx context.Context) error { + task := CreateSampleTask() + _, err := repo.Create(ctx, task) + return err + }) + + tester.TestContextCancellation("Get", func(ctx context.Context) error { + _, err := repo.Get(ctx, 1) + return err + }) + + tester.TestContextCancellation("Update", func(ctx context.Context) error { + task := CreateSampleTask() + task.ID = 1 + return repo.Update(ctx, task) + }) + + tester.TestContextCancellation("Delete", func(ctx context.Context) error { + return repo.Delete(ctx, 1) + }) + }) + + t.Run("NonExistentEntityOperations", func(t *testing.T) { + tester := NewRepositoryErrorTester(t, db) + + tester.TestGetNonExistent("task", func() error { + _, err := repo.Get(ctx, 999999) + return err + }) + + // Note: Update currently doesn't error on non-existent tasks + // This is a gap in error handling that could be improved + t.Run("UpdateNonExistent", func(t *testing.T) { + task := CreateSampleTask() + task.ID = 999999 + err := repo.Update(ctx, task) + // FIXME: Current implementation doesn't return error for non-existent ID + // This should be fixed to return sql.ErrNoRows or similar + t.Logf("Update non-existent task returned: %v", err) + }) + + // Note: Delete also doesn't error on non-existent tasks + t.Run("DeleteNonExistent", func(t *testing.T) { + err := repo.Delete(ctx, 999999) + // FIXME: Current implementation doesn't return error for non-existent ID + // This should be fixed to check RowsAffected and return error if 0 + t.Logf("Delete non-existent task returned: %v", err) + }) + }) +} + +// TestTaskRepositoryMarshalingErrors tests marshaling/unmarshaling error handling +func TestTaskRepositoryMarshalingErrors(t *testing.T) { + db := CreateTestDB(t) + repo := NewTaskRepository(db) + ctx := context.Background() + + t.Run("InvalidTagsMarshaling", func(t *testing.T) { + helper := NewMarshalingErrorHelper(t) + + task := CreateSampleTask() + id, err := repo.Create(ctx, task) + AssertNoError(t, err, "Failed to create task") + + helper.TestInvalidJSONMarshaling(func() error { + task.Tags = []string{helper.CreateInvalidUTF8String()} + _, err := task.MarshalTags() + return err + }) + + task2 := CreateSampleTask() + task2.Tags = []string{"valid", "tags"} + _, err = repo.Create(ctx, task2) + AssertNoError(t, err, "Should be able to create task after marshaling error") + + retrieved, err := repo.Get(ctx, id) + AssertNoError(t, err, "Should be able to retrieve task") + if retrieved == nil { + t.Error("Retrieved task should not be nil") + } + }) + + t.Run("InvalidAnnotationsMarshaling", func(t *testing.T) { + helper := NewMarshalingErrorHelper(t) + + task := CreateSampleTask() + id, err := repo.Create(ctx, task) + AssertNoError(t, err, "Failed to create task") + + helper.TestInvalidJSONMarshaling(func() error { + task.Annotations = []string{helper.CreateInvalidUTF8String()} + _, err := task.MarshalAnnotations() + return err + }) + + retrieved, err := repo.Get(ctx, id) + AssertNoError(t, err, "Should be able to retrieve task after error") + if retrieved == nil { + t.Error("Retrieved task should not be nil") + } + }) +} + +func TestTaskRepositoryEdgeCases(t *testing.T) { + db := CreateTestDB(t) + repo := NewTaskRepository(db) + ctx := context.Background() + + t.Run("EmptyDependencies", func(t *testing.T) { + task := CreateSampleTask() + task.DependsOn = []string{} + + id, err := repo.Create(ctx, task) + AssertNoError(t, err, "Should create task with empty dependencies") + + deps, err := repo.GetDependencies(ctx, task.UUID) + AssertNoError(t, err, "Should get empty dependencies") + if len(deps) != 0 { + t.Errorf("Expected 0 dependencies, got %d", len(deps)) + } + + retrieved, err := repo.Get(ctx, id) + AssertNoError(t, err, "Should retrieve task") + if len(retrieved.DependsOn) != 0 { + t.Errorf("Expected 0 dependencies on retrieved task, got %d", len(retrieved.DependsOn)) + } + }) + + t.Run("NilDueDate", func(t *testing.T) { + task := CreateSampleTask() + task.Due = nil + + id, err := repo.Create(ctx, task) + AssertNoError(t, err, "Should create task with nil due date") + + retrieved, err := repo.Get(ctx, id) + AssertNoError(t, err, "Should retrieve task") + if retrieved.Due != nil { + t.Error("Retrieved task should have nil due date") + } + }) + + t.Run("EmptyTags", func(t *testing.T) { + task := CreateSampleTask() + task.Tags = []string{} + + id, err := repo.Create(ctx, task) + AssertNoError(t, err, "Should create task with empty tags") + + retrieved, err := repo.Get(ctx, id) + AssertNoError(t, err, "Should retrieve task") + if len(retrieved.Tags) != 0 { + t.Errorf("Expected 0 tags on retrieved task, got %d", len(retrieved.Tags)) + } + }) + + t.Run("VeryLongDescription", func(t *testing.T) { + task := CreateSampleTask() + // Create a very long description (10KB) + longDesc := strings.Repeat("A very long task description. ", 340) + task.Description = longDesc + + id, err := repo.Create(ctx, task) + AssertNoError(t, err, "Should create task with very long description") + + retrieved, err := repo.Get(ctx, id) + AssertNoError(t, err, "Should retrieve task") + if retrieved.Description != longDesc { + t.Error("Long description should be preserved exactly") + } + }) + + t.Run("ManyTags", func(t *testing.T) { + task := CreateSampleTask() + tags := make([]string, 100) + for i := range tags { + tags[i] = fmt.Sprintf("tag-%d", i) + } + task.Tags = tags + + id, err := repo.Create(ctx, task) + AssertNoError(t, err, "Should create task with many tags") + + retrieved, err := repo.Get(ctx, id) + AssertNoError(t, err, "Should retrieve task") + if len(retrieved.Tags) != 100 { + t.Errorf("Expected 100 tags, got %d", len(retrieved.Tags)) + } + }) + + t.Run("CircularDependencyPrevention", func(t *testing.T) { + task1 := CreateSampleTask() + task2 := CreateSampleTask() + + id1, err := repo.Create(ctx, task1) + AssertNoError(t, err, "Failed to create task1") + + id2, err := repo.Create(ctx, task2) + AssertNoError(t, err, "Failed to create task2") + + err = repo.AddDependency(ctx, task2.UUID, task1.UUID) + AssertNoError(t, err, "Should add dependency task2 -> task1") + + err = repo.AddDependency(ctx, task1.UUID, task2.UUID) + t.Logf("Circular dependency result: %v", err) + + _, err = repo.Get(ctx, id1) + AssertNoError(t, err, "Should still be able to retrieve task1") + + _, err = repo.Get(ctx, id2) + AssertNoError(t, err, "Should still be able to retrieve task2") + }) +} diff --git a/internal/repo/test_utilities.go b/internal/repo/test_utilities.go index 8e8ea55..1d24e4d 100644 --- a/internal/repo/test_utilities.go +++ b/internal/repo/test_utilities.go @@ -319,6 +319,247 @@ func AssertContains(t *testing.T, str, substr, msg string) { } } +// Error Testing Helpers for Repositories +// +// These utilities extend the core error testing framework for repository-specific +// error scenarios including constraint violations, marshaling errors, and +// context-based error testing. + +// RepositoryErrorTester provides systematic error testing for repository operations +type RepositoryErrorTester struct { + t *testing.T + db *sql.DB + ctx context.Context +} + +// NewRepositoryErrorTester creates a repository error tester +func NewRepositoryErrorTester(t *testing.T, db *sql.DB) *RepositoryErrorTester { + t.Helper() + return &RepositoryErrorTester{ + t: t, + db: db, + ctx: context.Background(), + } +} + +// TestUniqueConstraintViolation tests unique constraint violations systematically +// +// Use this to test duplicate insertions across any repository. +// Example: +// +// tester := NewRepositoryErrorTester(t, db) +// tester.TestUniqueConstraintViolation("tasks", func() error { +// task.UUID = "duplicate-uuid" +// _, err := repo.Create(ctx, task) +// return err +// }) +func (ret *RepositoryErrorTester) TestUniqueConstraintViolation(entityName string, duplicateInsert func() error) { + ret.t.Helper() + + // First insert should succeed + err := duplicateInsert() + AssertNoError(ret.t, err, fmt.Sprintf("First %s insert should succeed", entityName)) + + // Second insert with same unique value should fail + err = duplicateInsert() + AssertError(ret.t, err, fmt.Sprintf("Duplicate %s should violate unique constraint", entityName)) +} + +// TestForeignKeyViolation tests foreign key constraint violations +// +// Use this to test operations that reference non-existent foreign entities. +// Example: +// +// tester := NewRepositoryErrorTester(t, db) +// tester.TestForeignKeyViolation("time entry", func() error { +// entry.TaskID = 999999 // Non-existent task +// return repo.CreateTimeEntry(ctx, entry) +// }) +func (ret *RepositoryErrorTester) TestForeignKeyViolation(entityName string, invalidFKInsert func() error) { + ret.t.Helper() + + err := invalidFKInsert() + AssertError(ret.t, err, fmt.Sprintf("%s with invalid foreign key should fail", entityName)) +} + +// TestNotNullViolation tests NOT NULL constraint violations +// +// Use this to verify required fields are enforced. +func (ret *RepositoryErrorTester) TestNotNullViolation(entityName string, nullInsert func() error) { + ret.t.Helper() + + err := nullInsert() + AssertError(ret.t, err, fmt.Sprintf("%s with NULL required field should fail", entityName)) +} + +// TestContextCancellation tests operation behavior with cancelled context +// +// Use this to verify all repository operations handle context cancellation. +// Example: +// +// tester := NewRepositoryErrorTester(t, db) +// tester.TestContextCancellation("Create", func(ctx context.Context) error { +// _, err := repo.Create(ctx, task) +// return err +// }) +func (ret *RepositoryErrorTester) TestContextCancellation(operationName string, operation func(context.Context) error) { + ret.t.Helper() + + ctx, cancel := context.WithCancel(ret.ctx) + cancel() + + err := operation(ctx) + AssertError(ret.t, err, fmt.Sprintf("%s with cancelled context should fail", operationName)) +} + +// TestGetNonExistent tests retrieval of non-existent entities +// +// Use this to verify proper error handling when entities don't exist. +// Pattern: +// +// tester := NewRepositoryErrorTester(t, db) +// tester.TestGetNonExistent("task", func() error { +// _, err := repo.Get(ctx, 999999) +// return err +// }) +func (ret *RepositoryErrorTester) TestGetNonExistent(entityName string, getNonExistent func() error) { + ret.t.Helper() + + err := getNonExistent() + AssertError(ret.t, err, fmt.Sprintf("Getting non-existent %s should fail", entityName)) +} + +// TestUpdateNonExistent tests update of non-existent entities +// +// Use this to verify updates fail gracefully for missing entities. +func (ret *RepositoryErrorTester) TestUpdateNonExistent(entityName string, updateNonExistent func() error) { + ret.t.Helper() + + err := updateNonExistent() + AssertError(ret.t, err, fmt.Sprintf("Updating non-existent %s should fail", entityName)) +} + +// TestDeleteNonExistent tests deletion of non-existent entities +// +// Use this to verify deletes handle missing entities properly. +func (ret *RepositoryErrorTester) TestDeleteNonExistent(entityName string, deleteNonExistent func() error) { + ret.t.Helper() + + err := deleteNonExistent() + AssertError(ret.t, err, fmt.Sprintf("Deleting non-existent %s should fail", entityName)) +} + +// MarshalingErrorHelper provides utilities for testing marshaling/unmarshaling errors +type MarshalingErrorHelper struct { + t *testing.T +} + +// NewMarshalingErrorHelper creates a marshaling error helper +func NewMarshalingErrorHelper(t *testing.T) *MarshalingErrorHelper { + t.Helper() + return &MarshalingErrorHelper{t: t} +} + +// TestInvalidJSONMarshaling tests marshaling of invalid JSON data +// +// Use this to verify error handling when JSON marshaling fails. +// Example: +// +// helper := NewMarshalingErrorHelper(t) +// helper.TestInvalidJSONMarshaling(func() error { +// task.Tags = []string{string([]byte{0xff, 0xfe, 0xfd})} // Invalid UTF-8 +// _, err := task.MarshalTags() +// return err +// }) +func (meh *MarshalingErrorHelper) TestInvalidJSONMarshaling(operation func() error) { + meh.t.Helper() + + err := operation() + if err != nil { + // Expected - verify it's a marshaling error + AssertErrorContains(meh.t, err, "", "Expected marshaling to handle invalid data") + } +} + +// TestInvalidJSONUnmarshaling tests unmarshaling of corrupted JSON +// +// Use this to verify error handling when unmarshaling invalid JSON. +// Example: +// +// helper := NewMarshalingErrorHelper(t) +// helper.TestInvalidJSONUnmarshaling(func() error { +// invalidJSON := `{"broken": json` +// return task.UnmarshalTags(invalidJSON) +// }) +func (meh *MarshalingErrorHelper) TestInvalidJSONUnmarshaling(operation func() error) { + meh.t.Helper() + + err := operation() + AssertError(meh.t, err, "Unmarshaling invalid JSON should fail") +} + +// CreateInvalidJSONString returns a malformed JSON string for testing +func (meh *MarshalingErrorHelper) CreateInvalidJSONString() string { + return `{"invalid": json without closing` +} + +// CreateInvalidUTF8String returns a string with invalid UTF-8 for testing +func (meh *MarshalingErrorHelper) CreateInvalidUTF8String() string { + return string([]byte{0xff, 0xfe, 0xfd}) +} + +// RepositoryTestScenario defines a common test scenario for repositories +type RepositoryTestScenario struct { + Name string + SetupFunc func(*testing.T, *sql.DB) (context.Context, func()) + TestFunc func(*testing.T, context.Context, *sql.DB) +} + +// RunRepositoryErrorScenarios executes a set of error testing scenarios +// +// Use this to systematically test all error paths in a repository. +// Example: +// +// scenarios := []RepositoryTestScenario{ +// { +// Name: "context cancellation", +// SetupFunc: func(t *testing.T, db *sql.DB) (context.Context, func()) { +// ctx, cancel := context.WithCancel(context.Background()) +// cancel() +// return ctx, func() {} +// }, +// TestFunc: func(t *testing.T, ctx context.Context, db *sql.DB) { +// repo := NewTaskRepository(db) +// _, err := repo.Create(ctx, task) +// AssertError(t, err, "Should fail with cancelled context") +// }, +// }, +// } +// RunRepositoryErrorScenarios(t, db, scenarios) +func RunRepositoryErrorScenarios(t *testing.T, db *sql.DB, scenarios []RepositoryTestScenario) { + t.Helper() + + for _, scenario := range scenarios { + t.Run(scenario.Name, func(t *testing.T) { + ctx, cleanup := scenario.SetupFunc(t, db) + defer cleanup() + scenario.TestFunc(t, ctx, db) + }) + } +} + +// AssertErrorContains verifies error contains expected substring +func AssertErrorContains(t *testing.T, err error, expectedSubstring, msg string) { + t.Helper() + if err == nil { + t.Errorf("%s: expected error containing %q but got none", msg, expectedSubstring) + return + } + if expectedSubstring != "" && !strings.Contains(err.Error(), expectedSubstring) { + t.Errorf("%s: expected error containing %q, got: %v", msg, expectedSubstring, err) + } +} + // SetupTestData creates sample data in the database and returns the repositories func SetupTestData(t *testing.T, db *sql.DB) *Repositories { ctx := context.Background() diff --git a/internal/store/database_test.go b/internal/store/database_test.go index 1e17d3c..a24c6b4 100644 --- a/internal/store/database_test.go +++ b/internal/store/database_test.go @@ -286,3 +286,206 @@ func TestDatabaseIntegration(t *testing.T) { } }) } + +// TestNewDatabaseWithConfig tests database creation with custom config +func TestNewDatabaseWithConfig(t *testing.T) { + tempDir, err := os.MkdirTemp("", "noteleaf-db-config-test-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + t.Run("creates database with custom database path", func(t *testing.T) { + customDBPath := filepath.Join(tempDir, "custom.db") + config := &Config{ + DatabasePath: customDBPath, + } + + db, err := NewDatabaseWithConfig(config) + if err != nil { + t.Fatalf("NewDatabaseWithConfig failed: %v", err) + } + defer db.Close() + + if db.GetPath() != customDBPath { + t.Errorf("Expected database path %s, got %s", customDBPath, db.GetPath()) + } + + if _, err := os.Stat(customDBPath); os.IsNotExist(err) { + t.Error("Custom database file should exist") + } + }) + + t.Run("creates database with custom data dir", func(t *testing.T) { + customDataDir := filepath.Join(tempDir, "data") + + // Create the custom data directory + if err := os.MkdirAll(customDataDir, 0755); err != nil { + t.Fatalf("Failed to create custom data dir: %v", err) + } + + config := &Config{ + DataDir: customDataDir, + } + + db, err := NewDatabaseWithConfig(config) + if err != nil { + t.Fatalf("NewDatabaseWithConfig failed: %v", err) + } + defer db.Close() + + expectedPath := filepath.Join(customDataDir, "noteleaf.db") + if db.GetPath() != expectedPath { + t.Errorf("Expected database path %s, got %s", expectedPath, db.GetPath()) + } + }) + + t.Run("handles invalid custom database path", func(t *testing.T) { + config := &Config{ + DatabasePath: "/invalid/path/that/does/not/exist/database.db", + } + + _, err := NewDatabaseWithConfig(config) + if err == nil { + t.Error("NewDatabaseWithConfig should fail with invalid database path") + } + }) + + t.Run("creates nested directories for custom database path", func(t *testing.T) { + nestedPath := filepath.Join(tempDir, "nested", "deep", "database.db") + config := &Config{ + DatabasePath: nestedPath, + } + + db, err := NewDatabaseWithConfig(config) + if err != nil { + t.Fatalf("NewDatabaseWithConfig should create nested directories: %v", err) + } + defer db.Close() + + if _, err := os.Stat(nestedPath); os.IsNotExist(err) { + t.Error("Database file should exist in nested directory") + } + }) +} + +// TestGetDataDirPlatformSpecific tests platform-specific GetDataDir behavior +func TestGetDataDirPlatformSpecific(t *testing.T) { + t.Run("handles missing LOCALAPPDATA on Windows", func(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-specific test") + } + + originalEnv := os.Getenv("LOCALAPPDATA") + os.Unsetenv("LOCALAPPDATA") + defer os.Setenv("LOCALAPPDATA", originalEnv) + + _, err := GetDataDir() + if err == nil { + t.Error("GetDataDir should fail when LOCALAPPDATA is not set on Windows") + } + }) + + t.Run("handles missing HOME on Unix", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-specific test") + } + + originalXDG := os.Getenv("XDG_DATA_HOME") + originalHome := os.Getenv("HOME") + os.Unsetenv("XDG_DATA_HOME") + os.Unsetenv("HOME") + + defer func() { + os.Setenv("XDG_DATA_HOME", originalXDG) + os.Setenv("HOME", originalHome) + }() + + _, err := GetDataDir() + if err == nil { + t.Error("GetDataDir should fail when both XDG_DATA_HOME and HOME are not set on Unix") + } + }) + + t.Run("uses XDG_DATA_HOME when set on Linux", func(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux-specific test") + } + + tempDir, err := os.MkdirTemp("", "noteleaf-xdg-test-*") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(tempDir) + + originalXDG := os.Getenv("XDG_DATA_HOME") + os.Setenv("XDG_DATA_HOME", tempDir) + defer os.Setenv("XDG_DATA_HOME", originalXDG) + + dataDir, err := GetDataDir() + if err != nil { + t.Fatalf("GetDataDir failed: %v", err) + } + + expectedPath := filepath.Join(tempDir, "noteleaf") + if dataDir != expectedPath { + t.Errorf("Expected data dir %s, got %s", expectedPath, dataDir) + } + }) + + t.Run("falls back to HOME/.local/share on Linux when XDG_DATA_HOME not set", func(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux-specific test") + } + + tempHome, err := os.MkdirTemp("", "noteleaf-home-test-*") + if err != nil { + t.Fatalf("Failed to create temp home: %v", err) + } + defer os.RemoveAll(tempHome) + + originalXDG := os.Getenv("XDG_DATA_HOME") + originalHome := os.Getenv("HOME") + os.Unsetenv("XDG_DATA_HOME") + os.Setenv("HOME", tempHome) + + defer func() { + os.Setenv("XDG_DATA_HOME", originalXDG) + os.Setenv("HOME", originalHome) + }() + + dataDir, err := GetDataDir() + if err != nil { + t.Fatalf("GetDataDir failed: %v", err) + } + + expectedPath := filepath.Join(tempHome, ".local", "share", "noteleaf") + if dataDir != expectedPath { + t.Errorf("Expected data dir %s, got %s", expectedPath, dataDir) + } + }) +} + +// TestDatabaseConstraintViolations tests database constraint handling +func TestDatabaseConstraintViolations(t *testing.T) { + env := NewIsolatedEnvironment(t) + defer env.Cleanup() + + t.Run("handles foreign key constraint violations", func(t *testing.T) { + db, err := NewDatabase() + if err != nil { + t.Fatalf("NewDatabase failed: %v", err) + } + defer db.Close() + + var foreignKeys int + err = db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys) + if err != nil { + t.Fatalf("Failed to check foreign keys: %v", err) + } + + if foreignKeys != 1 { + t.Error("Foreign keys should be enabled by default") + } + }) +} diff --git a/internal/store/test_utilities.go b/internal/store/test_utilities.go new file mode 100644 index 0000000..e59d627 --- /dev/null +++ b/internal/store/test_utilities.go @@ -0,0 +1,506 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "testing" + "time" +) + +// Error Testing Utilities +// +// This file provides reusable error testing helpers for systematically testing +// error paths in the store package. The utilities are organized into categories: +// +// 1. ErrorInjector - File system and permission errors +// 2. DBErrorSimulator - Database-specific errors +// 3. ContextHelper - Context cancellation and timeout scenarios +// 4. Platform-specific testing utilities +// +// Usage patterns are documented for each helper with examples from existing tests. + +// ErrorInjector provides utilities for simulating file system and permission errors +type ErrorInjector struct { + t *testing.T + tempDirs []string + originalPerms map[string]os.FileMode + cleanupFuncs []func() + envVarsModified map[string]string +} + +// NewErrorInjector creates a new error injector for testing +func NewErrorInjector(t *testing.T) *ErrorInjector { + t.Helper() + return &ErrorInjector{ + t: t, + tempDirs: make([]string, 0), + originalPerms: make(map[string]os.FileMode), + cleanupFuncs: make([]func(), 0), + envVarsModified: make(map[string]string), + } +} + +// CreateUnwritableDir creates a directory with read-only permissions +// +// Use this to test errors when the application cannot write to config or data directories. +// Example from config_test.go: +// +// injector := NewErrorInjector(t) +// dir := injector.CreateUnwritableDir() +// defer injector.Cleanup() +// // Test code that should fail when dir is read-only +func (ei *ErrorInjector) CreateUnwritableDir() string { + ei.t.Helper() + + if runtime.GOOS == "windows" { + ei.t.Skip("Permission test not reliable on Windows") + } + + tempDir, err := os.MkdirTemp("", "noteleaf-unwritable-*") + if err != nil { + ei.t.Fatalf("Failed to create temp directory: %v", err) + } + + ei.tempDirs = append(ei.tempDirs, tempDir) + + if err := os.Chmod(tempDir, 0555); err != nil { + ei.t.Fatalf("Failed to make directory read-only: %v", err) + } + + ei.originalPerms[tempDir] = 0755 + return tempDir +} + +// CreateUnreadableFile creates a file with no read permissions +// +// Use this to test errors when the application cannot read config files. +// Example pattern: +// +// injector := NewErrorInjector(t) +// filePath := injector.CreateUnreadableFile("config.toml", "content") +// defer injector.Cleanup() +// // Test code that should fail when file is unreadable +func (ei *ErrorInjector) CreateUnreadableFile(filename, content string) string { + ei.t.Helper() + + if runtime.GOOS == "windows" { + ei.t.Skip("Permission test not reliable on Windows") + } + + tempDir, err := os.MkdirTemp("", "noteleaf-unreadable-*") + if err != nil { + ei.t.Fatalf("Failed to create temp directory: %v", err) + } + + ei.tempDirs = append(ei.tempDirs, tempDir) + + filePath := filepath.Join(tempDir, filename) + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { + ei.t.Fatalf("Failed to create file: %v", err) + } + + if err := os.Chmod(filePath, 0000); err != nil { + ei.t.Fatalf("Failed to make file unreadable: %v", err) + } + + ei.originalPerms[filePath] = 0644 + return filePath +} + +// MockFuncError injects a custom error into a variable function +// +// Use this to test error handling when system functions fail. +// Example from database_test.go: +// +// injector := NewErrorInjector(t) +// injector.MockFuncError("GetConfigDir", func() (string, error) { +// return "", os.ErrPermission +// }) +// defer injector.Cleanup() +func (ei *ErrorInjector) MockFuncError(name string, cleanup func()) { + ei.t.Helper() + ei.cleanupFuncs = append(ei.cleanupFuncs, cleanup) +} + +// SetEnv sets an environment variable and tracks it for cleanup +// +// Use this to test environment variable handling and overrides. +// Example from config_test.go: +// +// injector := NewErrorInjector(t) +// injector.SetEnv("NOTELEAF_CONFIG", "/custom/path") +// defer injector.Cleanup() +func (ei *ErrorInjector) SetEnv(key, value string) { + ei.t.Helper() + + if _, exists := ei.envVarsModified[key]; !exists { + ei.envVarsModified[key] = os.Getenv(key) + } + + os.Setenv(key, value) +} + +// UnsetEnv unsets an environment variable and tracks it for restoration +func (ei *ErrorInjector) UnsetEnv(key string) { + ei.t.Helper() + + if _, exists := ei.envVarsModified[key]; !exists { + ei.envVarsModified[key] = os.Getenv(key) + } + + os.Unsetenv(key) +} + +// Cleanup restores all modified permissions, removes temp directories, and restores environment +func (ei *ErrorInjector) Cleanup() { + for path, perm := range ei.originalPerms { + os.Chmod(path, perm) + } + + for _, dir := range ei.tempDirs { + os.RemoveAll(dir) + } + + for _, cleanup := range ei.cleanupFuncs { + cleanup() + } + + for key, value := range ei.envVarsModified { + if value == "" { + os.Unsetenv(key) + } else { + os.Setenv(key, value) + } + } +} + +// DBErrorSimulator provides utilities for testing database error scenarios +type DBErrorSimulator struct { + t *testing.T + db *sql.DB +} + +// NewDBErrorSimulator creates a database error simulator +func NewDBErrorSimulator(t *testing.T, db *sql.DB) *DBErrorSimulator { + t.Helper() + return &DBErrorSimulator{t: t, db: db} +} + +// CorruptTable drops a table to simulate database corruption +// +// Use this to test error handling when database schema is corrupted. +// Example from handlers/test_utilities.go: +// +// sim := NewDBErrorSimulator(t, db.DB) +// sim.CorruptTable("tasks") +// // Test code that should handle missing table gracefully +func (sim *DBErrorSimulator) CorruptTable(tableName string) { + sim.t.Helper() + sim.db.Exec(fmt.Sprintf("DROP TABLE %s", tableName)) +} + +// SimulateConnectionFailure closes the database connection +// +// Use this to test error handling when database connection is lost. +// Example pattern: +// +// sim := NewDBErrorSimulator(t, db.DB) +// sim.SimulateConnectionFailure() +// // Test code that should handle connection errors +func (sim *DBErrorSimulator) SimulateConnectionFailure() { + sim.t.Helper() + sim.db.Close() +} + +// ConstraintViolationHelper provides utilities for testing constraint violations +type ConstraintViolationHelper struct { + t *testing.T + db *sql.DB + ctx context.Context +} + +// NewConstraintViolationHelper creates a constraint violation helper +func NewConstraintViolationHelper(t *testing.T, db *sql.DB) *ConstraintViolationHelper { + t.Helper() + return &ConstraintViolationHelper{ + t: t, + db: db, + ctx: context.Background(), + } +} + +// TestUniqueConstraint tests unique constraint violations +// +// Use this to verify error handling when inserting duplicate unique values. +// Pattern: +// +// helper := NewConstraintViolationHelper(t, db) +// helper.TestUniqueConstraint("tasks", "uuid", "duplicate-uuid", func() error { +// return repo.Create(ctx, task) +// }) +func (cvh *ConstraintViolationHelper) TestUniqueConstraint(tableName, columnName, duplicateValue string, operation func() error) { + cvh.t.Helper() + + // First operation should succeed + err := operation() + if err != nil { + cvh.t.Fatalf("First operation should succeed: %v", err) + } + + // Second operation should fail with constraint violation + err = operation() + if err == nil { + cvh.t.Errorf("Expected unique constraint violation on %s.%s", tableName, columnName) + } +} + +// TestForeignKeyConstraint tests foreign key constraint violations +// +// Use this to verify error handling when referencing non-existent foreign keys. +// Pattern: +// +// helper := NewConstraintViolationHelper(t, db) +// helper.TestForeignKeyConstraint("time_entries", "task_id", "nonexistent-task-id", func() error { +// return repo.CreateTimeEntry(ctx, entry) +// }) +func (cvh *ConstraintViolationHelper) TestForeignKeyConstraint(tableName, columnName, invalidFK string, operation func() error) { + cvh.t.Helper() + + err := operation() + if err == nil { + cvh.t.Errorf("Expected foreign key constraint violation on %s.%s", tableName, columnName) + } +} + +// ContextHelper provides utilities for testing context cancellation and timeouts +type ContextHelper struct { + t *testing.T +} + +// NewContextHelper creates a context helper +func NewContextHelper(t *testing.T) *ContextHelper { + t.Helper() + return &ContextHelper{t: t} +} + +// CancelledContext returns a pre-cancelled context +// +// Use this to test error handling when operations are called with cancelled context. +// Example from task_repository_test.go: +// +// helper := NewContextHelper(t) +// ctx := helper.CancelledContext() +// _, err := repo.Create(ctx, task) +// if err == nil { +// t.Error("Expected error with cancelled context") +// } +func (ch *ContextHelper) CancelledContext() context.Context { + ch.t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx +} + +// TimeoutContext returns a context that times out after the specified duration +// +// Use this to test timeout handling in long-running operations. +// Pattern: +// +// helper := NewContextHelper(t) +// ctx := helper.TimeoutContext(1 * time.Millisecond) +// // Slow operation that should timeout +func (ch *ContextHelper) TimeoutContext(timeout time.Duration) context.Context { + ch.t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + ch.t.Cleanup(cancel) + return ctx +} + +// DeadlineExceededContext returns a context with an already-expired deadline +// +// Use this to test error handling when context deadline is already exceeded. +func (ch *ContextHelper) DeadlineExceededContext() context.Context { + ch.t.Helper() + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second)) + ch.t.Cleanup(cancel) + return ctx +} + +// PlatformSpecific provides utilities for platform-specific testing +type PlatformSpecific struct { + t *testing.T +} + +// NewPlatformSpecific creates platform-specific test helper +func NewPlatformSpecific(t *testing.T) *PlatformSpecific { + t.Helper() + return &PlatformSpecific{t: t} +} + +// SkipOnWindows skips the test if running on Windows +// +// Use this for tests that rely on Unix-specific features like chmod. +func (ps *PlatformSpecific) SkipOnWindows(reason string) { + ps.t.Helper() + if runtime.GOOS == "windows" { + ps.t.Skip(reason) + } +} + +// SkipOnMac skips the test if running on macOS +// +// Use this for tests that have platform-specific behavior on macOS. +func (ps *PlatformSpecific) SkipOnMac(reason string) { + ps.t.Helper() + if runtime.GOOS == "darwin" { + ps.t.Skip(reason) + } +} + +// RunOnlyOn runs the test only on specified platforms +// +// Use this to create platform-specific test cases. +func (ps *PlatformSpecific) RunOnlyOn(platforms []string) { + ps.t.Helper() + if slices.Contains(platforms, runtime.GOOS) { + return + } + ps.t.Skipf("Test only runs on %v, skipping on %s", platforms, runtime.GOOS) +} + +// IsolatedEnvironment creates an isolated test environment with custom dirs +type IsolatedEnvironment struct { + t *testing.T + TempDir string + ConfigDir string + DataDir string + originalGetConfigDir func() (string, error) + originalGetDataDir func() (string, error) +} + +// NewIsolatedEnvironment creates an isolated test environment +// +// Use this to test in complete isolation from the actual system config/data directories. +// Example from database_test.go: +// +// env := NewIsolatedEnvironment(t) +// defer env.Cleanup() +// // All operations will use env.TempDir +func NewIsolatedEnvironment(t *testing.T) *IsolatedEnvironment { + t.Helper() + + tempDir, err := os.MkdirTemp("", "noteleaf-isolated-*") + if err != nil { + t.Fatalf("Failed to create isolated temp directory: %v", err) + } + + env := &IsolatedEnvironment{ + t: t, + TempDir: tempDir, + ConfigDir: tempDir, + DataDir: tempDir, + originalGetConfigDir: GetConfigDir, + originalGetDataDir: GetDataDir, + } + + GetConfigDir = func() (string, error) { + return env.ConfigDir, nil + } + + GetDataDir = func() (string, error) { + return env.DataDir, nil + } + + t.Cleanup(func() { + env.Cleanup() + }) + + return env +} + +// SetConfigDirError configures GetConfigDir to return an error +func (env *IsolatedEnvironment) SetConfigDirError(err error) { + env.t.Helper() + GetConfigDir = func() (string, error) { + return "", err + } +} + +// SetDataDirError configures GetDataDir to return an error +func (env *IsolatedEnvironment) SetDataDirError(err error) { + env.t.Helper() + GetDataDir = func() (string, error) { + return "", err + } +} + +// Cleanup restores original functions and removes temp directory +func (env *IsolatedEnvironment) Cleanup() { + GetConfigDir = env.originalGetConfigDir + GetDataDir = env.originalGetDataDir + os.RemoveAll(env.TempDir) +} + +// AssertError checks that an error occurred +// +// Use this for simple error existence checks. +func AssertError(t *testing.T, err error, msg string) { + t.Helper() + if err == nil { + t.Errorf("%s: expected error but got none", msg) + } +} + +// AssertNoError checks that no error occurred +// +// Use this to verify successful operations. +func AssertNoError(t *testing.T, err error, msg string) { + t.Helper() + if err != nil { + t.Errorf("%s: unexpected error: %v", msg, err) + } +} + +// AssertErrorContains checks that error contains expected substring +// +// Use this to verify specific error messages. +func AssertErrorContains(t *testing.T, err error, expectedSubstring, msg string) { + t.Helper() + if err == nil { + t.Errorf("%s: expected error containing %q but got none", msg, expectedSubstring) + return + } + if expectedSubstring != "" && !contains(err.Error(), expectedSubstring) { + t.Errorf("%s: expected error containing %q, got: %v", msg, expectedSubstring, err) + } +} + +// AssertErrorIs checks that error matches target using [errors.Is] +// +// Use this to verify error types and wrapped errors. +func AssertErrorIs(t *testing.T, err, target error, msg string) { + t.Helper() + if !errors.Is(err, target) { + t.Errorf("%s: expected error to be %v, got: %v", msg, target, err) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) >= len(needle) && + (haystack[len(haystack)-len(needle):] == needle || + haystack[:len(needle)] == needle || + (len(haystack) > len(needle) && func() bool { + for i := 1; i <= len(haystack)-len(needle); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false + }())) +} -- knotmirror 0.0.1