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

build: persistence layer tests

+1625 -1
+653
internal/models/models_test.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func TestModels(t *testing.T) { 10 + t.Run("Task Model", func(t *testing.T) { 11 + t.Run("Model Interface Implementation", func(t *testing.T) { 12 + task := &Task{ 13 + ID: 1, 14 + UUID: "test-uuid", 15 + Description: "Test task", 16 + Status: "pending", 17 + Entry: time.Now(), 18 + Modified: time.Now(), 19 + } 20 + 21 + if task.GetID() != 1 { 22 + t.Errorf("Expected ID 1, got %d", task.GetID()) 23 + } 24 + 25 + task.SetID(2) 26 + if task.GetID() != 2 { 27 + t.Errorf("Expected ID 2 after SetID, got %d", task.GetID()) 28 + } 29 + 30 + if task.GetTableName() != "tasks" { 31 + t.Errorf("Expected table name 'tasks', got '%s'", task.GetTableName()) 32 + } 33 + 34 + createdAt := time.Now() 35 + task.SetCreatedAt(createdAt) 36 + if !task.GetCreatedAt().Equal(createdAt) { 37 + t.Errorf("Expected created at %v, got %v", createdAt, task.GetCreatedAt()) 38 + } 39 + 40 + updatedAt := time.Now().Add(time.Hour) 41 + task.SetUpdatedAt(updatedAt) 42 + if !task.GetUpdatedAt().Equal(updatedAt) { 43 + t.Errorf("Expected updated at %v, got %v", updatedAt, task.GetUpdatedAt()) 44 + } 45 + }) 46 + 47 + t.Run("Status Methods", func(t *testing.T) { 48 + testCases := []struct { 49 + status string 50 + isCompleted bool 51 + isPending bool 52 + isDeleted bool 53 + }{ 54 + {"pending", false, true, false}, 55 + {"completed", true, false, false}, 56 + {"deleted", false, false, true}, 57 + {"unknown", false, false, false}, 58 + } 59 + 60 + for _, tc := range testCases { 61 + task := &Task{Status: tc.status} 62 + 63 + if task.IsCompleted() != tc.isCompleted { 64 + t.Errorf("Status %s: expected IsCompleted %v, got %v", tc.status, tc.isCompleted, task.IsCompleted()) 65 + } 66 + if task.IsPending() != tc.isPending { 67 + t.Errorf("Status %s: expected IsPending %v, got %v", tc.status, tc.isPending, task.IsPending()) 68 + } 69 + if task.IsDeleted() != tc.isDeleted { 70 + t.Errorf("Status %s: expected IsDeleted %v, got %v", tc.status, tc.isDeleted, task.IsDeleted()) 71 + } 72 + } 73 + }) 74 + 75 + t.Run("Priority Methods", func(t *testing.T) { 76 + task := &Task{} 77 + 78 + if task.HasPriority() { 79 + t.Error("Task with empty priority should return false for HasPriority") 80 + } 81 + 82 + task.Priority = "A" 83 + if !task.HasPriority() { 84 + t.Error("Task with priority should return true for HasPriority") 85 + } 86 + }) 87 + 88 + t.Run("Tags Marshaling", func(t *testing.T) { 89 + task := &Task{} 90 + 91 + result, err := task.MarshalTags() 92 + if err != nil { 93 + t.Fatalf("MarshalTags failed: %v", err) 94 + } 95 + if result != "" { 96 + t.Errorf("Expected empty string for empty tags, got '%s'", result) 97 + } 98 + 99 + task.Tags = []string{"work", "urgent", "project-x"} 100 + result, err = task.MarshalTags() 101 + if err != nil { 102 + t.Fatalf("MarshalTags failed: %v", err) 103 + } 104 + 105 + expected := `["work","urgent","project-x"]` 106 + if result != expected { 107 + t.Errorf("Expected %s, got %s", expected, result) 108 + } 109 + 110 + newTask := &Task{} 111 + err = newTask.UnmarshalTags(result) 112 + if err != nil { 113 + t.Fatalf("UnmarshalTags failed: %v", err) 114 + } 115 + 116 + if len(newTask.Tags) != 3 { 117 + t.Errorf("Expected 3 tags, got %d", len(newTask.Tags)) 118 + } 119 + if newTask.Tags[0] != "work" || newTask.Tags[1] != "urgent" || newTask.Tags[2] != "project-x" { 120 + t.Errorf("Tags not unmarshaled correctly: %v", newTask.Tags) 121 + } 122 + 123 + emptyTask := &Task{} 124 + err = emptyTask.UnmarshalTags("") 125 + if err != nil { 126 + t.Fatalf("UnmarshalTags with empty string failed: %v", err) 127 + } 128 + if emptyTask.Tags != nil { 129 + t.Error("Expected nil tags for empty string") 130 + } 131 + }) 132 + 133 + t.Run("Annotations Marshaling", func(t *testing.T) { 134 + task := &Task{} 135 + 136 + result, err := task.MarshalAnnotations() 137 + if err != nil { 138 + t.Fatalf("MarshalAnnotations failed: %v", err) 139 + } 140 + if result != "" { 141 + t.Errorf("Expected empty string for empty annotations, got '%s'", result) 142 + } 143 + 144 + task.Annotations = []string{"Note 1", "Note 2", "Important reminder"} 145 + result, err = task.MarshalAnnotations() 146 + if err != nil { 147 + t.Fatalf("MarshalAnnotations failed: %v", err) 148 + } 149 + 150 + expected := `["Note 1","Note 2","Important reminder"]` 151 + if result != expected { 152 + t.Errorf("Expected %s, got %s", expected, result) 153 + } 154 + 155 + newTask := &Task{} 156 + err = newTask.UnmarshalAnnotations(result) 157 + if err != nil { 158 + t.Fatalf("UnmarshalAnnotations failed: %v", err) 159 + } 160 + 161 + if len(newTask.Annotations) != 3 { 162 + t.Errorf("Expected 3 annotations, got %d", len(newTask.Annotations)) 163 + } 164 + if newTask.Annotations[0] != "Note 1" || newTask.Annotations[1] != "Note 2" || newTask.Annotations[2] != "Important reminder" { 165 + t.Errorf("Annotations not unmarshaled correctly: %v", newTask.Annotations) 166 + } 167 + 168 + emptyTask := &Task{} 169 + err = emptyTask.UnmarshalAnnotations("") 170 + if err != nil { 171 + t.Fatalf("UnmarshalAnnotations with empty string failed: %v", err) 172 + } 173 + if emptyTask.Annotations != nil { 174 + t.Error("Expected nil annotations for empty string") 175 + } 176 + }) 177 + 178 + t.Run("JSON Marshaling", func(t *testing.T) { 179 + now := time.Now() 180 + due := now.Add(24 * time.Hour) 181 + task := &Task{ 182 + ID: 1, 183 + UUID: "test-uuid", 184 + Description: "Test task", 185 + Status: "pending", 186 + Priority: "A", 187 + Project: "test-project", 188 + Tags: []string{"work", "urgent"}, 189 + Due: &due, 190 + Entry: now, 191 + Modified: now, 192 + Annotations: []string{"Note 1"}, 193 + } 194 + 195 + data, err := json.Marshal(task) 196 + if err != nil { 197 + t.Fatalf("JSON marshal failed: %v", err) 198 + } 199 + 200 + var unmarshaled Task 201 + err = json.Unmarshal(data, &unmarshaled) 202 + if err != nil { 203 + t.Fatalf("JSON unmarshal failed: %v", err) 204 + } 205 + 206 + if unmarshaled.ID != task.ID { 207 + t.Errorf("Expected ID %d, got %d", task.ID, unmarshaled.ID) 208 + } 209 + if unmarshaled.UUID != task.UUID { 210 + t.Errorf("Expected UUID %s, got %s", task.UUID, unmarshaled.UUID) 211 + } 212 + if unmarshaled.Description != task.Description { 213 + t.Errorf("Expected description %s, got %s", task.Description, unmarshaled.Description) 214 + } 215 + }) 216 + }) 217 + 218 + t.Run("Movie Model", func(t *testing.T) { 219 + t.Run("Model Interface Implementation", func(t *testing.T) { 220 + movie := &Movie{ 221 + ID: 1, 222 + Title: "Test Movie", 223 + Year: 2023, 224 + Added: time.Now(), 225 + } 226 + 227 + if movie.GetID() != 1 { 228 + t.Errorf("Expected ID 1, got %d", movie.GetID()) 229 + } 230 + 231 + movie.SetID(2) 232 + if movie.GetID() != 2 { 233 + t.Errorf("Expected ID 2 after SetID, got %d", movie.GetID()) 234 + } 235 + 236 + if movie.GetTableName() != "movies" { 237 + t.Errorf("Expected table name 'movies', got '%s'", movie.GetTableName()) 238 + } 239 + 240 + createdAt := time.Now() 241 + movie.SetCreatedAt(createdAt) 242 + if !movie.GetCreatedAt().Equal(createdAt) { 243 + t.Errorf("Expected created at %v, got %v", createdAt, movie.GetCreatedAt()) 244 + } 245 + 246 + updatedAt := time.Now().Add(time.Hour) 247 + movie.SetUpdatedAt(updatedAt) 248 + if !movie.GetUpdatedAt().Equal(updatedAt) { 249 + t.Errorf("Expected updated at %v, got %v", updatedAt, movie.GetUpdatedAt()) 250 + } 251 + }) 252 + 253 + t.Run("Status Methods", func(t *testing.T) { 254 + testCases := []struct { 255 + status string 256 + isWatched bool 257 + isQueued bool 258 + }{ 259 + {"queued", false, true}, 260 + {"watched", true, false}, 261 + {"removed", false, false}, 262 + {"unknown", false, false}, 263 + } 264 + 265 + for _, tc := range testCases { 266 + movie := &Movie{Status: tc.status} 267 + 268 + if movie.IsWatched() != tc.isWatched { 269 + t.Errorf("Status %s: expected IsWatched %v, got %v", tc.status, tc.isWatched, movie.IsWatched()) 270 + } 271 + if movie.IsQueued() != tc.isQueued { 272 + t.Errorf("Status %s: expected IsQueued %v, got %v", tc.status, tc.isQueued, movie.IsQueued()) 273 + } 274 + } 275 + }) 276 + 277 + t.Run("JSON Marshaling", func(t *testing.T) { 278 + now := time.Now() 279 + watched := now.Add(-24 * time.Hour) 280 + movie := &Movie{ 281 + ID: 1, 282 + Title: "Test Movie", 283 + Year: 2023, 284 + Status: "watched", 285 + Rating: 8.5, 286 + Notes: "Great movie!", 287 + Added: now, 288 + Watched: &watched, 289 + } 290 + 291 + data, err := json.Marshal(movie) 292 + if err != nil { 293 + t.Fatalf("JSON marshal failed: %v", err) 294 + } 295 + 296 + var unmarshaled Movie 297 + err = json.Unmarshal(data, &unmarshaled) 298 + if err != nil { 299 + t.Fatalf("JSON unmarshal failed: %v", err) 300 + } 301 + 302 + if unmarshaled.ID != movie.ID { 303 + t.Errorf("Expected ID %d, got %d", movie.ID, unmarshaled.ID) 304 + } 305 + if unmarshaled.Title != movie.Title { 306 + t.Errorf("Expected title %s, got %s", movie.Title, unmarshaled.Title) 307 + } 308 + if unmarshaled.Rating != movie.Rating { 309 + t.Errorf("Expected rating %f, got %f", movie.Rating, unmarshaled.Rating) 310 + } 311 + }) 312 + }) 313 + 314 + t.Run("TV Show Model", func(t *testing.T) { 315 + t.Run("Model Interface Implementation", func(t *testing.T) { 316 + tvShow := &TVShow{ 317 + ID: 1, 318 + Title: "Test Show", 319 + Added: time.Now(), 320 + } 321 + 322 + if tvShow.GetID() != 1 { 323 + t.Errorf("Expected ID 1, got %d", tvShow.GetID()) 324 + } 325 + 326 + tvShow.SetID(2) 327 + if tvShow.GetID() != 2 { 328 + t.Errorf("Expected ID 2 after SetID, got %d", tvShow.GetID()) 329 + } 330 + 331 + if tvShow.GetTableName() != "tv_shows" { 332 + t.Errorf("Expected table name 'tv_shows', got '%s'", tvShow.GetTableName()) 333 + } 334 + 335 + createdAt := time.Now() 336 + tvShow.SetCreatedAt(createdAt) 337 + if !tvShow.GetCreatedAt().Equal(createdAt) { 338 + t.Errorf("Expected created at %v, got %v", createdAt, tvShow.GetCreatedAt()) 339 + } 340 + 341 + updatedAt := time.Now().Add(time.Hour) 342 + tvShow.SetUpdatedAt(updatedAt) 343 + if !tvShow.GetUpdatedAt().Equal(updatedAt) { 344 + t.Errorf("Expected updated at %v, got %v", updatedAt, tvShow.GetUpdatedAt()) 345 + } 346 + }) 347 + 348 + t.Run("Status Methods", func(t *testing.T) { 349 + testCases := []struct { 350 + status string 351 + isWatching bool 352 + isWatched bool 353 + isQueued bool 354 + }{ 355 + {"queued", false, false, true}, 356 + {"watching", true, false, false}, 357 + {"watched", false, true, false}, 358 + {"removed", false, false, false}, 359 + {"unknown", false, false, false}, 360 + } 361 + 362 + for _, tc := range testCases { 363 + tvShow := &TVShow{Status: tc.status} 364 + 365 + if tvShow.IsWatching() != tc.isWatching { 366 + t.Errorf("Status %s: expected IsWatching %v, got %v", tc.status, tc.isWatching, tvShow.IsWatching()) 367 + } 368 + if tvShow.IsWatched() != tc.isWatched { 369 + t.Errorf("Status %s: expected IsWatched %v, got %v", tc.status, tc.isWatched, tvShow.IsWatched()) 370 + } 371 + if tvShow.IsQueued() != tc.isQueued { 372 + t.Errorf("Status %s: expected IsQueued %v, got %v", tc.status, tc.isQueued, tvShow.IsQueued()) 373 + } 374 + } 375 + }) 376 + 377 + t.Run("JSON Marshaling", func(t *testing.T) { 378 + now := time.Now() 379 + lastWatched := now.Add(-24 * time.Hour) 380 + tvShow := &TVShow{ 381 + ID: 1, 382 + Title: "Test Show", 383 + Season: 1, 384 + Episode: 5, 385 + Status: "watching", 386 + Rating: 9.0, 387 + Notes: "Amazing series!", 388 + Added: now, 389 + LastWatched: &lastWatched, 390 + } 391 + 392 + data, err := json.Marshal(tvShow) 393 + if err != nil { 394 + t.Fatalf("JSON marshal failed: %v", err) 395 + } 396 + 397 + var unmarshaled TVShow 398 + err = json.Unmarshal(data, &unmarshaled) 399 + if err != nil { 400 + t.Fatalf("JSON unmarshal failed: %v", err) 401 + } 402 + 403 + if unmarshaled.ID != tvShow.ID { 404 + t.Errorf("Expected ID %d, got %d", tvShow.ID, unmarshaled.ID) 405 + } 406 + if unmarshaled.Title != tvShow.Title { 407 + t.Errorf("Expected title %s, got %s", tvShow.Title, unmarshaled.Title) 408 + } 409 + if unmarshaled.Season != tvShow.Season { 410 + t.Errorf("Expected season %d, got %d", tvShow.Season, unmarshaled.Season) 411 + } 412 + if unmarshaled.Episode != tvShow.Episode { 413 + t.Errorf("Expected episode %d, got %d", tvShow.Episode, unmarshaled.Episode) 414 + } 415 + }) 416 + }) 417 + 418 + t.Run("Book Model", func(t *testing.T) { 419 + t.Run("Model Interface Implementation", func(t *testing.T) { 420 + book := &Book{ 421 + ID: 1, 422 + Title: "Test Book", 423 + Added: time.Now(), 424 + } 425 + 426 + if book.GetID() != 1 { 427 + t.Errorf("Expected ID 1, got %d", book.GetID()) 428 + } 429 + 430 + book.SetID(2) 431 + if book.GetID() != 2 { 432 + t.Errorf("Expected ID 2 after SetID, got %d", book.GetID()) 433 + } 434 + 435 + if book.GetTableName() != "books" { 436 + t.Errorf("Expected table name 'books', got '%s'", book.GetTableName()) 437 + } 438 + 439 + createdAt := time.Now() 440 + book.SetCreatedAt(createdAt) 441 + if !book.GetCreatedAt().Equal(createdAt) { 442 + t.Errorf("Expected created at %v, got %v", createdAt, book.GetCreatedAt()) 443 + } 444 + 445 + updatedAt := time.Now().Add(time.Hour) 446 + book.SetUpdatedAt(updatedAt) 447 + if !book.GetUpdatedAt().Equal(updatedAt) { 448 + t.Errorf("Expected updated at %v, got %v", updatedAt, book.GetUpdatedAt()) 449 + } 450 + }) 451 + 452 + t.Run("Status Methods", func(t *testing.T) { 453 + testCases := []struct { 454 + status string 455 + isReading bool 456 + isFinished bool 457 + isQueued bool 458 + }{ 459 + {"queued", false, false, true}, 460 + {"reading", true, false, false}, 461 + {"finished", false, true, false}, 462 + {"removed", false, false, false}, 463 + {"unknown", false, false, false}, 464 + } 465 + 466 + for _, tc := range testCases { 467 + book := &Book{Status: tc.status} 468 + 469 + if book.IsReading() != tc.isReading { 470 + t.Errorf("Status %s: expected IsReading %v, got %v", tc.status, tc.isReading, book.IsReading()) 471 + } 472 + if book.IsFinished() != tc.isFinished { 473 + t.Errorf("Status %s: expected IsFinished %v, got %v", tc.status, tc.isFinished, book.IsFinished()) 474 + } 475 + if book.IsQueued() != tc.isQueued { 476 + t.Errorf("Status %s: expected IsQueued %v, got %v", tc.status, tc.isQueued, book.IsQueued()) 477 + } 478 + } 479 + }) 480 + 481 + t.Run("Progress Methods", func(t *testing.T) { 482 + book := &Book{Progress: 75} 483 + 484 + if book.ProgressPercent() != 75 { 485 + t.Errorf("Expected progress 75%%, got %d%%", book.ProgressPercent()) 486 + } 487 + }) 488 + 489 + t.Run("JSON Marshaling", func(t *testing.T) { 490 + now := time.Now() 491 + started := now.Add(-7 * 24 * time.Hour) 492 + finished := now.Add(-24 * time.Hour) 493 + book := &Book{ 494 + ID: 1, 495 + Title: "Test Book", 496 + Author: "Test Author", 497 + Status: "finished", 498 + Progress: 100, 499 + Pages: 300, 500 + Rating: 4.5, 501 + Notes: "Excellent read!", 502 + Added: now, 503 + Started: &started, 504 + Finished: &finished, 505 + } 506 + 507 + data, err := json.Marshal(book) 508 + if err != nil { 509 + t.Fatalf("JSON marshal failed: %v", err) 510 + } 511 + 512 + var unmarshaled Book 513 + err = json.Unmarshal(data, &unmarshaled) 514 + if err != nil { 515 + t.Fatalf("JSON unmarshal failed: %v", err) 516 + } 517 + 518 + if unmarshaled.ID != book.ID { 519 + t.Errorf("Expected ID %d, got %d", book.ID, unmarshaled.ID) 520 + } 521 + if unmarshaled.Title != book.Title { 522 + t.Errorf("Expected title %s, got %s", book.Title, unmarshaled.Title) 523 + } 524 + if unmarshaled.Author != book.Author { 525 + t.Errorf("Expected author %s, got %s", book.Author, unmarshaled.Author) 526 + } 527 + if unmarshaled.Progress != book.Progress { 528 + t.Errorf("Expected progress %d, got %d", book.Progress, unmarshaled.Progress) 529 + } 530 + if unmarshaled.Pages != book.Pages { 531 + t.Errorf("Expected pages %d, got %d", book.Pages, unmarshaled.Pages) 532 + } 533 + }) 534 + }) 535 + 536 + t.Run("Interface Implementations", func(t *testing.T) { 537 + t.Run("All models implement Model interface", func(t *testing.T) { 538 + var models []Model 539 + 540 + task := &Task{} 541 + movie := &Movie{} 542 + tvShow := &TVShow{} 543 + book := &Book{} 544 + 545 + models = append(models, task, movie, tvShow, book) 546 + 547 + if len(models) != 4 { 548 + t.Errorf("Expected 4 models, got %d", len(models)) 549 + } 550 + 551 + // Test that all models have the required methods 552 + for i, model := range models { 553 + // Test ID methods 554 + model.SetID(int64(i + 1)) 555 + if model.GetID() != int64(i+1) { 556 + t.Errorf("Model %d: ID not set correctly", i) 557 + } 558 + 559 + // Test table name method 560 + tableName := model.GetTableName() 561 + if tableName == "" { 562 + t.Errorf("Model %d: table name should not be empty", i) 563 + } 564 + 565 + // Test timestamp methods 566 + now := time.Now() 567 + model.SetCreatedAt(now) 568 + model.SetUpdatedAt(now) 569 + 570 + // Note: We don't test exact equality due to potential precision differences 571 + if model.GetCreatedAt().IsZero() { 572 + t.Errorf("Model %d: created at should not be zero", i) 573 + } 574 + if model.GetUpdatedAt().IsZero() { 575 + t.Errorf("Model %d: updated at should not be zero", i) 576 + } 577 + } 578 + }) 579 + }) 580 + 581 + t.Run("Errors & Edge cases", func(t *testing.T) { 582 + t.Run("Marshaling Errors", func(t *testing.T) { 583 + t.Run("UnmarshalTags handles invalid JSON", func(t *testing.T) { 584 + task := &Task{} 585 + err := task.UnmarshalTags(`{"invalid": "json"}`) 586 + if err == nil { 587 + t.Error("Expected error for invalid JSON, got nil") 588 + } 589 + }) 590 + 591 + t.Run("UnmarshalAnnotations handles invalid JSON", func(t *testing.T) { 592 + task := &Task{} 593 + err := task.UnmarshalAnnotations(`{"invalid": "json"}`) 594 + if err == nil { 595 + t.Error("Expected error for invalid JSON, got nil") 596 + } 597 + }) 598 + }) 599 + }) 600 + 601 + t.Run("Edge Cases", func(t *testing.T) { 602 + t.Run("Task with nil slices", func(t *testing.T) { 603 + task := &Task{ 604 + Tags: nil, 605 + Annotations: nil, 606 + } 607 + 608 + tagsJSON, err := task.MarshalTags() 609 + if err != nil { 610 + t.Errorf("MarshalTags with nil slice failed: %v", err) 611 + } 612 + if tagsJSON != "" { 613 + t.Errorf("Expected empty string for nil tags, got '%s'", tagsJSON) 614 + } 615 + 616 + annotationsJSON, err := task.MarshalAnnotations() 617 + if err != nil { 618 + t.Errorf("MarshalAnnotations with nil slice failed: %v", err) 619 + } 620 + if annotationsJSON != "" { 621 + t.Errorf("Expected empty string for nil annotations, got '%s'", annotationsJSON) 622 + } 623 + }) 624 + 625 + t.Run("Models with zero values", func(t *testing.T) { 626 + task := &Task{} 627 + movie := &Movie{} 628 + tvShow := &TVShow{} 629 + book := &Book{} 630 + 631 + // Test that zero values don't cause panics 632 + if task.IsCompleted() || task.IsPending() || task.IsDeleted() { 633 + t.Error("Zero value task should have false status methods") 634 + } 635 + 636 + if movie.IsWatched() || movie.IsQueued() { 637 + t.Error("Zero value movie should have false status methods") 638 + } 639 + 640 + if tvShow.IsWatching() || tvShow.IsWatched() || tvShow.IsQueued() { 641 + t.Error("Zero value TV show should have false status methods") 642 + } 643 + 644 + if book.IsReading() || book.IsFinished() || book.IsQueued() { 645 + t.Error("Zero value book should have false status methods") 646 + } 647 + 648 + if book.ProgressPercent() != 0 { 649 + t.Errorf("Zero value book should have 0%% progress, got %d%%", book.ProgressPercent()) 650 + } 651 + }) 652 + }) 653 + }
+347
internal/store/config_test.go
··· 1 + package store 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "runtime" 7 + "testing" 8 + ) 9 + 10 + func TestDefaultConfig(t *testing.T) { 11 + config := DefaultConfig() 12 + 13 + if config == nil { 14 + t.Fatal("DefaultConfig should not return nil") 15 + } 16 + 17 + expectedDefaults := map[string]interface{}{ 18 + "DateFormat": "2006-01-02", 19 + "ColorScheme": "default", 20 + "DefaultView": "list", 21 + "AutoArchive": false, 22 + "SyncEnabled": false, 23 + "ExportFormat": "json", 24 + } 25 + 26 + if config.DateFormat != expectedDefaults["DateFormat"] { 27 + t.Errorf("Expected DateFormat %s, got %s", expectedDefaults["DateFormat"], config.DateFormat) 28 + } 29 + if config.ColorScheme != expectedDefaults["ColorScheme"] { 30 + t.Errorf("Expected ColorScheme %s, got %s", expectedDefaults["ColorScheme"], config.ColorScheme) 31 + } 32 + if config.DefaultView != expectedDefaults["DefaultView"] { 33 + t.Errorf("Expected DefaultView %s, got %s", expectedDefaults["DefaultView"], config.DefaultView) 34 + } 35 + if config.AutoArchive != expectedDefaults["AutoArchive"] { 36 + t.Errorf("Expected AutoArchive %v, got %v", expectedDefaults["AutoArchive"], config.AutoArchive) 37 + } 38 + if config.SyncEnabled != expectedDefaults["SyncEnabled"] { 39 + t.Errorf("Expected SyncEnabled %v, got %v", expectedDefaults["SyncEnabled"], config.SyncEnabled) 40 + } 41 + if config.ExportFormat != expectedDefaults["ExportFormat"] { 42 + t.Errorf("Expected ExportFormat %s, got %s", expectedDefaults["ExportFormat"], config.ExportFormat) 43 + } 44 + } 45 + 46 + func TestConfigOperations(t *testing.T) { 47 + tempDir, err := os.MkdirTemp("", "noteleaf-config-test-*") 48 + if err != nil { 49 + t.Fatalf("Failed to create temp directory: %v", err) 50 + } 51 + defer os.RemoveAll(tempDir) 52 + 53 + originalGetConfigDir := GetConfigDir 54 + GetConfigDir = func() (string, error) { 55 + return tempDir, nil 56 + } 57 + defer func() { GetConfigDir = originalGetConfigDir }() 58 + 59 + t.Run("SaveConfig creates config file", func(t *testing.T) { 60 + config := DefaultConfig() 61 + config.ColorScheme = "dark" 62 + config.AutoArchive = true 63 + 64 + err := SaveConfig(config) 65 + if err != nil { 66 + t.Fatalf("SaveConfig failed: %v", err) 67 + } 68 + 69 + configPath := filepath.Join(tempDir, ".noteleaf.conf.toml") 70 + if _, err := os.Stat(configPath); os.IsNotExist(err) { 71 + t.Error("Config file should exist after SaveConfig") 72 + } 73 + }) 74 + 75 + t.Run("LoadConfig reads existing config", func(t *testing.T) { 76 + config, err := LoadConfig() 77 + if err != nil { 78 + t.Fatalf("LoadConfig failed: %v", err) 79 + } 80 + 81 + if config.ColorScheme != "dark" { 82 + t.Errorf("Expected ColorScheme 'dark', got '%s'", config.ColorScheme) 83 + } 84 + if !config.AutoArchive { 85 + t.Error("Expected AutoArchive to be true") 86 + } 87 + }) 88 + 89 + t.Run("LoadConfig creates default when file doesn't exist", func(t *testing.T) { 90 + configPath := filepath.Join(tempDir, ".noteleaf.conf.toml") 91 + os.Remove(configPath) 92 + 93 + config, err := LoadConfig() 94 + if err != nil { 95 + t.Fatalf("LoadConfig failed: %v", err) 96 + } 97 + 98 + if config.ColorScheme != "default" { 99 + t.Errorf("Expected default ColorScheme 'default', got '%s'", config.ColorScheme) 100 + } 101 + if config.AutoArchive { 102 + t.Error("Expected AutoArchive to be false by default") 103 + } 104 + 105 + if _, err := os.Stat(configPath); os.IsNotExist(err) { 106 + t.Error("Config file should be created when it doesn't exist") 107 + } 108 + }) 109 + 110 + t.Run("GetConfigPath returns correct path", func(t *testing.T) { 111 + configPath, err := GetConfigPath() 112 + if err != nil { 113 + t.Fatalf("GetConfigPath failed: %v", err) 114 + } 115 + 116 + expectedPath := filepath.Join(tempDir, ".noteleaf.conf.toml") 117 + if configPath != expectedPath { 118 + t.Errorf("Expected config path %s, got %s", expectedPath, configPath) 119 + } 120 + }) 121 + } 122 + 123 + func TestConfigPersistence(t *testing.T) { 124 + tempDir, err := os.MkdirTemp("", "noteleaf-config-persist-test-*") 125 + if err != nil { 126 + t.Fatalf("Failed to create temp directory: %v", err) 127 + } 128 + defer os.RemoveAll(tempDir) 129 + 130 + originalGetConfigDir := GetConfigDir 131 + GetConfigDir = func() (string, error) { 132 + return tempDir, nil 133 + } 134 + defer func() { GetConfigDir = originalGetConfigDir }() 135 + 136 + t.Run("config values persist across save/load cycles", func(t *testing.T) { 137 + originalConfig := &Config{ 138 + DateFormat: "01/02/2006", 139 + ColorScheme: "custom", 140 + DefaultView: "kanban", 141 + DefaultPriority: "high", 142 + AutoArchive: true, 143 + SyncEnabled: true, 144 + SyncEndpoint: "https://api.example.com", 145 + SyncToken: "secret-token", 146 + ExportFormat: "csv", 147 + MovieAPIKey: "movie-key", 148 + BookAPIKey: "book-key", 149 + } 150 + 151 + err := SaveConfig(originalConfig) 152 + if err != nil { 153 + t.Fatalf("SaveConfig failed: %v", err) 154 + } 155 + 156 + loadedConfig, err := LoadConfig() 157 + if err != nil { 158 + t.Fatalf("LoadConfig failed: %v", err) 159 + } 160 + 161 + if loadedConfig.DateFormat != originalConfig.DateFormat { 162 + t.Errorf("DateFormat not preserved: expected %s, got %s", originalConfig.DateFormat, loadedConfig.DateFormat) 163 + } 164 + if loadedConfig.ColorScheme != originalConfig.ColorScheme { 165 + t.Errorf("ColorScheme not preserved: expected %s, got %s", originalConfig.ColorScheme, loadedConfig.ColorScheme) 166 + } 167 + if loadedConfig.DefaultView != originalConfig.DefaultView { 168 + t.Errorf("DefaultView not preserved: expected %s, got %s", originalConfig.DefaultView, loadedConfig.DefaultView) 169 + } 170 + if loadedConfig.DefaultPriority != originalConfig.DefaultPriority { 171 + t.Errorf("DefaultPriority not preserved: expected %s, got %s", originalConfig.DefaultPriority, loadedConfig.DefaultPriority) 172 + } 173 + if loadedConfig.AutoArchive != originalConfig.AutoArchive { 174 + t.Errorf("AutoArchive not preserved: expected %v, got %v", originalConfig.AutoArchive, loadedConfig.AutoArchive) 175 + } 176 + if loadedConfig.SyncEnabled != originalConfig.SyncEnabled { 177 + t.Errorf("SyncEnabled not preserved: expected %v, got %v", originalConfig.SyncEnabled, loadedConfig.SyncEnabled) 178 + } 179 + if loadedConfig.SyncEndpoint != originalConfig.SyncEndpoint { 180 + t.Errorf("SyncEndpoint not preserved: expected %s, got %s", originalConfig.SyncEndpoint, loadedConfig.SyncEndpoint) 181 + } 182 + if loadedConfig.SyncToken != originalConfig.SyncToken { 183 + t.Errorf("SyncToken not preserved: expected %s, got %s", originalConfig.SyncToken, loadedConfig.SyncToken) 184 + } 185 + if loadedConfig.ExportFormat != originalConfig.ExportFormat { 186 + t.Errorf("ExportFormat not preserved: expected %s, got %s", originalConfig.ExportFormat, loadedConfig.ExportFormat) 187 + } 188 + if loadedConfig.MovieAPIKey != originalConfig.MovieAPIKey { 189 + t.Errorf("MovieAPIKey not preserved: expected %s, got %s", originalConfig.MovieAPIKey, loadedConfig.MovieAPIKey) 190 + } 191 + if loadedConfig.BookAPIKey != originalConfig.BookAPIKey { 192 + t.Errorf("BookAPIKey not preserved: expected %s, got %s", originalConfig.BookAPIKey, loadedConfig.BookAPIKey) 193 + } 194 + }) 195 + } 196 + 197 + func TestConfigErrorHandling(t *testing.T) { 198 + t.Run("LoadConfig handles invalid TOML", func(t *testing.T) { 199 + tempDir, err := os.MkdirTemp("", "noteleaf-config-error-test-*") 200 + if err != nil { 201 + t.Fatalf("Failed to create temp directory: %v", err) 202 + } 203 + defer os.RemoveAll(tempDir) 204 + 205 + originalGetConfigDir := GetConfigDir 206 + GetConfigDir = func() (string, error) { 207 + return tempDir, nil 208 + } 209 + defer func() { GetConfigDir = originalGetConfigDir }() 210 + 211 + configPath := filepath.Join(tempDir, ".noteleaf.conf.toml") 212 + invalidTOML := `[invalid toml content` 213 + err = os.WriteFile(configPath, []byte(invalidTOML), 0644) 214 + if err != nil { 215 + t.Fatalf("Failed to write invalid TOML: %v", err) 216 + } 217 + 218 + _, err = LoadConfig() 219 + if err == nil { 220 + t.Error("LoadConfig should fail with invalid TOML") 221 + } 222 + }) 223 + 224 + t.Run("SaveConfig handles directory creation failure", func(t *testing.T) { 225 + originalGetConfigDir := GetConfigDir 226 + GetConfigDir = func() (string, error) { 227 + return "/invalid/path/that/cannot/be/created", nil 228 + } 229 + defer func() { GetConfigDir = originalGetConfigDir }() 230 + 231 + config := DefaultConfig() 232 + err := SaveConfig(config) 233 + if err == nil { 234 + t.Error("SaveConfig should fail when config directory cannot be accessed") 235 + } 236 + }) 237 + 238 + t.Run("GetConfigPath handles GetConfigDir error", func(t *testing.T) { 239 + originalGetConfigDir := GetConfigDir 240 + GetConfigDir = func() (string, error) { 241 + return "", os.ErrPermission 242 + } 243 + defer func() { GetConfigDir = originalGetConfigDir }() 244 + 245 + _, err := GetConfigPath() 246 + if err == nil { 247 + t.Error("GetConfigPath should fail when GetConfigDir fails") 248 + } 249 + }) 250 + } 251 + 252 + func TestGetConfigDir(t *testing.T) { 253 + t.Run("returns correct directory based on OS", func(t *testing.T) { 254 + configDir, err := GetConfigDir() 255 + if err != nil { 256 + t.Fatalf("GetConfigDir failed: %v", err) 257 + } 258 + 259 + if configDir == "" { 260 + t.Error("Config directory should not be empty") 261 + } 262 + 263 + if _, err := os.Stat(configDir); os.IsNotExist(err) { 264 + t.Error("Config directory should be created if it doesn't exist") 265 + } 266 + 267 + if filepath.Base(configDir) != "noteleaf" { 268 + t.Errorf("Config directory should end with 'noteleaf', got: %s", configDir) 269 + } 270 + }) 271 + 272 + t.Run("creates directory if it doesn't exist", func(t *testing.T) { 273 + tempDir, err := os.MkdirTemp("", "noteleaf-test-*") 274 + if err != nil { 275 + t.Fatalf("Failed to create temp directory: %v", err) 276 + } 277 + defer os.RemoveAll(tempDir) 278 + 279 + var originalEnv string 280 + var envVar string 281 + switch runtime.GOOS { 282 + case "windows": 283 + envVar = "APPDATA" 284 + originalEnv = os.Getenv("APPDATA") 285 + os.Setenv("APPDATA", tempDir) 286 + default: 287 + envVar = "XDG_CONFIG_HOME" 288 + originalEnv = os.Getenv("XDG_CONFIG_HOME") 289 + os.Setenv("XDG_CONFIG_HOME", tempDir) 290 + } 291 + defer os.Setenv(envVar, originalEnv) 292 + 293 + configDir, err := GetConfigDir() 294 + if err != nil { 295 + t.Fatalf("GetConfigDir failed: %v", err) 296 + } 297 + 298 + expectedPath := filepath.Join(tempDir, "noteleaf") 299 + if configDir != expectedPath { 300 + t.Errorf("Expected config dir %s, got %s", expectedPath, configDir) 301 + } 302 + 303 + if _, err := os.Stat(configDir); os.IsNotExist(err) { 304 + t.Error("Config directory should be created") 305 + } 306 + }) 307 + 308 + t.Run("handles missing environment variables", func(t *testing.T) { 309 + switch runtime.GOOS { 310 + case "windows": 311 + originalAppData := os.Getenv("APPDATA") 312 + os.Unsetenv("APPDATA") 313 + defer os.Setenv("APPDATA", originalAppData) 314 + 315 + _, err := GetConfigDir() 316 + if err == nil { 317 + t.Error("GetConfigDir should fail when APPDATA is not set on Windows") 318 + } 319 + default: 320 + originalXDG := os.Getenv("XDG_CONFIG_HOME") 321 + originalHome := os.Getenv("HOME") 322 + os.Unsetenv("XDG_CONFIG_HOME") 323 + 324 + tempHome, err := os.MkdirTemp("", "noteleaf-home-test-*") 325 + if err != nil { 326 + t.Fatalf("Failed to create temp home: %v", err) 327 + } 328 + defer os.RemoveAll(tempHome) 329 + os.Setenv("HOME", tempHome) 330 + 331 + defer func() { 332 + os.Setenv("XDG_CONFIG_HOME", originalXDG) 333 + os.Setenv("HOME", originalHome) 334 + }() 335 + 336 + configDir, err := GetConfigDir() 337 + if err != nil { 338 + t.Fatalf("GetConfigDir should work with HOME fallback: %v", err) 339 + } 340 + 341 + expectedPath := filepath.Join(tempHome, ".config", "noteleaf") 342 + if configDir != expectedPath { 343 + t.Errorf("Expected config dir %s, got %s", expectedPath, configDir) 344 + } 345 + } 346 + }) 347 + }
+1 -1
internal/store/database.go
··· 21 21 } 22 22 23 23 // GetConfigDir returns the appropriate configuration directory based on the OS 24 - func GetConfigDir() (string, error) { 24 + var GetConfigDir = func() (string, error) { 25 25 var configDir string 26 26 27 27 switch runtime.GOOS {
+231
internal/store/database_test.go
··· 1 + package store 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + ) 8 + 9 + func TestNewDatabase(t *testing.T) { 10 + tempDir, err := os.MkdirTemp("", "noteleaf-db-test-*") 11 + if err != nil { 12 + t.Fatalf("Failed to create temp directory: %v", err) 13 + } 14 + defer os.RemoveAll(tempDir) 15 + 16 + originalGetConfigDir := GetConfigDir 17 + GetConfigDir = func() (string, error) { 18 + return tempDir, nil 19 + } 20 + defer func() { GetConfigDir = originalGetConfigDir }() 21 + 22 + t.Run("creates database successfully", func(t *testing.T) { 23 + db, err := NewDatabase() 24 + if err != nil { 25 + t.Fatalf("NewDatabase failed: %v", err) 26 + } 27 + defer db.Close() 28 + 29 + if db == nil { 30 + t.Fatal("Database should not be nil") 31 + } 32 + 33 + dbPath := filepath.Join(tempDir, "noteleaf.db") 34 + if _, err := os.Stat(dbPath); os.IsNotExist(err) { 35 + t.Error("Database file should exist") 36 + } 37 + 38 + if db.GetPath() != dbPath { 39 + t.Errorf("Expected database path %s, got %s", dbPath, db.GetPath()) 40 + } 41 + }) 42 + 43 + t.Run("enables foreign keys", func(t *testing.T) { 44 + db, err := NewDatabase() 45 + if err != nil { 46 + t.Fatalf("NewDatabase failed: %v", err) 47 + } 48 + defer db.Close() 49 + 50 + var foreignKeys int 51 + err = db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys) 52 + if err != nil { 53 + t.Fatalf("Failed to check foreign keys: %v", err) 54 + } 55 + 56 + if foreignKeys != 1 { 57 + t.Error("Foreign keys should be enabled") 58 + } 59 + }) 60 + 61 + t.Run("enables WAL mode", func(t *testing.T) { 62 + db, err := NewDatabase() 63 + if err != nil { 64 + t.Fatalf("NewDatabase failed: %v", err) 65 + } 66 + defer db.Close() 67 + 68 + var journalMode string 69 + err = db.QueryRow("PRAGMA journal_mode").Scan(&journalMode) 70 + if err != nil { 71 + t.Fatalf("Failed to check journal mode: %v", err) 72 + } 73 + 74 + if journalMode != "wal" { 75 + t.Errorf("Expected WAL journal mode, got %s", journalMode) 76 + } 77 + }) 78 + 79 + t.Run("runs migrations", func(t *testing.T) { 80 + db, err := NewDatabase() 81 + if err != nil { 82 + t.Fatalf("NewDatabase failed: %v", err) 83 + } 84 + defer db.Close() 85 + 86 + var count int 87 + err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&count) 88 + if err != nil { 89 + t.Fatalf("Failed to check migrations table: %v", err) 90 + } 91 + 92 + if count != 1 { 93 + t.Error("Migrations table should exist") 94 + } 95 + 96 + var migrationCount int 97 + err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&migrationCount) 98 + if err != nil { 99 + t.Fatalf("Failed to count migrations: %v", err) 100 + } 101 + 102 + if migrationCount == 0 { 103 + t.Error("At least one migration should be applied") 104 + } 105 + }) 106 + 107 + t.Run("creates migration runner", func(t *testing.T) { 108 + db, err := NewDatabase() 109 + if err != nil { 110 + t.Fatalf("NewDatabase failed: %v", err) 111 + } 112 + defer db.Close() 113 + 114 + runner := db.NewMigrationRunner() 115 + if runner == nil { 116 + t.Error("Migration runner should not be nil") 117 + } 118 + }) 119 + 120 + t.Run("closes database connection", func(t *testing.T) { 121 + db, err := NewDatabase() 122 + if err != nil { 123 + t.Fatalf("NewDatabase failed: %v", err) 124 + } 125 + 126 + err = db.Close() 127 + if err != nil { 128 + t.Errorf("Close should not return error: %v", err) 129 + } 130 + 131 + err = db.Ping() 132 + if err == nil { 133 + t.Error("Database should be closed and ping should fail") 134 + } 135 + }) 136 + } 137 + 138 + func TestDatabaseErrorHandling(t *testing.T) { 139 + t.Run("handles GetConfigDir error", func(t *testing.T) { 140 + originalGetConfigDir := GetConfigDir 141 + GetConfigDir = func() (string, error) { 142 + return "", os.ErrPermission 143 + } 144 + defer func() { GetConfigDir = originalGetConfigDir }() 145 + 146 + _, err := NewDatabase() 147 + if err == nil { 148 + t.Error("NewDatabase should fail when GetConfigDir fails") 149 + } 150 + }) 151 + 152 + t.Run("handles invalid database path", func(t *testing.T) { 153 + originalGetConfigDir := GetConfigDir 154 + GetConfigDir = func() (string, error) { 155 + return "/invalid/path/that/does/not/exist", nil 156 + } 157 + defer func() { GetConfigDir = originalGetConfigDir }() 158 + 159 + _, err := NewDatabase() 160 + if err == nil { 161 + t.Error("NewDatabase should fail with invalid database path") 162 + } 163 + }) 164 + } 165 + 166 + func TestDatabaseIntegration(t *testing.T) { 167 + tempDir, err := os.MkdirTemp("", "noteleaf-db-integration-test-*") 168 + if err != nil { 169 + t.Fatalf("Failed to create temp directory: %v", err) 170 + } 171 + defer os.RemoveAll(tempDir) 172 + 173 + originalGetConfigDir := GetConfigDir 174 + GetConfigDir = func() (string, error) { 175 + return tempDir, nil 176 + } 177 + defer func() { GetConfigDir = originalGetConfigDir }() 178 + 179 + t.Run("multiple database instances use same file", func(t *testing.T) { 180 + db1, err := NewDatabase() 181 + if err != nil { 182 + t.Fatalf("First NewDatabase failed: %v", err) 183 + } 184 + defer db1.Close() 185 + 186 + db2, err := NewDatabase() 187 + if err != nil { 188 + t.Fatalf("Second NewDatabase failed: %v", err) 189 + } 190 + defer db2.Close() 191 + 192 + if db1.GetPath() != db2.GetPath() { 193 + t.Error("Both database instances should use the same file path") 194 + } 195 + }) 196 + 197 + t.Run("database survives connection close and reopen", func(t *testing.T) { 198 + db1, err := NewDatabase() 199 + if err != nil { 200 + t.Fatalf("NewDatabase failed: %v", err) 201 + } 202 + 203 + _, err = db1.Exec("CREATE TABLE IF NOT EXISTS test_table (id INTEGER PRIMARY KEY, name TEXT)") 204 + if err != nil { 205 + t.Fatalf("Failed to create test table: %v", err) 206 + } 207 + 208 + _, err = db1.Exec("INSERT INTO test_table (name) VALUES (?)", "test_value") 209 + if err != nil { 210 + t.Fatalf("Failed to insert test data: %v", err) 211 + } 212 + 213 + db1.Close() 214 + 215 + db2, err := NewDatabase() 216 + if err != nil { 217 + t.Fatalf("Second NewDatabase failed: %v", err) 218 + } 219 + defer db2.Close() 220 + 221 + var name string 222 + err = db2.QueryRow("SELECT name FROM test_table WHERE id = 1").Scan(&name) 223 + if err != nil { 224 + t.Fatalf("Failed to query test data: %v", err) 225 + } 226 + 227 + if name != "test_value" { 228 + t.Errorf("Expected 'test_value', got '%s'", name) 229 + } 230 + }) 231 + }
+393
internal/store/migration_test.go
··· 1 + package store 2 + 3 + import ( 4 + "database/sql" 5 + "embed" 6 + "testing" 7 + 8 + _ "github.com/mattn/go-sqlite3" 9 + ) 10 + 11 + //go:embed sql/migrations 12 + var testMigrationFiles embed.FS 13 + 14 + func createTestDB(t *testing.T) *sql.DB { 15 + db, err := sql.Open("sqlite3", ":memory:") 16 + if err != nil { 17 + t.Fatalf("Failed to create in-memory database: %v", err) 18 + } 19 + 20 + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { 21 + t.Fatalf("Failed to enable foreign keys: %v", err) 22 + } 23 + 24 + t.Cleanup(func() { 25 + db.Close() 26 + }) 27 + 28 + return db 29 + } 30 + 31 + func TestNewMigrationRunner(t *testing.T) { 32 + db := createTestDB(t) 33 + 34 + runner := NewMigrationRunner(db, testMigrationFiles) 35 + if runner == nil { 36 + t.Fatal("NewMigrationRunner should not return nil") 37 + } 38 + 39 + if runner.db != db { 40 + t.Error("Migration runner should store the database reference") 41 + } 42 + } 43 + 44 + func TestMigrationRunner_RunMigrations(t *testing.T) { 45 + t.Run("runs migrations successfully", func(t *testing.T) { 46 + db := createTestDB(t) 47 + runner := NewMigrationRunner(db, testMigrationFiles) 48 + 49 + err := runner.RunMigrations() 50 + if err != nil { 51 + t.Fatalf("RunMigrations failed: %v", err) 52 + } 53 + 54 + var count int 55 + err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='migrations'").Scan(&count) 56 + if err != nil { 57 + t.Fatalf("Failed to check migrations table: %v", err) 58 + } 59 + 60 + if count != 1 { 61 + t.Error("Migrations table should exist after running migrations") 62 + } 63 + 64 + var migrationCount int 65 + err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&migrationCount) 66 + if err != nil { 67 + t.Fatalf("Failed to count applied migrations: %v", err) 68 + } 69 + 70 + if migrationCount == 0 { 71 + t.Error("At least one migration should be applied") 72 + } 73 + }) 74 + 75 + t.Run("skips already applied migrations", func(t *testing.T) { 76 + db := createTestDB(t) 77 + runner := NewMigrationRunner(db, testMigrationFiles) 78 + 79 + err := runner.RunMigrations() 80 + if err != nil { 81 + t.Fatalf("First RunMigrations failed: %v", err) 82 + } 83 + 84 + var initialCount int 85 + err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&initialCount) 86 + if err != nil { 87 + t.Fatalf("Failed to count migrations: %v", err) 88 + } 89 + 90 + err = runner.RunMigrations() 91 + if err != nil { 92 + t.Fatalf("Second RunMigrations failed: %v", err) 93 + } 94 + 95 + var finalCount int 96 + err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&finalCount) 97 + if err != nil { 98 + t.Fatalf("Failed to count migrations after second run: %v", err) 99 + } 100 + 101 + if finalCount != initialCount { 102 + t.Errorf("Expected %d migrations, got %d (migrations should not be re-applied)", initialCount, finalCount) 103 + } 104 + }) 105 + 106 + t.Run("creates expected tables", func(t *testing.T) { 107 + db := createTestDB(t) 108 + runner := NewMigrationRunner(db, testMigrationFiles) 109 + 110 + err := runner.RunMigrations() 111 + if err != nil { 112 + t.Fatalf("RunMigrations failed: %v", err) 113 + } 114 + 115 + expectedTables := []string{"migrations", "tasks", "movies", "tv_shows", "books"} 116 + 117 + for _, tableName := range expectedTables { 118 + var count int 119 + err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", tableName).Scan(&count) 120 + if err != nil { 121 + t.Fatalf("Failed to check table %s: %v", tableName, err) 122 + } 123 + 124 + if count != 1 { 125 + t.Errorf("Table %s should exist after migrations", tableName) 126 + } 127 + } 128 + }) 129 + } 130 + 131 + func TestMigrationRunner_GetAppliedMigrations(t *testing.T) { 132 + t.Run("returns empty list when no migrations table", func(t *testing.T) { 133 + db := createTestDB(t) 134 + runner := NewMigrationRunner(db, testMigrationFiles) 135 + 136 + migrations, err := runner.GetAppliedMigrations() 137 + if err != nil { 138 + t.Fatalf("GetAppliedMigrations failed: %v", err) 139 + } 140 + 141 + if len(migrations) != 0 { 142 + t.Errorf("Expected 0 migrations, got %d", len(migrations)) 143 + } 144 + }) 145 + 146 + t.Run("returns applied migrations", func(t *testing.T) { 147 + db := createTestDB(t) 148 + runner := NewMigrationRunner(db, testMigrationFiles) 149 + 150 + // Run migrations first 151 + err := runner.RunMigrations() 152 + if err != nil { 153 + t.Fatalf("RunMigrations failed: %v", err) 154 + } 155 + 156 + migrations, err := runner.GetAppliedMigrations() 157 + if err != nil { 158 + t.Fatalf("GetAppliedMigrations failed: %v", err) 159 + } 160 + 161 + if len(migrations) == 0 { 162 + t.Error("Should have applied migrations") 163 + } 164 + 165 + for _, migration := range migrations { 166 + if migration.Version == "" { 167 + t.Error("Migration version should not be empty") 168 + } 169 + if !migration.Applied { 170 + t.Error("Migration should be marked as applied") 171 + } 172 + if migration.AppliedAt == "" { 173 + t.Error("Migration should have applied timestamp") 174 + } 175 + } 176 + 177 + for i := 1; i < len(migrations); i++ { 178 + if migrations[i-1].Version > migrations[i].Version { 179 + t.Error("Migrations should be sorted by version") 180 + } 181 + } 182 + }) 183 + } 184 + 185 + func TestMigrationRunner_GetAvailableMigrations(t *testing.T) { 186 + t.Run("returns available migrations from embedded files", func(t *testing.T) { 187 + db := createTestDB(t) 188 + runner := NewMigrationRunner(db, testMigrationFiles) 189 + 190 + migrations, err := runner.GetAvailableMigrations() 191 + if err != nil { 192 + t.Fatalf("GetAvailableMigrations failed: %v", err) 193 + } 194 + 195 + if len(migrations) == 0 { 196 + t.Error("Should have available migrations") 197 + } 198 + 199 + for _, migration := range migrations { 200 + if migration.Version == "" { 201 + t.Error("Migration version should not be empty") 202 + } 203 + if migration.UpSQL == "" { 204 + t.Error("Migration should have up SQL") 205 + } 206 + // Note: Down SQL might be empty for some migrations, so we don't check it 207 + } 208 + 209 + for i := 1; i < len(migrations); i++ { 210 + if migrations[i-1].Version > migrations[i].Version { 211 + t.Error("Migrations should be sorted by version") 212 + } 213 + } 214 + }) 215 + 216 + t.Run("includes both up and down SQL when available", func(t *testing.T) { 217 + db := createTestDB(t) 218 + runner := NewMigrationRunner(db, testMigrationFiles) 219 + 220 + migrations, err := runner.GetAvailableMigrations() 221 + if err != nil { 222 + t.Fatalf("GetAvailableMigrations failed: %v", err) 223 + } 224 + 225 + var foundMigrationWithDown bool 226 + for _, migration := range migrations { 227 + if migration.UpSQL != "" && migration.DownSQL != "" { 228 + foundMigrationWithDown = true 229 + break 230 + } 231 + } 232 + 233 + if !foundMigrationWithDown { 234 + t.Log("Note: No migrations found with both up and down SQL - this may be expected") 235 + } 236 + }) 237 + } 238 + 239 + func TestMigrationRunner_Rollback(t *testing.T) { 240 + t.Run("fails when no migrations to rollback", func(t *testing.T) { 241 + db := createTestDB(t) 242 + runner := NewMigrationRunner(db, testMigrationFiles) 243 + 244 + err := runner.Rollback() 245 + if err == nil { 246 + t.Error("Rollback should fail when no migrations are applied") 247 + } 248 + }) 249 + 250 + t.Run("rolls back last migration", func(t *testing.T) { 251 + db := createTestDB(t) 252 + runner := NewMigrationRunner(db, testMigrationFiles) 253 + 254 + err := runner.RunMigrations() 255 + if err != nil { 256 + t.Fatalf("RunMigrations failed: %v", err) 257 + } 258 + 259 + var initialCount int 260 + err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&initialCount) 261 + if err != nil { 262 + t.Fatalf("Failed to count migrations: %v", err) 263 + } 264 + 265 + if initialCount == 0 { 266 + t.Skip("No migrations to rollback") 267 + } 268 + 269 + err = runner.Rollback() 270 + if err != nil { 271 + t.Fatalf("Rollback failed: %v", err) 272 + } 273 + 274 + var finalCount int 275 + err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&finalCount) 276 + if err != nil { 277 + t.Fatalf("Failed to count migrations after rollback: %v", err) 278 + } 279 + 280 + if finalCount != initialCount-1 { 281 + t.Errorf("Expected %d migrations after rollback, got %d", initialCount-1, finalCount) 282 + } 283 + }) 284 + } 285 + 286 + func TestMigrationHelperFunctions(t *testing.T) { 287 + t.Run("extractVersionFromFilename", func(t *testing.T) { 288 + testCases := []struct { 289 + filename string 290 + expected string 291 + }{ 292 + {"0000_create_migrations_table_up.sql", "0000"}, 293 + {"0001_create_all_tables_up.sql", "0001"}, 294 + {"0002_add_indexes_down.sql", "0002"}, 295 + {"invalid_filename.sql", "invalid"}, 296 + {"", ""}, 297 + } 298 + 299 + for _, tc := range testCases { 300 + result := extractVersionFromFilename(tc.filename) 301 + if result != tc.expected { 302 + t.Errorf("extractVersionFromFilename(%s): expected %s, got %s", tc.filename, tc.expected, result) 303 + } 304 + } 305 + }) 306 + 307 + t.Run("extractNameFromFilename", func(t *testing.T) { 308 + testCases := []struct { 309 + filename string 310 + expected string 311 + }{ 312 + {"0000_create_migrations_table_up.sql", "create_migrations_table"}, 313 + {"0001_create_all_tables_up.sql", "create_all_tables"}, 314 + {"0002_add_indexes_down.sql", "add_indexes"}, 315 + {"invalid_filename.sql", ""}, 316 + {"0003_up.sql", ""}, 317 + {"", ""}, 318 + } 319 + 320 + for _, tc := range testCases { 321 + result := extractNameFromFilename(tc.filename) 322 + if result != tc.expected { 323 + t.Errorf("extractNameFromFilename(%s): expected %s, got %s", tc.filename, tc.expected, result) 324 + } 325 + } 326 + }) 327 + } 328 + 329 + func TestMigrationIntegration(t *testing.T) { 330 + t.Run("full migration lifecycle", func(t *testing.T) { 331 + db := createTestDB(t) 332 + runner := NewMigrationRunner(db, testMigrationFiles) 333 + 334 + available, err := runner.GetAvailableMigrations() 335 + if err != nil { 336 + t.Fatalf("GetAvailableMigrations failed: %v", err) 337 + } 338 + 339 + if len(available) == 0 { 340 + t.Skip("No migrations available for testing") 341 + } 342 + 343 + err = runner.RunMigrations() 344 + if err != nil { 345 + t.Fatalf("RunMigrations failed: %v", err) 346 + } 347 + 348 + applied, err := runner.GetAppliedMigrations() 349 + if err != nil { 350 + t.Fatalf("GetAppliedMigrations failed: %v", err) 351 + } 352 + 353 + if len(applied) == 0 { 354 + t.Error("No migrations were applied") 355 + } 356 + 357 + tables := []string{"tasks", "movies", "tv_shows", "books"} 358 + for _, table := range tables { 359 + var count int 360 + err = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) 361 + if err != nil { 362 + t.Errorf("Failed to query table %s: %v", table, err) 363 + } 364 + } 365 + 366 + if len(applied) > 1 { // Only test rollback if we have more than one migration 367 + err = runner.Rollback() 368 + if err != nil { 369 + t.Logf("Rollback failed (may be expected): %v", err) 370 + } 371 + } 372 + }) 373 + 374 + t.Run("migration runner works with real database", func(t *testing.T) { 375 + db := createTestDB(t) 376 + runner := NewMigrationRunner(db, migrationFiles) 377 + 378 + err := runner.RunMigrations() 379 + if err != nil { 380 + t.Fatalf("RunMigrations with real files failed: %v", err) 381 + } 382 + 383 + var count int 384 + err = db.QueryRow("SELECT COUNT(*) FROM migrations").Scan(&count) 385 + if err != nil { 386 + t.Fatalf("Failed to count real migrations: %v", err) 387 + } 388 + 389 + if count == 0 { 390 + t.Error("Real migrations should be applied") 391 + } 392 + }) 393 + }