cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat(wip): error handling test utilities

+1504 -21
+307 -21
internal/handlers/test_utilities.go
··· 9 9 "net/http/httptest" 10 10 "os" 11 11 "path/filepath" 12 + "runtime" 12 13 "strconv" 13 14 "strings" 14 15 "sync" ··· 460 461 461 462 var Expect = AssertionHelpers{} 462 463 464 + // Error Testing Helpers for Handlers 465 + // 466 + // These utilities provide systematic error testing for handler-specific scenarios 467 + // including constructor failures, repository errors, and file system operations. 468 + 469 + // HandlerErrorTester provides utilities for testing handler error scenarios 470 + type HandlerErrorTester struct { 471 + t *testing.T 472 + } 473 + 474 + // NewHandlerErrorTester creates a handler error tester 475 + func NewHandlerErrorTester(t *testing.T) *HandlerErrorTester { 476 + t.Helper() 477 + return &HandlerErrorTester{t: t} 478 + } 479 + 480 + // TestConstructorDatabaseFailure tests handler constructor with database initialization failure 481 + // 482 + // Use this to verify handlers handle database connection errors gracefully. 483 + // Example: 484 + // 485 + // tester := NewHandlerErrorTester(t) 486 + // tester.TestConstructorDatabaseFailure(func() error { 487 + // // Close global database to simulate failure 488 + // _, err := NewTaskHandler() 489 + // return err 490 + // }) 491 + func (het *HandlerErrorTester) TestConstructorDatabaseFailure(constructor func() error) { 492 + het.t.Helper() 493 + 494 + err := constructor() 495 + if err == nil { 496 + het.t.Error("Handler constructor should fail when database initialization fails") 497 + } 498 + } 499 + 500 + // TestConstructorConfigFailure tests handler constructor with config loading failure 501 + // 502 + // Use this to verify handlers handle config errors properly. 503 + func (het *HandlerErrorTester) TestConstructorConfigFailure(constructor func() error) { 504 + het.t.Helper() 505 + 506 + err := constructor() 507 + if err == nil { 508 + het.t.Error("Handler constructor should fail when config loading fails") 509 + } 510 + } 511 + 512 + // TestRepositoryMethodError tests handler methods when repository operations fail 513 + // 514 + // Use this to verify handlers propagate repository errors correctly. 515 + // Example: 516 + // 517 + // tester := NewHandlerErrorTester(t) 518 + // tester.TestRepositoryMethodError("Create", func() error { 519 + // // Corrupt database to cause repository error 520 + // dbHelper.CorruptTable("tasks") 521 + // return handler.Create(ctx, "description", ...) 522 + // }) 523 + func (het *HandlerErrorTester) TestRepositoryMethodError(methodName string, operation func() error) { 524 + het.t.Helper() 525 + 526 + err := operation() 527 + if err == nil { 528 + het.t.Errorf("Handler %s should propagate repository errors", methodName) 529 + } 530 + } 531 + 532 + // FileSystemErrorHelper provides utilities for testing file system errors in handlers 533 + type FileSystemErrorHelper struct { 534 + t *testing.T 535 + tempDirs []string 536 + } 537 + 538 + // NewFileSystemErrorHelper creates a file system error helper 539 + func NewFileSystemErrorHelper(t *testing.T) *FileSystemErrorHelper { 540 + t.Helper() 541 + return &FileSystemErrorHelper{ 542 + t: t, 543 + tempDirs: make([]string, 0), 544 + } 545 + } 546 + 547 + // CreateReadOnlyDirectory creates a read-only directory for testing permission errors 548 + // 549 + // Use this to test handler behavior when unable to write files. 550 + // Example: 551 + // 552 + // helper := NewFileSystemErrorHelper(t) 553 + // defer helper.Cleanup() 554 + // readOnlyDir := helper.CreateReadOnlyDirectory() 555 + // // Test operations that should fail with permission errors 556 + func (fseh *FileSystemErrorHelper) CreateReadOnlyDirectory() string { 557 + fseh.t.Helper() 558 + 559 + if runtime.GOOS == "windows" { 560 + fseh.t.Skip("Permission test not reliable on Windows") 561 + } 562 + 563 + tempDir, err := os.MkdirTemp("", "noteleaf-readonly-*") 564 + if err != nil { 565 + fseh.t.Fatalf("Failed to create temp directory: %v", err) 566 + } 567 + 568 + fseh.tempDirs = append(fseh.tempDirs, tempDir) 569 + 570 + if err := os.Chmod(tempDir, 0555); err != nil { 571 + fseh.t.Fatalf("Failed to make directory read-only: %v", err) 572 + } 573 + 574 + return tempDir 575 + } 576 + 577 + // CreateUnwritableFile creates a file that cannot be written to 578 + // 579 + // Use this to test error handling when files cannot be modified. 580 + func (fseh *FileSystemErrorHelper) CreateUnwritableFile(content string) string { 581 + fseh.t.Helper() 582 + 583 + if runtime.GOOS == "windows" { 584 + fseh.t.Skip("Permission test not reliable on Windows") 585 + } 586 + 587 + tempDir, err := os.MkdirTemp("", "noteleaf-unwritable-*") 588 + if err != nil { 589 + fseh.t.Fatalf("Failed to create temp directory: %v", err) 590 + } 591 + 592 + fseh.tempDirs = append(fseh.tempDirs, tempDir) 593 + 594 + filePath := filepath.Join(tempDir, "test-file.txt") 595 + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 596 + fseh.t.Fatalf("Failed to create file: %v", err) 597 + } 598 + 599 + if err := os.Chmod(filePath, 0444); err != nil { 600 + fseh.t.Fatalf("Failed to make file read-only: %v", err) 601 + } 602 + 603 + return filePath 604 + } 605 + 606 + // SimulateDiskFull simulates disk full errors using /dev/full on Unix systems 607 + // 608 + // Use this to test error handling when disk space is exhausted. 609 + // Note: This only works on Unix systems with /dev/full 610 + func (fseh *FileSystemErrorHelper) SimulateDiskFull() string { 611 + fseh.t.Helper() 612 + 613 + if runtime.GOOS == "windows" { 614 + fseh.t.Skip("Disk full simulation not available on Windows") 615 + } 616 + 617 + if _, err := os.Stat("/dev/full"); os.IsNotExist(err) { 618 + fseh.t.Skip("/dev/full not available on this system") 619 + } 620 + 621 + return "/dev/full" 622 + } 623 + 624 + // Cleanup removes temporary directories and restores permissions 625 + func (fseh *FileSystemErrorHelper) Cleanup() { 626 + for _, dir := range fseh.tempDirs { 627 + os.Chmod(dir, 0755) 628 + os.RemoveAll(dir) 629 + } 630 + } 631 + 632 + // RepositoryErrorSimulator simulates repository errors for handler testing 633 + type RepositoryErrorSimulator struct { 634 + t *testing.T 635 + dbHelper *DatabaseTestHelper 636 + } 637 + 638 + // NewRepositoryErrorSimulator creates a repository error simulator 639 + func NewRepositoryErrorSimulator(t *testing.T, dbHelper *DatabaseTestHelper) *RepositoryErrorSimulator { 640 + t.Helper() 641 + return &RepositoryErrorSimulator{ 642 + t: t, 643 + dbHelper: dbHelper, 644 + } 645 + } 646 + 647 + // SimulateCreateError simulates repository Create method failure 648 + // 649 + // Use this to test handler behavior when repository create operations fail. 650 + func (res *RepositoryErrorSimulator) SimulateCreateError(tableName string) { 651 + res.t.Helper() 652 + res.dbHelper.DropNotesTable() 653 + } 654 + 655 + // SimulateGetError simulates repository Get method failure 656 + // 657 + // Use this to test handler behavior when repository get operations fail. 658 + func (res *RepositoryErrorSimulator) SimulateGetError() { 659 + res.t.Helper() 660 + res.dbHelper.CloseDatabase() 661 + } 662 + 663 + // SimulateUpdateError simulates repository Update method failure 664 + // 665 + // Use this to test handler behavior when repository update operations fail. 666 + func (res *RepositoryErrorSimulator) SimulateUpdateError() { 667 + res.t.Helper() 668 + res.dbHelper.CloseDatabase() 669 + } 670 + 671 + // SimulateDeleteError simulates repository Delete method failure 672 + // 673 + // Use this to test handler behavior when repository delete operations fail. 674 + func (res *RepositoryErrorSimulator) SimulateDeleteError() { 675 + res.t.Helper() 676 + res.dbHelper.CloseDatabase() 677 + } 678 + 679 + // ValidationErrorHelper provides utilities for testing validation errors 680 + type ValidationErrorHelper struct { 681 + t *testing.T 682 + } 683 + 684 + // NewValidationErrorHelper creates a validation error helper 685 + func NewValidationErrorHelper(t *testing.T) *ValidationErrorHelper { 686 + t.Helper() 687 + return &ValidationErrorHelper{t: t} 688 + } 689 + 690 + // AssertValidationError verifies that an operation fails with validation error 691 + // 692 + // Use this to test input validation in handlers. 693 + // Example: 694 + // 695 + // helper := NewValidationErrorHelper(t) 696 + // helper.AssertValidationError(func() error { 697 + // return handler.Create(ctx, "", ...) // Empty description 698 + // }, "description") 699 + func (veh *ValidationErrorHelper) AssertValidationError(operation func() error, expectedField string) { 700 + veh.t.Helper() 701 + 702 + err := operation() 703 + if err == nil { 704 + veh.t.Errorf("Expected validation error for field %s", expectedField) 705 + return 706 + } 707 + 708 + if expectedField != "" && !containsString(err.Error(), expectedField) { 709 + veh.t.Logf("Validation error does not mention field %s: %v", expectedField, err) 710 + } 711 + } 712 + 713 + // HandlerErrorScenario defines a common error testing scenario for handlers 714 + type HandlerErrorScenario struct { 715 + Name string 716 + Setup func(*testing.T) func() 717 + Operation func(*testing.T) error 718 + ExpectError bool 719 + ErrorCheck func(*testing.T, error) 720 + } 721 + 722 + // RunHandlerErrorScenarios executes a set of error testing scenarios for handlers 723 + // 724 + // Use this to systematically test all error paths in a handler. 725 + // Example: 726 + // 727 + // scenarios := []HandlerErrorScenario{ 728 + // { 729 + // Name: "database connection failure", 730 + // Setup: func(t *testing.T) func() { 731 + // dbHelper.CloseDatabase() 732 + // return func() { dbHelper.RestoreDatabase(t) } 733 + // }, 734 + // Operation: func(t *testing.T) error { 735 + // return handler.Create(ctx, "task") 736 + // }, 737 + // ExpectError: true, 738 + // }, 739 + // } 740 + // RunHandlerErrorScenarios(t, scenarios) 741 + func RunHandlerErrorScenarios(t *testing.T, scenarios []HandlerErrorScenario) { 742 + t.Helper() 743 + 744 + for _, scenario := range scenarios { 745 + t.Run(scenario.Name, func(t *testing.T) { 746 + cleanup := scenario.Setup(t) 747 + defer cleanup() 748 + 749 + err := scenario.Operation(t) 750 + 751 + if scenario.ExpectError && err == nil { 752 + t.Errorf("Expected error in scenario %s but got none", scenario.Name) 753 + } else if !scenario.ExpectError && err != nil { 754 + t.Errorf("Unexpected error in scenario %s: %v", scenario.Name, err) 755 + } 756 + 757 + if scenario.ErrorCheck != nil && err != nil { 758 + scenario.ErrorCheck(t, err) 759 + } 760 + }) 761 + } 762 + } 763 + 463 764 // HTTPMockServer provides utilities for mocking HTTP services in tests 464 765 type HTTPMockServer struct { 465 766 server *httptest.Server ··· 728 1029 } 729 1030 730 1031 // InputSimulator provides controlled input simulation for testing [fmt.Scanf] interactions 731 - // It implements [io.Reader] to provide predictable input sequences for interactive components 1032 + // 1033 + // It implements [io.Reader] to provide predictable input sequences for interactive components 732 1034 type InputSimulator struct { 733 1035 inputs []string 734 1036 position int ··· 1169 1471 // CommonTUIScenarios returns standard TUI testing scenarios 1170 1472 func CommonTUIScenarios() []TUITestScenario { 1171 1473 return []TUITestScenario{ 1172 - { 1173 - Name: "help_toggle", 1174 - KeySequence: []tea.KeyType{tea.KeyRunes}, 1175 - Timeout: 500 * time.Millisecond, 1176 - }, 1177 - { 1178 - Name: "navigation_down", 1179 - KeySequence: []tea.KeyType{tea.KeyDown, tea.KeyDown, tea.KeyUp}, 1180 - Timeout: 500 * time.Millisecond, 1181 - }, 1182 - { 1183 - Name: "page_navigation", 1184 - KeySequence: []tea.KeyType{tea.KeyPgDown, tea.KeyPgUp}, 1185 - Timeout: 500 * time.Millisecond, 1186 - }, 1187 - { 1188 - Name: "quit_sequence", 1189 - KeySequence: []tea.KeyType{tea.KeyCtrlC}, 1190 - Timeout: 500 * time.Millisecond, 1191 - }, 1474 + {Name: "help_toggle", KeySequence: []tea.KeyType{tea.KeyRunes}, Timeout: 500 * time.Millisecond}, 1475 + {Name: "navigation_down", KeySequence: []tea.KeyType{tea.KeyDown, tea.KeyDown, tea.KeyUp}, Timeout: 500 * time.Millisecond}, 1476 + {Name: "page_navigation", KeySequence: []tea.KeyType{tea.KeyPgDown, tea.KeyPgUp}, Timeout: 500 * time.Millisecond}, 1477 + {Name: "quit_sequence", KeySequence: []tea.KeyType{tea.KeyCtrlC}, Timeout: 500 * time.Millisecond}, 1192 1478 } 1193 1479 }
+247
internal/repo/task_repository_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "slices" 7 + "strings" 6 8 "testing" 7 9 "time" 8 10 ··· 1051 1053 t.Errorf("Expected context 'home', got '%s'", homeTasks[0].Context) 1052 1054 } 1053 1055 } 1056 + 1057 + func TestTaskRepositoryConstraintViolations(t *testing.T) { 1058 + db := CreateTestDB(t) 1059 + repo := NewTaskRepository(db) 1060 + ctx := context.Background() 1061 + 1062 + t.Run("UniqueConstraintViolation", func(t *testing.T) { 1063 + tester := NewRepositoryErrorTester(t, db) 1064 + uniqueUUID := newUUID() 1065 + 1066 + tester.TestUniqueConstraintViolation("task", func() error { 1067 + task := CreateSampleTask() 1068 + task.UUID = uniqueUUID 1069 + _, err := repo.Create(ctx, task) 1070 + return err 1071 + }) 1072 + }) 1073 + 1074 + t.Run("ForeignKeyViolation", func(t *testing.T) { 1075 + tester := NewRepositoryErrorTester(t, db) 1076 + 1077 + tester.TestForeignKeyViolation("task dependency", func() error { 1078 + task := CreateSampleTask() 1079 + task.DependsOn = []string{"non-existent-task-uuid"} 1080 + _, err := repo.Create(ctx, task) 1081 + return err 1082 + }) 1083 + }) 1084 + 1085 + t.Run("ContextCancellation", func(t *testing.T) { 1086 + tester := NewRepositoryErrorTester(t, db) 1087 + 1088 + tester.TestContextCancellation("Create", func(ctx context.Context) error { 1089 + task := CreateSampleTask() 1090 + _, err := repo.Create(ctx, task) 1091 + return err 1092 + }) 1093 + 1094 + tester.TestContextCancellation("Get", func(ctx context.Context) error { 1095 + _, err := repo.Get(ctx, 1) 1096 + return err 1097 + }) 1098 + 1099 + tester.TestContextCancellation("Update", func(ctx context.Context) error { 1100 + task := CreateSampleTask() 1101 + task.ID = 1 1102 + return repo.Update(ctx, task) 1103 + }) 1104 + 1105 + tester.TestContextCancellation("Delete", func(ctx context.Context) error { 1106 + return repo.Delete(ctx, 1) 1107 + }) 1108 + }) 1109 + 1110 + t.Run("NonExistentEntityOperations", func(t *testing.T) { 1111 + tester := NewRepositoryErrorTester(t, db) 1112 + 1113 + tester.TestGetNonExistent("task", func() error { 1114 + _, err := repo.Get(ctx, 999999) 1115 + return err 1116 + }) 1117 + 1118 + // Note: Update currently doesn't error on non-existent tasks 1119 + // This is a gap in error handling that could be improved 1120 + t.Run("UpdateNonExistent", func(t *testing.T) { 1121 + task := CreateSampleTask() 1122 + task.ID = 999999 1123 + err := repo.Update(ctx, task) 1124 + // FIXME: Current implementation doesn't return error for non-existent ID 1125 + // This should be fixed to return sql.ErrNoRows or similar 1126 + t.Logf("Update non-existent task returned: %v", err) 1127 + }) 1128 + 1129 + // Note: Delete also doesn't error on non-existent tasks 1130 + t.Run("DeleteNonExistent", func(t *testing.T) { 1131 + err := repo.Delete(ctx, 999999) 1132 + // FIXME: Current implementation doesn't return error for non-existent ID 1133 + // This should be fixed to check RowsAffected and return error if 0 1134 + t.Logf("Delete non-existent task returned: %v", err) 1135 + }) 1136 + }) 1137 + } 1138 + 1139 + // TestTaskRepositoryMarshalingErrors tests marshaling/unmarshaling error handling 1140 + func TestTaskRepositoryMarshalingErrors(t *testing.T) { 1141 + db := CreateTestDB(t) 1142 + repo := NewTaskRepository(db) 1143 + ctx := context.Background() 1144 + 1145 + t.Run("InvalidTagsMarshaling", func(t *testing.T) { 1146 + helper := NewMarshalingErrorHelper(t) 1147 + 1148 + task := CreateSampleTask() 1149 + id, err := repo.Create(ctx, task) 1150 + AssertNoError(t, err, "Failed to create task") 1151 + 1152 + helper.TestInvalidJSONMarshaling(func() error { 1153 + task.Tags = []string{helper.CreateInvalidUTF8String()} 1154 + _, err := task.MarshalTags() 1155 + return err 1156 + }) 1157 + 1158 + task2 := CreateSampleTask() 1159 + task2.Tags = []string{"valid", "tags"} 1160 + _, err = repo.Create(ctx, task2) 1161 + AssertNoError(t, err, "Should be able to create task after marshaling error") 1162 + 1163 + retrieved, err := repo.Get(ctx, id) 1164 + AssertNoError(t, err, "Should be able to retrieve task") 1165 + if retrieved == nil { 1166 + t.Error("Retrieved task should not be nil") 1167 + } 1168 + }) 1169 + 1170 + t.Run("InvalidAnnotationsMarshaling", func(t *testing.T) { 1171 + helper := NewMarshalingErrorHelper(t) 1172 + 1173 + task := CreateSampleTask() 1174 + id, err := repo.Create(ctx, task) 1175 + AssertNoError(t, err, "Failed to create task") 1176 + 1177 + helper.TestInvalidJSONMarshaling(func() error { 1178 + task.Annotations = []string{helper.CreateInvalidUTF8String()} 1179 + _, err := task.MarshalAnnotations() 1180 + return err 1181 + }) 1182 + 1183 + retrieved, err := repo.Get(ctx, id) 1184 + AssertNoError(t, err, "Should be able to retrieve task after error") 1185 + if retrieved == nil { 1186 + t.Error("Retrieved task should not be nil") 1187 + } 1188 + }) 1189 + } 1190 + 1191 + func TestTaskRepositoryEdgeCases(t *testing.T) { 1192 + db := CreateTestDB(t) 1193 + repo := NewTaskRepository(db) 1194 + ctx := context.Background() 1195 + 1196 + t.Run("EmptyDependencies", func(t *testing.T) { 1197 + task := CreateSampleTask() 1198 + task.DependsOn = []string{} 1199 + 1200 + id, err := repo.Create(ctx, task) 1201 + AssertNoError(t, err, "Should create task with empty dependencies") 1202 + 1203 + deps, err := repo.GetDependencies(ctx, task.UUID) 1204 + AssertNoError(t, err, "Should get empty dependencies") 1205 + if len(deps) != 0 { 1206 + t.Errorf("Expected 0 dependencies, got %d", len(deps)) 1207 + } 1208 + 1209 + retrieved, err := repo.Get(ctx, id) 1210 + AssertNoError(t, err, "Should retrieve task") 1211 + if len(retrieved.DependsOn) != 0 { 1212 + t.Errorf("Expected 0 dependencies on retrieved task, got %d", len(retrieved.DependsOn)) 1213 + } 1214 + }) 1215 + 1216 + t.Run("NilDueDate", func(t *testing.T) { 1217 + task := CreateSampleTask() 1218 + task.Due = nil 1219 + 1220 + id, err := repo.Create(ctx, task) 1221 + AssertNoError(t, err, "Should create task with nil due date") 1222 + 1223 + retrieved, err := repo.Get(ctx, id) 1224 + AssertNoError(t, err, "Should retrieve task") 1225 + if retrieved.Due != nil { 1226 + t.Error("Retrieved task should have nil due date") 1227 + } 1228 + }) 1229 + 1230 + t.Run("EmptyTags", func(t *testing.T) { 1231 + task := CreateSampleTask() 1232 + task.Tags = []string{} 1233 + 1234 + id, err := repo.Create(ctx, task) 1235 + AssertNoError(t, err, "Should create task with empty tags") 1236 + 1237 + retrieved, err := repo.Get(ctx, id) 1238 + AssertNoError(t, err, "Should retrieve task") 1239 + if len(retrieved.Tags) != 0 { 1240 + t.Errorf("Expected 0 tags on retrieved task, got %d", len(retrieved.Tags)) 1241 + } 1242 + }) 1243 + 1244 + t.Run("VeryLongDescription", func(t *testing.T) { 1245 + task := CreateSampleTask() 1246 + // Create a very long description (10KB) 1247 + longDesc := strings.Repeat("A very long task description. ", 340) 1248 + task.Description = longDesc 1249 + 1250 + id, err := repo.Create(ctx, task) 1251 + AssertNoError(t, err, "Should create task with very long description") 1252 + 1253 + retrieved, err := repo.Get(ctx, id) 1254 + AssertNoError(t, err, "Should retrieve task") 1255 + if retrieved.Description != longDesc { 1256 + t.Error("Long description should be preserved exactly") 1257 + } 1258 + }) 1259 + 1260 + t.Run("ManyTags", func(t *testing.T) { 1261 + task := CreateSampleTask() 1262 + tags := make([]string, 100) 1263 + for i := range tags { 1264 + tags[i] = fmt.Sprintf("tag-%d", i) 1265 + } 1266 + task.Tags = tags 1267 + 1268 + id, err := repo.Create(ctx, task) 1269 + AssertNoError(t, err, "Should create task with many tags") 1270 + 1271 + retrieved, err := repo.Get(ctx, id) 1272 + AssertNoError(t, err, "Should retrieve task") 1273 + if len(retrieved.Tags) != 100 { 1274 + t.Errorf("Expected 100 tags, got %d", len(retrieved.Tags)) 1275 + } 1276 + }) 1277 + 1278 + t.Run("CircularDependencyPrevention", func(t *testing.T) { 1279 + task1 := CreateSampleTask() 1280 + task2 := CreateSampleTask() 1281 + 1282 + id1, err := repo.Create(ctx, task1) 1283 + AssertNoError(t, err, "Failed to create task1") 1284 + 1285 + id2, err := repo.Create(ctx, task2) 1286 + AssertNoError(t, err, "Failed to create task2") 1287 + 1288 + err = repo.AddDependency(ctx, task2.UUID, task1.UUID) 1289 + AssertNoError(t, err, "Should add dependency task2 -> task1") 1290 + 1291 + err = repo.AddDependency(ctx, task1.UUID, task2.UUID) 1292 + t.Logf("Circular dependency result: %v", err) 1293 + 1294 + _, err = repo.Get(ctx, id1) 1295 + AssertNoError(t, err, "Should still be able to retrieve task1") 1296 + 1297 + _, err = repo.Get(ctx, id2) 1298 + AssertNoError(t, err, "Should still be able to retrieve task2") 1299 + }) 1300 + }
+241
internal/repo/test_utilities.go
··· 319 319 } 320 320 } 321 321 322 + // Error Testing Helpers for Repositories 323 + // 324 + // These utilities extend the core error testing framework for repository-specific 325 + // error scenarios including constraint violations, marshaling errors, and 326 + // context-based error testing. 327 + 328 + // RepositoryErrorTester provides systematic error testing for repository operations 329 + type RepositoryErrorTester struct { 330 + t *testing.T 331 + db *sql.DB 332 + ctx context.Context 333 + } 334 + 335 + // NewRepositoryErrorTester creates a repository error tester 336 + func NewRepositoryErrorTester(t *testing.T, db *sql.DB) *RepositoryErrorTester { 337 + t.Helper() 338 + return &RepositoryErrorTester{ 339 + t: t, 340 + db: db, 341 + ctx: context.Background(), 342 + } 343 + } 344 + 345 + // TestUniqueConstraintViolation tests unique constraint violations systematically 346 + // 347 + // Use this to test duplicate insertions across any repository. 348 + // Example: 349 + // 350 + // tester := NewRepositoryErrorTester(t, db) 351 + // tester.TestUniqueConstraintViolation("tasks", func() error { 352 + // task.UUID = "duplicate-uuid" 353 + // _, err := repo.Create(ctx, task) 354 + // return err 355 + // }) 356 + func (ret *RepositoryErrorTester) TestUniqueConstraintViolation(entityName string, duplicateInsert func() error) { 357 + ret.t.Helper() 358 + 359 + // First insert should succeed 360 + err := duplicateInsert() 361 + AssertNoError(ret.t, err, fmt.Sprintf("First %s insert should succeed", entityName)) 362 + 363 + // Second insert with same unique value should fail 364 + err = duplicateInsert() 365 + AssertError(ret.t, err, fmt.Sprintf("Duplicate %s should violate unique constraint", entityName)) 366 + } 367 + 368 + // TestForeignKeyViolation tests foreign key constraint violations 369 + // 370 + // Use this to test operations that reference non-existent foreign entities. 371 + // Example: 372 + // 373 + // tester := NewRepositoryErrorTester(t, db) 374 + // tester.TestForeignKeyViolation("time entry", func() error { 375 + // entry.TaskID = 999999 // Non-existent task 376 + // return repo.CreateTimeEntry(ctx, entry) 377 + // }) 378 + func (ret *RepositoryErrorTester) TestForeignKeyViolation(entityName string, invalidFKInsert func() error) { 379 + ret.t.Helper() 380 + 381 + err := invalidFKInsert() 382 + AssertError(ret.t, err, fmt.Sprintf("%s with invalid foreign key should fail", entityName)) 383 + } 384 + 385 + // TestNotNullViolation tests NOT NULL constraint violations 386 + // 387 + // Use this to verify required fields are enforced. 388 + func (ret *RepositoryErrorTester) TestNotNullViolation(entityName string, nullInsert func() error) { 389 + ret.t.Helper() 390 + 391 + err := nullInsert() 392 + AssertError(ret.t, err, fmt.Sprintf("%s with NULL required field should fail", entityName)) 393 + } 394 + 395 + // TestContextCancellation tests operation behavior with cancelled context 396 + // 397 + // Use this to verify all repository operations handle context cancellation. 398 + // Example: 399 + // 400 + // tester := NewRepositoryErrorTester(t, db) 401 + // tester.TestContextCancellation("Create", func(ctx context.Context) error { 402 + // _, err := repo.Create(ctx, task) 403 + // return err 404 + // }) 405 + func (ret *RepositoryErrorTester) TestContextCancellation(operationName string, operation func(context.Context) error) { 406 + ret.t.Helper() 407 + 408 + ctx, cancel := context.WithCancel(ret.ctx) 409 + cancel() 410 + 411 + err := operation(ctx) 412 + AssertError(ret.t, err, fmt.Sprintf("%s with cancelled context should fail", operationName)) 413 + } 414 + 415 + // TestGetNonExistent tests retrieval of non-existent entities 416 + // 417 + // Use this to verify proper error handling when entities don't exist. 418 + // Pattern: 419 + // 420 + // tester := NewRepositoryErrorTester(t, db) 421 + // tester.TestGetNonExistent("task", func() error { 422 + // _, err := repo.Get(ctx, 999999) 423 + // return err 424 + // }) 425 + func (ret *RepositoryErrorTester) TestGetNonExistent(entityName string, getNonExistent func() error) { 426 + ret.t.Helper() 427 + 428 + err := getNonExistent() 429 + AssertError(ret.t, err, fmt.Sprintf("Getting non-existent %s should fail", entityName)) 430 + } 431 + 432 + // TestUpdateNonExistent tests update of non-existent entities 433 + // 434 + // Use this to verify updates fail gracefully for missing entities. 435 + func (ret *RepositoryErrorTester) TestUpdateNonExistent(entityName string, updateNonExistent func() error) { 436 + ret.t.Helper() 437 + 438 + err := updateNonExistent() 439 + AssertError(ret.t, err, fmt.Sprintf("Updating non-existent %s should fail", entityName)) 440 + } 441 + 442 + // TestDeleteNonExistent tests deletion of non-existent entities 443 + // 444 + // Use this to verify deletes handle missing entities properly. 445 + func (ret *RepositoryErrorTester) TestDeleteNonExistent(entityName string, deleteNonExistent func() error) { 446 + ret.t.Helper() 447 + 448 + err := deleteNonExistent() 449 + AssertError(ret.t, err, fmt.Sprintf("Deleting non-existent %s should fail", entityName)) 450 + } 451 + 452 + // MarshalingErrorHelper provides utilities for testing marshaling/unmarshaling errors 453 + type MarshalingErrorHelper struct { 454 + t *testing.T 455 + } 456 + 457 + // NewMarshalingErrorHelper creates a marshaling error helper 458 + func NewMarshalingErrorHelper(t *testing.T) *MarshalingErrorHelper { 459 + t.Helper() 460 + return &MarshalingErrorHelper{t: t} 461 + } 462 + 463 + // TestInvalidJSONMarshaling tests marshaling of invalid JSON data 464 + // 465 + // Use this to verify error handling when JSON marshaling fails. 466 + // Example: 467 + // 468 + // helper := NewMarshalingErrorHelper(t) 469 + // helper.TestInvalidJSONMarshaling(func() error { 470 + // task.Tags = []string{string([]byte{0xff, 0xfe, 0xfd})} // Invalid UTF-8 471 + // _, err := task.MarshalTags() 472 + // return err 473 + // }) 474 + func (meh *MarshalingErrorHelper) TestInvalidJSONMarshaling(operation func() error) { 475 + meh.t.Helper() 476 + 477 + err := operation() 478 + if err != nil { 479 + // Expected - verify it's a marshaling error 480 + AssertErrorContains(meh.t, err, "", "Expected marshaling to handle invalid data") 481 + } 482 + } 483 + 484 + // TestInvalidJSONUnmarshaling tests unmarshaling of corrupted JSON 485 + // 486 + // Use this to verify error handling when unmarshaling invalid JSON. 487 + // Example: 488 + // 489 + // helper := NewMarshalingErrorHelper(t) 490 + // helper.TestInvalidJSONUnmarshaling(func() error { 491 + // invalidJSON := `{"broken": json` 492 + // return task.UnmarshalTags(invalidJSON) 493 + // }) 494 + func (meh *MarshalingErrorHelper) TestInvalidJSONUnmarshaling(operation func() error) { 495 + meh.t.Helper() 496 + 497 + err := operation() 498 + AssertError(meh.t, err, "Unmarshaling invalid JSON should fail") 499 + } 500 + 501 + // CreateInvalidJSONString returns a malformed JSON string for testing 502 + func (meh *MarshalingErrorHelper) CreateInvalidJSONString() string { 503 + return `{"invalid": json without closing` 504 + } 505 + 506 + // CreateInvalidUTF8String returns a string with invalid UTF-8 for testing 507 + func (meh *MarshalingErrorHelper) CreateInvalidUTF8String() string { 508 + return string([]byte{0xff, 0xfe, 0xfd}) 509 + } 510 + 511 + // RepositoryTestScenario defines a common test scenario for repositories 512 + type RepositoryTestScenario struct { 513 + Name string 514 + SetupFunc func(*testing.T, *sql.DB) (context.Context, func()) 515 + TestFunc func(*testing.T, context.Context, *sql.DB) 516 + } 517 + 518 + // RunRepositoryErrorScenarios executes a set of error testing scenarios 519 + // 520 + // Use this to systematically test all error paths in a repository. 521 + // Example: 522 + // 523 + // scenarios := []RepositoryTestScenario{ 524 + // { 525 + // Name: "context cancellation", 526 + // SetupFunc: func(t *testing.T, db *sql.DB) (context.Context, func()) { 527 + // ctx, cancel := context.WithCancel(context.Background()) 528 + // cancel() 529 + // return ctx, func() {} 530 + // }, 531 + // TestFunc: func(t *testing.T, ctx context.Context, db *sql.DB) { 532 + // repo := NewTaskRepository(db) 533 + // _, err := repo.Create(ctx, task) 534 + // AssertError(t, err, "Should fail with cancelled context") 535 + // }, 536 + // }, 537 + // } 538 + // RunRepositoryErrorScenarios(t, db, scenarios) 539 + func RunRepositoryErrorScenarios(t *testing.T, db *sql.DB, scenarios []RepositoryTestScenario) { 540 + t.Helper() 541 + 542 + for _, scenario := range scenarios { 543 + t.Run(scenario.Name, func(t *testing.T) { 544 + ctx, cleanup := scenario.SetupFunc(t, db) 545 + defer cleanup() 546 + scenario.TestFunc(t, ctx, db) 547 + }) 548 + } 549 + } 550 + 551 + // AssertErrorContains verifies error contains expected substring 552 + func AssertErrorContains(t *testing.T, err error, expectedSubstring, msg string) { 553 + t.Helper() 554 + if err == nil { 555 + t.Errorf("%s: expected error containing %q but got none", msg, expectedSubstring) 556 + return 557 + } 558 + if expectedSubstring != "" && !strings.Contains(err.Error(), expectedSubstring) { 559 + t.Errorf("%s: expected error containing %q, got: %v", msg, expectedSubstring, err) 560 + } 561 + } 562 + 322 563 // SetupTestData creates sample data in the database and returns the repositories 323 564 func SetupTestData(t *testing.T, db *sql.DB) *Repositories { 324 565 ctx := context.Background()
+203
internal/store/database_test.go
··· 286 286 } 287 287 }) 288 288 } 289 + 290 + // TestNewDatabaseWithConfig tests database creation with custom config 291 + func TestNewDatabaseWithConfig(t *testing.T) { 292 + tempDir, err := os.MkdirTemp("", "noteleaf-db-config-test-*") 293 + if err != nil { 294 + t.Fatalf("Failed to create temp directory: %v", err) 295 + } 296 + defer os.RemoveAll(tempDir) 297 + 298 + t.Run("creates database with custom database path", func(t *testing.T) { 299 + customDBPath := filepath.Join(tempDir, "custom.db") 300 + config := &Config{ 301 + DatabasePath: customDBPath, 302 + } 303 + 304 + db, err := NewDatabaseWithConfig(config) 305 + if err != nil { 306 + t.Fatalf("NewDatabaseWithConfig failed: %v", err) 307 + } 308 + defer db.Close() 309 + 310 + if db.GetPath() != customDBPath { 311 + t.Errorf("Expected database path %s, got %s", customDBPath, db.GetPath()) 312 + } 313 + 314 + if _, err := os.Stat(customDBPath); os.IsNotExist(err) { 315 + t.Error("Custom database file should exist") 316 + } 317 + }) 318 + 319 + t.Run("creates database with custom data dir", func(t *testing.T) { 320 + customDataDir := filepath.Join(tempDir, "data") 321 + 322 + // Create the custom data directory 323 + if err := os.MkdirAll(customDataDir, 0755); err != nil { 324 + t.Fatalf("Failed to create custom data dir: %v", err) 325 + } 326 + 327 + config := &Config{ 328 + DataDir: customDataDir, 329 + } 330 + 331 + db, err := NewDatabaseWithConfig(config) 332 + if err != nil { 333 + t.Fatalf("NewDatabaseWithConfig failed: %v", err) 334 + } 335 + defer db.Close() 336 + 337 + expectedPath := filepath.Join(customDataDir, "noteleaf.db") 338 + if db.GetPath() != expectedPath { 339 + t.Errorf("Expected database path %s, got %s", expectedPath, db.GetPath()) 340 + } 341 + }) 342 + 343 + t.Run("handles invalid custom database path", func(t *testing.T) { 344 + config := &Config{ 345 + DatabasePath: "/invalid/path/that/does/not/exist/database.db", 346 + } 347 + 348 + _, err := NewDatabaseWithConfig(config) 349 + if err == nil { 350 + t.Error("NewDatabaseWithConfig should fail with invalid database path") 351 + } 352 + }) 353 + 354 + t.Run("creates nested directories for custom database path", func(t *testing.T) { 355 + nestedPath := filepath.Join(tempDir, "nested", "deep", "database.db") 356 + config := &Config{ 357 + DatabasePath: nestedPath, 358 + } 359 + 360 + db, err := NewDatabaseWithConfig(config) 361 + if err != nil { 362 + t.Fatalf("NewDatabaseWithConfig should create nested directories: %v", err) 363 + } 364 + defer db.Close() 365 + 366 + if _, err := os.Stat(nestedPath); os.IsNotExist(err) { 367 + t.Error("Database file should exist in nested directory") 368 + } 369 + }) 370 + } 371 + 372 + // TestGetDataDirPlatformSpecific tests platform-specific GetDataDir behavior 373 + func TestGetDataDirPlatformSpecific(t *testing.T) { 374 + t.Run("handles missing LOCALAPPDATA on Windows", func(t *testing.T) { 375 + if runtime.GOOS != "windows" { 376 + t.Skip("Windows-specific test") 377 + } 378 + 379 + originalEnv := os.Getenv("LOCALAPPDATA") 380 + os.Unsetenv("LOCALAPPDATA") 381 + defer os.Setenv("LOCALAPPDATA", originalEnv) 382 + 383 + _, err := GetDataDir() 384 + if err == nil { 385 + t.Error("GetDataDir should fail when LOCALAPPDATA is not set on Windows") 386 + } 387 + }) 388 + 389 + t.Run("handles missing HOME on Unix", func(t *testing.T) { 390 + if runtime.GOOS == "windows" { 391 + t.Skip("Unix-specific test") 392 + } 393 + 394 + originalXDG := os.Getenv("XDG_DATA_HOME") 395 + originalHome := os.Getenv("HOME") 396 + os.Unsetenv("XDG_DATA_HOME") 397 + os.Unsetenv("HOME") 398 + 399 + defer func() { 400 + os.Setenv("XDG_DATA_HOME", originalXDG) 401 + os.Setenv("HOME", originalHome) 402 + }() 403 + 404 + _, err := GetDataDir() 405 + if err == nil { 406 + t.Error("GetDataDir should fail when both XDG_DATA_HOME and HOME are not set on Unix") 407 + } 408 + }) 409 + 410 + t.Run("uses XDG_DATA_HOME when set on Linux", func(t *testing.T) { 411 + if runtime.GOOS != "linux" { 412 + t.Skip("Linux-specific test") 413 + } 414 + 415 + tempDir, err := os.MkdirTemp("", "noteleaf-xdg-test-*") 416 + if err != nil { 417 + t.Fatalf("Failed to create temp directory: %v", err) 418 + } 419 + defer os.RemoveAll(tempDir) 420 + 421 + originalXDG := os.Getenv("XDG_DATA_HOME") 422 + os.Setenv("XDG_DATA_HOME", tempDir) 423 + defer os.Setenv("XDG_DATA_HOME", originalXDG) 424 + 425 + dataDir, err := GetDataDir() 426 + if err != nil { 427 + t.Fatalf("GetDataDir failed: %v", err) 428 + } 429 + 430 + expectedPath := filepath.Join(tempDir, "noteleaf") 431 + if dataDir != expectedPath { 432 + t.Errorf("Expected data dir %s, got %s", expectedPath, dataDir) 433 + } 434 + }) 435 + 436 + t.Run("falls back to HOME/.local/share on Linux when XDG_DATA_HOME not set", func(t *testing.T) { 437 + if runtime.GOOS != "linux" { 438 + t.Skip("Linux-specific test") 439 + } 440 + 441 + tempHome, err := os.MkdirTemp("", "noteleaf-home-test-*") 442 + if err != nil { 443 + t.Fatalf("Failed to create temp home: %v", err) 444 + } 445 + defer os.RemoveAll(tempHome) 446 + 447 + originalXDG := os.Getenv("XDG_DATA_HOME") 448 + originalHome := os.Getenv("HOME") 449 + os.Unsetenv("XDG_DATA_HOME") 450 + os.Setenv("HOME", tempHome) 451 + 452 + defer func() { 453 + os.Setenv("XDG_DATA_HOME", originalXDG) 454 + os.Setenv("HOME", originalHome) 455 + }() 456 + 457 + dataDir, err := GetDataDir() 458 + if err != nil { 459 + t.Fatalf("GetDataDir failed: %v", err) 460 + } 461 + 462 + expectedPath := filepath.Join(tempHome, ".local", "share", "noteleaf") 463 + if dataDir != expectedPath { 464 + t.Errorf("Expected data dir %s, got %s", expectedPath, dataDir) 465 + } 466 + }) 467 + } 468 + 469 + // TestDatabaseConstraintViolations tests database constraint handling 470 + func TestDatabaseConstraintViolations(t *testing.T) { 471 + env := NewIsolatedEnvironment(t) 472 + defer env.Cleanup() 473 + 474 + t.Run("handles foreign key constraint violations", func(t *testing.T) { 475 + db, err := NewDatabase() 476 + if err != nil { 477 + t.Fatalf("NewDatabase failed: %v", err) 478 + } 479 + defer db.Close() 480 + 481 + var foreignKeys int 482 + err = db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys) 483 + if err != nil { 484 + t.Fatalf("Failed to check foreign keys: %v", err) 485 + } 486 + 487 + if foreignKeys != 1 { 488 + t.Error("Foreign keys should be enabled by default") 489 + } 490 + }) 491 + }
+506
internal/store/test_utilities.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "os" 9 + "path/filepath" 10 + "runtime" 11 + "slices" 12 + "testing" 13 + "time" 14 + ) 15 + 16 + // Error Testing Utilities 17 + // 18 + // This file provides reusable error testing helpers for systematically testing 19 + // error paths in the store package. The utilities are organized into categories: 20 + // 21 + // 1. ErrorInjector - File system and permission errors 22 + // 2. DBErrorSimulator - Database-specific errors 23 + // 3. ContextHelper - Context cancellation and timeout scenarios 24 + // 4. Platform-specific testing utilities 25 + // 26 + // Usage patterns are documented for each helper with examples from existing tests. 27 + 28 + // ErrorInjector provides utilities for simulating file system and permission errors 29 + type ErrorInjector struct { 30 + t *testing.T 31 + tempDirs []string 32 + originalPerms map[string]os.FileMode 33 + cleanupFuncs []func() 34 + envVarsModified map[string]string 35 + } 36 + 37 + // NewErrorInjector creates a new error injector for testing 38 + func NewErrorInjector(t *testing.T) *ErrorInjector { 39 + t.Helper() 40 + return &ErrorInjector{ 41 + t: t, 42 + tempDirs: make([]string, 0), 43 + originalPerms: make(map[string]os.FileMode), 44 + cleanupFuncs: make([]func(), 0), 45 + envVarsModified: make(map[string]string), 46 + } 47 + } 48 + 49 + // CreateUnwritableDir creates a directory with read-only permissions 50 + // 51 + // Use this to test errors when the application cannot write to config or data directories. 52 + // Example from config_test.go: 53 + // 54 + // injector := NewErrorInjector(t) 55 + // dir := injector.CreateUnwritableDir() 56 + // defer injector.Cleanup() 57 + // // Test code that should fail when dir is read-only 58 + func (ei *ErrorInjector) CreateUnwritableDir() string { 59 + ei.t.Helper() 60 + 61 + if runtime.GOOS == "windows" { 62 + ei.t.Skip("Permission test not reliable on Windows") 63 + } 64 + 65 + tempDir, err := os.MkdirTemp("", "noteleaf-unwritable-*") 66 + if err != nil { 67 + ei.t.Fatalf("Failed to create temp directory: %v", err) 68 + } 69 + 70 + ei.tempDirs = append(ei.tempDirs, tempDir) 71 + 72 + if err := os.Chmod(tempDir, 0555); err != nil { 73 + ei.t.Fatalf("Failed to make directory read-only: %v", err) 74 + } 75 + 76 + ei.originalPerms[tempDir] = 0755 77 + return tempDir 78 + } 79 + 80 + // CreateUnreadableFile creates a file with no read permissions 81 + // 82 + // Use this to test errors when the application cannot read config files. 83 + // Example pattern: 84 + // 85 + // injector := NewErrorInjector(t) 86 + // filePath := injector.CreateUnreadableFile("config.toml", "content") 87 + // defer injector.Cleanup() 88 + // // Test code that should fail when file is unreadable 89 + func (ei *ErrorInjector) CreateUnreadableFile(filename, content string) string { 90 + ei.t.Helper() 91 + 92 + if runtime.GOOS == "windows" { 93 + ei.t.Skip("Permission test not reliable on Windows") 94 + } 95 + 96 + tempDir, err := os.MkdirTemp("", "noteleaf-unreadable-*") 97 + if err != nil { 98 + ei.t.Fatalf("Failed to create temp directory: %v", err) 99 + } 100 + 101 + ei.tempDirs = append(ei.tempDirs, tempDir) 102 + 103 + filePath := filepath.Join(tempDir, filename) 104 + if err := os.WriteFile(filePath, []byte(content), 0644); err != nil { 105 + ei.t.Fatalf("Failed to create file: %v", err) 106 + } 107 + 108 + if err := os.Chmod(filePath, 0000); err != nil { 109 + ei.t.Fatalf("Failed to make file unreadable: %v", err) 110 + } 111 + 112 + ei.originalPerms[filePath] = 0644 113 + return filePath 114 + } 115 + 116 + // MockFuncError injects a custom error into a variable function 117 + // 118 + // Use this to test error handling when system functions fail. 119 + // Example from database_test.go: 120 + // 121 + // injector := NewErrorInjector(t) 122 + // injector.MockFuncError("GetConfigDir", func() (string, error) { 123 + // return "", os.ErrPermission 124 + // }) 125 + // defer injector.Cleanup() 126 + func (ei *ErrorInjector) MockFuncError(name string, cleanup func()) { 127 + ei.t.Helper() 128 + ei.cleanupFuncs = append(ei.cleanupFuncs, cleanup) 129 + } 130 + 131 + // SetEnv sets an environment variable and tracks it for cleanup 132 + // 133 + // Use this to test environment variable handling and overrides. 134 + // Example from config_test.go: 135 + // 136 + // injector := NewErrorInjector(t) 137 + // injector.SetEnv("NOTELEAF_CONFIG", "/custom/path") 138 + // defer injector.Cleanup() 139 + func (ei *ErrorInjector) SetEnv(key, value string) { 140 + ei.t.Helper() 141 + 142 + if _, exists := ei.envVarsModified[key]; !exists { 143 + ei.envVarsModified[key] = os.Getenv(key) 144 + } 145 + 146 + os.Setenv(key, value) 147 + } 148 + 149 + // UnsetEnv unsets an environment variable and tracks it for restoration 150 + func (ei *ErrorInjector) UnsetEnv(key string) { 151 + ei.t.Helper() 152 + 153 + if _, exists := ei.envVarsModified[key]; !exists { 154 + ei.envVarsModified[key] = os.Getenv(key) 155 + } 156 + 157 + os.Unsetenv(key) 158 + } 159 + 160 + // Cleanup restores all modified permissions, removes temp directories, and restores environment 161 + func (ei *ErrorInjector) Cleanup() { 162 + for path, perm := range ei.originalPerms { 163 + os.Chmod(path, perm) 164 + } 165 + 166 + for _, dir := range ei.tempDirs { 167 + os.RemoveAll(dir) 168 + } 169 + 170 + for _, cleanup := range ei.cleanupFuncs { 171 + cleanup() 172 + } 173 + 174 + for key, value := range ei.envVarsModified { 175 + if value == "" { 176 + os.Unsetenv(key) 177 + } else { 178 + os.Setenv(key, value) 179 + } 180 + } 181 + } 182 + 183 + // DBErrorSimulator provides utilities for testing database error scenarios 184 + type DBErrorSimulator struct { 185 + t *testing.T 186 + db *sql.DB 187 + } 188 + 189 + // NewDBErrorSimulator creates a database error simulator 190 + func NewDBErrorSimulator(t *testing.T, db *sql.DB) *DBErrorSimulator { 191 + t.Helper() 192 + return &DBErrorSimulator{t: t, db: db} 193 + } 194 + 195 + // CorruptTable drops a table to simulate database corruption 196 + // 197 + // Use this to test error handling when database schema is corrupted. 198 + // Example from handlers/test_utilities.go: 199 + // 200 + // sim := NewDBErrorSimulator(t, db.DB) 201 + // sim.CorruptTable("tasks") 202 + // // Test code that should handle missing table gracefully 203 + func (sim *DBErrorSimulator) CorruptTable(tableName string) { 204 + sim.t.Helper() 205 + sim.db.Exec(fmt.Sprintf("DROP TABLE %s", tableName)) 206 + } 207 + 208 + // SimulateConnectionFailure closes the database connection 209 + // 210 + // Use this to test error handling when database connection is lost. 211 + // Example pattern: 212 + // 213 + // sim := NewDBErrorSimulator(t, db.DB) 214 + // sim.SimulateConnectionFailure() 215 + // // Test code that should handle connection errors 216 + func (sim *DBErrorSimulator) SimulateConnectionFailure() { 217 + sim.t.Helper() 218 + sim.db.Close() 219 + } 220 + 221 + // ConstraintViolationHelper provides utilities for testing constraint violations 222 + type ConstraintViolationHelper struct { 223 + t *testing.T 224 + db *sql.DB 225 + ctx context.Context 226 + } 227 + 228 + // NewConstraintViolationHelper creates a constraint violation helper 229 + func NewConstraintViolationHelper(t *testing.T, db *sql.DB) *ConstraintViolationHelper { 230 + t.Helper() 231 + return &ConstraintViolationHelper{ 232 + t: t, 233 + db: db, 234 + ctx: context.Background(), 235 + } 236 + } 237 + 238 + // TestUniqueConstraint tests unique constraint violations 239 + // 240 + // Use this to verify error handling when inserting duplicate unique values. 241 + // Pattern: 242 + // 243 + // helper := NewConstraintViolationHelper(t, db) 244 + // helper.TestUniqueConstraint("tasks", "uuid", "duplicate-uuid", func() error { 245 + // return repo.Create(ctx, task) 246 + // }) 247 + func (cvh *ConstraintViolationHelper) TestUniqueConstraint(tableName, columnName, duplicateValue string, operation func() error) { 248 + cvh.t.Helper() 249 + 250 + // First operation should succeed 251 + err := operation() 252 + if err != nil { 253 + cvh.t.Fatalf("First operation should succeed: %v", err) 254 + } 255 + 256 + // Second operation should fail with constraint violation 257 + err = operation() 258 + if err == nil { 259 + cvh.t.Errorf("Expected unique constraint violation on %s.%s", tableName, columnName) 260 + } 261 + } 262 + 263 + // TestForeignKeyConstraint tests foreign key constraint violations 264 + // 265 + // Use this to verify error handling when referencing non-existent foreign keys. 266 + // Pattern: 267 + // 268 + // helper := NewConstraintViolationHelper(t, db) 269 + // helper.TestForeignKeyConstraint("time_entries", "task_id", "nonexistent-task-id", func() error { 270 + // return repo.CreateTimeEntry(ctx, entry) 271 + // }) 272 + func (cvh *ConstraintViolationHelper) TestForeignKeyConstraint(tableName, columnName, invalidFK string, operation func() error) { 273 + cvh.t.Helper() 274 + 275 + err := operation() 276 + if err == nil { 277 + cvh.t.Errorf("Expected foreign key constraint violation on %s.%s", tableName, columnName) 278 + } 279 + } 280 + 281 + // ContextHelper provides utilities for testing context cancellation and timeouts 282 + type ContextHelper struct { 283 + t *testing.T 284 + } 285 + 286 + // NewContextHelper creates a context helper 287 + func NewContextHelper(t *testing.T) *ContextHelper { 288 + t.Helper() 289 + return &ContextHelper{t: t} 290 + } 291 + 292 + // CancelledContext returns a pre-cancelled context 293 + // 294 + // Use this to test error handling when operations are called with cancelled context. 295 + // Example from task_repository_test.go: 296 + // 297 + // helper := NewContextHelper(t) 298 + // ctx := helper.CancelledContext() 299 + // _, err := repo.Create(ctx, task) 300 + // if err == nil { 301 + // t.Error("Expected error with cancelled context") 302 + // } 303 + func (ch *ContextHelper) CancelledContext() context.Context { 304 + ch.t.Helper() 305 + ctx, cancel := context.WithCancel(context.Background()) 306 + cancel() 307 + return ctx 308 + } 309 + 310 + // TimeoutContext returns a context that times out after the specified duration 311 + // 312 + // Use this to test timeout handling in long-running operations. 313 + // Pattern: 314 + // 315 + // helper := NewContextHelper(t) 316 + // ctx := helper.TimeoutContext(1 * time.Millisecond) 317 + // // Slow operation that should timeout 318 + func (ch *ContextHelper) TimeoutContext(timeout time.Duration) context.Context { 319 + ch.t.Helper() 320 + ctx, cancel := context.WithTimeout(context.Background(), timeout) 321 + ch.t.Cleanup(cancel) 322 + return ctx 323 + } 324 + 325 + // DeadlineExceededContext returns a context with an already-expired deadline 326 + // 327 + // Use this to test error handling when context deadline is already exceeded. 328 + func (ch *ContextHelper) DeadlineExceededContext() context.Context { 329 + ch.t.Helper() 330 + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second)) 331 + ch.t.Cleanup(cancel) 332 + return ctx 333 + } 334 + 335 + // PlatformSpecific provides utilities for platform-specific testing 336 + type PlatformSpecific struct { 337 + t *testing.T 338 + } 339 + 340 + // NewPlatformSpecific creates platform-specific test helper 341 + func NewPlatformSpecific(t *testing.T) *PlatformSpecific { 342 + t.Helper() 343 + return &PlatformSpecific{t: t} 344 + } 345 + 346 + // SkipOnWindows skips the test if running on Windows 347 + // 348 + // Use this for tests that rely on Unix-specific features like chmod. 349 + func (ps *PlatformSpecific) SkipOnWindows(reason string) { 350 + ps.t.Helper() 351 + if runtime.GOOS == "windows" { 352 + ps.t.Skip(reason) 353 + } 354 + } 355 + 356 + // SkipOnMac skips the test if running on macOS 357 + // 358 + // Use this for tests that have platform-specific behavior on macOS. 359 + func (ps *PlatformSpecific) SkipOnMac(reason string) { 360 + ps.t.Helper() 361 + if runtime.GOOS == "darwin" { 362 + ps.t.Skip(reason) 363 + } 364 + } 365 + 366 + // RunOnlyOn runs the test only on specified platforms 367 + // 368 + // Use this to create platform-specific test cases. 369 + func (ps *PlatformSpecific) RunOnlyOn(platforms []string) { 370 + ps.t.Helper() 371 + if slices.Contains(platforms, runtime.GOOS) { 372 + return 373 + } 374 + ps.t.Skipf("Test only runs on %v, skipping on %s", platforms, runtime.GOOS) 375 + } 376 + 377 + // IsolatedEnvironment creates an isolated test environment with custom dirs 378 + type IsolatedEnvironment struct { 379 + t *testing.T 380 + TempDir string 381 + ConfigDir string 382 + DataDir string 383 + originalGetConfigDir func() (string, error) 384 + originalGetDataDir func() (string, error) 385 + } 386 + 387 + // NewIsolatedEnvironment creates an isolated test environment 388 + // 389 + // Use this to test in complete isolation from the actual system config/data directories. 390 + // Example from database_test.go: 391 + // 392 + // env := NewIsolatedEnvironment(t) 393 + // defer env.Cleanup() 394 + // // All operations will use env.TempDir 395 + func NewIsolatedEnvironment(t *testing.T) *IsolatedEnvironment { 396 + t.Helper() 397 + 398 + tempDir, err := os.MkdirTemp("", "noteleaf-isolated-*") 399 + if err != nil { 400 + t.Fatalf("Failed to create isolated temp directory: %v", err) 401 + } 402 + 403 + env := &IsolatedEnvironment{ 404 + t: t, 405 + TempDir: tempDir, 406 + ConfigDir: tempDir, 407 + DataDir: tempDir, 408 + originalGetConfigDir: GetConfigDir, 409 + originalGetDataDir: GetDataDir, 410 + } 411 + 412 + GetConfigDir = func() (string, error) { 413 + return env.ConfigDir, nil 414 + } 415 + 416 + GetDataDir = func() (string, error) { 417 + return env.DataDir, nil 418 + } 419 + 420 + t.Cleanup(func() { 421 + env.Cleanup() 422 + }) 423 + 424 + return env 425 + } 426 + 427 + // SetConfigDirError configures GetConfigDir to return an error 428 + func (env *IsolatedEnvironment) SetConfigDirError(err error) { 429 + env.t.Helper() 430 + GetConfigDir = func() (string, error) { 431 + return "", err 432 + } 433 + } 434 + 435 + // SetDataDirError configures GetDataDir to return an error 436 + func (env *IsolatedEnvironment) SetDataDirError(err error) { 437 + env.t.Helper() 438 + GetDataDir = func() (string, error) { 439 + return "", err 440 + } 441 + } 442 + 443 + // Cleanup restores original functions and removes temp directory 444 + func (env *IsolatedEnvironment) Cleanup() { 445 + GetConfigDir = env.originalGetConfigDir 446 + GetDataDir = env.originalGetDataDir 447 + os.RemoveAll(env.TempDir) 448 + } 449 + 450 + // AssertError checks that an error occurred 451 + // 452 + // Use this for simple error existence checks. 453 + func AssertError(t *testing.T, err error, msg string) { 454 + t.Helper() 455 + if err == nil { 456 + t.Errorf("%s: expected error but got none", msg) 457 + } 458 + } 459 + 460 + // AssertNoError checks that no error occurred 461 + // 462 + // Use this to verify successful operations. 463 + func AssertNoError(t *testing.T, err error, msg string) { 464 + t.Helper() 465 + if err != nil { 466 + t.Errorf("%s: unexpected error: %v", msg, err) 467 + } 468 + } 469 + 470 + // AssertErrorContains checks that error contains expected substring 471 + // 472 + // Use this to verify specific error messages. 473 + func AssertErrorContains(t *testing.T, err error, expectedSubstring, msg string) { 474 + t.Helper() 475 + if err == nil { 476 + t.Errorf("%s: expected error containing %q but got none", msg, expectedSubstring) 477 + return 478 + } 479 + if expectedSubstring != "" && !contains(err.Error(), expectedSubstring) { 480 + t.Errorf("%s: expected error containing %q, got: %v", msg, expectedSubstring, err) 481 + } 482 + } 483 + 484 + // AssertErrorIs checks that error matches target using [errors.Is] 485 + // 486 + // Use this to verify error types and wrapped errors. 487 + func AssertErrorIs(t *testing.T, err, target error, msg string) { 488 + t.Helper() 489 + if !errors.Is(err, target) { 490 + t.Errorf("%s: expected error to be %v, got: %v", msg, target, err) 491 + } 492 + } 493 + 494 + func contains(haystack, needle string) bool { 495 + return len(haystack) >= len(needle) && 496 + (haystack[len(haystack)-len(needle):] == needle || 497 + haystack[:len(needle)] == needle || 498 + (len(haystack) > len(needle) && func() bool { 499 + for i := 1; i <= len(haystack)-len(needle); i++ { 500 + if haystack[i:i+len(needle)] == needle { 501 + return true 502 + } 503 + } 504 + return false 505 + }())) 506 + }