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

refactor: model behavioral interface

+722 -90
+4 -5
internal/handlers/books_test.go
··· 261 defer handler.Close() 262 263 ctx := context.Background() 264 - if _, err = handler.repos.Books.Create(ctx, &models.Book{ 265 - Title: "Interactive Test Book", 266 - Author: "Interactive Author", 267 - Status: "completed", 268 - }); err != nil { 269 t.Fatalf("Failed to create test book: %v", err) 270 } 271
··· 261 defer handler.Close() 262 263 ctx := context.Background() 264 + if _, err = handler.repos.Books.Create(ctx, 265 + &models.Book{ 266 + Title: "Interactive Test Book", Author: "Interactive Author", Status: "finished", 267 + }); err != nil { 268 t.Fatalf("Failed to create test book: %v", err) 269 } 270
+52
internal/models/behaviors.go
···
··· 1 + package models 2 + 3 + import "time" 4 + 5 + // Stateful represents entities with status management behavior 6 + // 7 + // Implemented by: [Book], [Movie], [TVShow], [Task] 8 + type Stateful interface { 9 + GetStatus() string 10 + ValidStatuses() []string 11 + } 12 + 13 + // Queueable represents media that can be queued for later consumption 14 + // 15 + // Implemented by: [Book], [Movie], [TVShow] 16 + type Queueable interface { 17 + Stateful 18 + IsQueued() bool 19 + } 20 + 21 + // Completable represents media that can be marked as completed/finished/watched. It tracks completion timestamps for media consumption. 22 + // 23 + // Implemented by: [Book] (finished), [Movie] (watched), [TVShow] (watched) 24 + type Completable interface { 25 + Stateful 26 + IsCompleted() bool 27 + GetCompletionTime() *time.Time 28 + } 29 + 30 + // Progressable represents media with measurable progress tracking 31 + // 32 + // Implemented by: [Book] (percentage-based reading progress) 33 + type Progressable interface { 34 + Completable 35 + GetProgress() int 36 + SetProgress(progress int) error 37 + } 38 + 39 + // Compile-time interface checks 40 + var ( 41 + _ Stateful = (*Task)(nil) 42 + _ Stateful = (*Book)(nil) 43 + _ Stateful = (*Movie)(nil) 44 + _ Stateful = (*TVShow)(nil) 45 + _ Queueable = (*Book)(nil) 46 + _ Queueable = (*Movie)(nil) 47 + _ Queueable = (*TVShow)(nil) 48 + _ Completable = (*Book)(nil) 49 + _ Completable = (*Movie)(nil) 50 + _ Completable = (*TVShow)(nil) 51 + _ Progressable = (*Book)(nil) 52 + )
+73 -37
internal/models/models.go
··· 2 3 import ( 4 "encoding/json" 5 "net/url" 6 "slices" 7 "time" ··· 311 } 312 313 // IsStarted returns true if the task has a start time set. 314 - func (t *Task) IsStarted() bool { 315 - return t.Start != nil 316 - } 317 318 // IsOverdue returns true if the task is overdue. 319 func (t *Task) IsOverdue(now time.Time) bool { ··· 321 } 322 323 // HasDueDate returns true if the task has a due date set. 324 - func (t *Task) HasDueDate() bool { 325 - return t.Due != nil 326 - } 327 328 // IsRecurring returns true if the task has recurrence defined. 329 - func (t *Task) IsRecurring() bool { 330 - return t.Recur != "" 331 - } 332 333 // IsRecurExpired checks if the recurrence has an end (until) date and is past it. 334 func (t *Task) IsRecurExpired(now time.Time) bool { ··· 336 } 337 338 // HasDependencies returns true if the task depends on other tasks. 339 - func (t *Task) HasDependencies() bool { 340 - return len(t.DependsOn) > 0 341 - } 342 343 // Blocks checks if this task blocks another given task. 344 func (t *Task) Blocks(other *Task) bool { ··· 361 return score 362 } 363 364 // IsWatched returns true if the movie has been watched 365 - func (m *Movie) IsWatched() bool { 366 - return m.Status == "watched" 367 - } 368 369 // IsQueued returns true if the movie is in the queue 370 - func (m *Movie) IsQueued() bool { 371 - return m.Status == "queued" 372 - } 373 374 // IsWatching returns true if the TV show is currently being watched 375 - func (tv *TVShow) IsWatching() bool { 376 - return tv.Status == "watching" 377 - } 378 379 // IsWatched returns true if the TV show has been watched 380 - func (tv *TVShow) IsWatched() bool { 381 - return tv.Status == "watched" 382 - } 383 384 // IsQueued returns true if the TV show is in the queue 385 - func (tv *TVShow) IsQueued() bool { 386 - return tv.Status == "queued" 387 } 388 389 // IsReading returns true if the book is currently being read 390 - func (b *Book) IsReading() bool { 391 - return b.Status == "reading" 392 - } 393 394 // IsFinished returns true if the book has been finished 395 - func (b *Book) IsFinished() bool { 396 - return b.Status == "finished" 397 - } 398 399 // IsQueued returns true if the book is in the queue 400 - func (b *Book) IsQueued() bool { 401 - return b.Status == "queued" 402 - } 403 404 // ProgressPercent returns the reading progress as a percentage 405 - func (b *Book) ProgressPercent() int { 406 - return b.Progress 407 } 408 409 func (t *Task) GetID() int64 { return t.ID }
··· 2 3 import ( 4 "encoding/json" 5 + "fmt" 6 "net/url" 7 "slices" 8 "time" ··· 312 } 313 314 // IsStarted returns true if the task has a start time set. 315 + func (t *Task) IsStarted() bool { return t.Start != nil } 316 317 // IsOverdue returns true if the task is overdue. 318 func (t *Task) IsOverdue(now time.Time) bool { ··· 320 } 321 322 // HasDueDate returns true if the task has a due date set. 323 + func (t *Task) HasDueDate() bool { return t.Due != nil } 324 325 // IsRecurring returns true if the task has recurrence defined. 326 + func (t *Task) IsRecurring() bool { return t.Recur != "" } 327 328 // IsRecurExpired checks if the recurrence has an end (until) date and is past it. 329 func (t *Task) IsRecurExpired(now time.Time) bool { ··· 331 } 332 333 // HasDependencies returns true if the task depends on other tasks. 334 + func (t *Task) HasDependencies() bool { return len(t.DependsOn) > 0 } 335 336 // Blocks checks if this task blocks another given task. 337 func (t *Task) Blocks(other *Task) bool { ··· 354 return score 355 } 356 357 + // GetStatus returns the current status of the task 358 + func (t *Task) GetStatus() string { return t.Status } 359 + 360 + // ValidStatuses returns all valid status values for a task 361 + func (t *Task) ValidStatuses() []string { 362 + return []string{ 363 + StatusTodo, StatusInProgress, StatusBlocked, StatusDone, StatusAbandoned, 364 + StatusPending, StatusCompleted, StatusDeleted, 365 + } 366 + } 367 + 368 // IsWatched returns true if the movie has been watched 369 + func (m *Movie) IsWatched() bool { return m.Status == "watched" } 370 371 // IsQueued returns true if the movie is in the queue 372 + func (m *Movie) IsQueued() bool { return m.Status == "queued" } 373 + 374 + // GetStatus returns the current status of the movie 375 + func (m *Movie) GetStatus() string { return m.Status } 376 + 377 + // ValidStatuses returns all valid status values for a movie 378 + func (m *Movie) ValidStatuses() []string { return []string{"queued", "watched", "removed"} } 379 + 380 + // IsCompleted returns true if the movie has been watched 381 + func (m *Movie) IsCompleted() bool { return m.Status == "watched" } 382 + 383 + // GetCompletionTime returns when the movie was watched 384 + func (m *Movie) GetCompletionTime() *time.Time { return m.Watched } 385 386 // IsWatching returns true if the TV show is currently being watched 387 + func (tv *TVShow) IsWatching() bool { return tv.Status == "watching" } 388 389 // IsWatched returns true if the TV show has been watched 390 + func (tv *TVShow) IsWatched() bool { return tv.Status == "watched" } 391 392 // IsQueued returns true if the TV show is in the queue 393 + func (tv *TVShow) IsQueued() bool { return tv.Status == "queued" } 394 + 395 + // GetStatus returns the current status of the TV show 396 + func (tv *TVShow) GetStatus() string { return tv.Status } 397 + 398 + // ValidStatuses returns all valid status values for a TV show 399 + func (tv *TVShow) ValidStatuses() []string { 400 + return []string{"queued", "watching", "watched", "removed"} 401 } 402 403 + // IsCompleted returns true if the TV show has been watched 404 + func (tv *TVShow) IsCompleted() bool { return tv.Status == "watched" } 405 + 406 + // GetCompletionTime returns when the TV show was last watched 407 + func (tv *TVShow) GetCompletionTime() *time.Time { return tv.LastWatched } 408 + 409 // IsReading returns true if the book is currently being read 410 + func (b *Book) IsReading() bool { return b.Status == "reading" } 411 412 // IsFinished returns true if the book has been finished 413 + func (b *Book) IsFinished() bool { return b.Status == "finished" } 414 415 // IsQueued returns true if the book is in the queue 416 + func (b *Book) IsQueued() bool { return b.Status == "queued" } 417 418 // ProgressPercent returns the reading progress as a percentage 419 + func (b *Book) ProgressPercent() int { return b.Progress } 420 + 421 + // GetStatus returns the current status of the book 422 + func (b *Book) GetStatus() string { return b.Status } 423 + 424 + // ValidStatuses returns all valid status values for a book 425 + func (b *Book) ValidStatuses() []string { return []string{"queued", "reading", "finished", "removed"} } 426 + 427 + // IsCompleted returns true if the book has been finished 428 + func (b *Book) IsCompleted() bool { return b.Status == "finished" } 429 + 430 + // GetCompletionTime returns when the book was finished 431 + func (b *Book) GetCompletionTime() *time.Time { return b.Finished } 432 + 433 + // GetProgress returns the reading progress percentage (0-100) 434 + func (b *Book) GetProgress() int { return b.Progress } 435 + 436 + // SetProgress sets the reading progress percentage (0-100) 437 + func (b *Book) SetProgress(progress int) error { 438 + if progress < 0 || progress > 100 { 439 + return fmt.Errorf("progress must be between 0 and 100, got %d", progress) 440 + } 441 + b.Progress = progress 442 + return nil 443 } 444 445 func (t *Task) GetID() int64 { return t.ID }
+223
internal/models/models_test.go
··· 906 } 907 }) 908 }) 909 }
··· 906 } 907 }) 908 }) 909 + 910 + t.Run("Behavior Interfaces", func(t *testing.T) { 911 + t.Run("Stateful Interface", func(t *testing.T) { 912 + t.Run("Task implements Stateful", func(t *testing.T) { 913 + task := &Task{Status: StatusTodo} 914 + 915 + if task.GetStatus() != StatusTodo { 916 + t.Errorf("Expected status %s, got %s", StatusTodo, task.GetStatus()) 917 + } 918 + 919 + validStatuses := task.ValidStatuses() 920 + if len(validStatuses) == 0 { 921 + t.Error("ValidStatuses should not be empty") 922 + } 923 + 924 + expectedStatuses := []string{StatusTodo, StatusInProgress, StatusBlocked, StatusDone, StatusAbandoned, StatusPending, StatusCompleted, StatusDeleted} 925 + if len(validStatuses) != len(expectedStatuses) { 926 + t.Errorf("Expected %d valid statuses, got %d", len(expectedStatuses), len(validStatuses)) 927 + } 928 + }) 929 + 930 + t.Run("Book implements Stateful", func(t *testing.T) { 931 + book := &Book{Status: "reading"} 932 + 933 + if book.GetStatus() != "reading" { 934 + t.Errorf("Expected status 'reading', got %s", book.GetStatus()) 935 + } 936 + 937 + validStatuses := book.ValidStatuses() 938 + expectedStatuses := []string{"queued", "reading", "finished", "removed"} 939 + 940 + if len(validStatuses) != len(expectedStatuses) { 941 + t.Errorf("Expected %d valid statuses, got %d", len(expectedStatuses), len(validStatuses)) 942 + } 943 + 944 + for i, status := range expectedStatuses { 945 + if validStatuses[i] != status { 946 + t.Errorf("Expected status %s at index %d, got %s", status, i, validStatuses[i]) 947 + } 948 + } 949 + }) 950 + 951 + t.Run("Movie implements Stateful", func(t *testing.T) { 952 + movie := &Movie{Status: "queued"} 953 + 954 + if movie.GetStatus() != "queued" { 955 + t.Errorf("Expected status 'queued', got %s", movie.GetStatus()) 956 + } 957 + 958 + validStatuses := movie.ValidStatuses() 959 + expectedStatuses := []string{"queued", "watched", "removed"} 960 + 961 + if len(validStatuses) != len(expectedStatuses) { 962 + t.Errorf("Expected %d valid statuses, got %d", len(expectedStatuses), len(validStatuses)) 963 + } 964 + }) 965 + 966 + t.Run("TVShow implements Stateful", func(t *testing.T) { 967 + tvShow := &TVShow{Status: "watching"} 968 + 969 + if tvShow.GetStatus() != "watching" { 970 + t.Errorf("Expected status 'watching', got %s", tvShow.GetStatus()) 971 + } 972 + 973 + validStatuses := tvShow.ValidStatuses() 974 + expectedStatuses := []string{"queued", "watching", "watched", "removed"} 975 + 976 + if len(validStatuses) != len(expectedStatuses) { 977 + t.Errorf("Expected %d valid statuses, got %d", len(expectedStatuses), len(validStatuses)) 978 + } 979 + }) 980 + }) 981 + 982 + t.Run("Completable Interface", func(t *testing.T) { 983 + t.Run("Book implements Completable", func(t *testing.T) { 984 + now := time.Now() 985 + 986 + unfinishedBook := &Book{Status: "reading"} 987 + if unfinishedBook.IsCompleted() { 988 + t.Error("Book with 'reading' status should not be completed") 989 + } 990 + if unfinishedBook.GetCompletionTime() != nil { 991 + t.Error("Unfinished book should have nil completion time") 992 + } 993 + 994 + finishedBook := &Book{Status: "finished", Finished: &now} 995 + if !finishedBook.IsCompleted() { 996 + t.Error("Book with 'finished' status should be completed") 997 + } 998 + if finishedBook.GetCompletionTime() == nil { 999 + t.Error("Finished book should have completion time") 1000 + } 1001 + if !finishedBook.GetCompletionTime().Equal(now) { 1002 + t.Errorf("Expected completion time %v, got %v", now, finishedBook.GetCompletionTime()) 1003 + } 1004 + }) 1005 + 1006 + t.Run("Movie implements Completable", func(t *testing.T) { 1007 + now := time.Now() 1008 + 1009 + unwatchedMovie := &Movie{Status: "queued"} 1010 + if unwatchedMovie.IsCompleted() { 1011 + t.Error("Movie with 'queued' status should not be completed") 1012 + } 1013 + if unwatchedMovie.GetCompletionTime() != nil { 1014 + t.Error("Unwatched movie should have nil completion time") 1015 + } 1016 + 1017 + watchedMovie := &Movie{Status: "watched", Watched: &now} 1018 + if !watchedMovie.IsCompleted() { 1019 + t.Error("Movie with 'watched' status should be completed") 1020 + } 1021 + if watchedMovie.GetCompletionTime() == nil { 1022 + t.Error("Watched movie should have completion time") 1023 + } 1024 + if !watchedMovie.GetCompletionTime().Equal(now) { 1025 + t.Errorf("Expected completion time %v, got %v", now, watchedMovie.GetCompletionTime()) 1026 + } 1027 + }) 1028 + 1029 + t.Run("TVShow implements Completable", func(t *testing.T) { 1030 + now := time.Now() 1031 + 1032 + unwatchedShow := &TVShow{Status: "watching"} 1033 + if unwatchedShow.IsCompleted() { 1034 + t.Error("TVShow with 'watching' status should not be completed") 1035 + } 1036 + if unwatchedShow.GetCompletionTime() != nil { 1037 + t.Error("Unwatched show should have nil completion time") 1038 + } 1039 + 1040 + watchedShow := &TVShow{Status: "watched", LastWatched: &now} 1041 + if !watchedShow.IsCompleted() { 1042 + t.Error("TVShow with 'watched' status should be completed") 1043 + } 1044 + if watchedShow.GetCompletionTime() == nil { 1045 + t.Error("Watched show should have completion time") 1046 + } 1047 + if !watchedShow.GetCompletionTime().Equal(now) { 1048 + t.Errorf("Expected completion time %v, got %v", now, watchedShow.GetCompletionTime()) 1049 + } 1050 + }) 1051 + }) 1052 + 1053 + t.Run("Progressable Interface", func(t *testing.T) { 1054 + t.Run("Book implements Progressable", func(t *testing.T) { 1055 + book := &Book{Progress: 50} 1056 + 1057 + if book.GetProgress() != 50 { 1058 + t.Errorf("Expected progress 50, got %d", book.GetProgress()) 1059 + } 1060 + }) 1061 + 1062 + t.Run("SetProgress with valid values", func(t *testing.T) { 1063 + book := &Book{} 1064 + 1065 + if err := book.SetProgress(0); err != nil { 1066 + t.Errorf("SetProgress(0) should succeed, got error: %v", err) 1067 + } 1068 + if book.Progress != 0 { 1069 + t.Errorf("Expected progress 0, got %d", book.Progress) 1070 + } 1071 + 1072 + if err := book.SetProgress(100); err != nil { 1073 + t.Errorf("SetProgress(100) should succeed, got error: %v", err) 1074 + } 1075 + if book.Progress != 100 { 1076 + t.Errorf("Expected progress 100, got %d", book.Progress) 1077 + } 1078 + 1079 + if err := book.SetProgress(42); err != nil { 1080 + t.Errorf("SetProgress(42) should succeed, got error: %v", err) 1081 + } 1082 + if book.Progress != 42 { 1083 + t.Errorf("Expected progress 42, got %d", book.Progress) 1084 + } 1085 + }) 1086 + 1087 + t.Run("SetProgress rejects invalid values", func(t *testing.T) { 1088 + book := &Book{Progress: 50} 1089 + 1090 + if err := book.SetProgress(-1); err == nil { 1091 + t.Error("SetProgress(-1) should fail") 1092 + } else if book.Progress != 50 { 1093 + t.Error("Progress should not change on validation error") 1094 + } 1095 + 1096 + if err := book.SetProgress(101); err == nil { 1097 + t.Error("SetProgress(101) should fail") 1098 + } else if book.Progress != 50 { 1099 + t.Error("Progress should not change on validation error") 1100 + } 1101 + 1102 + if err := book.SetProgress(-100); err == nil { 1103 + t.Error("SetProgress(-100) should fail") 1104 + } 1105 + 1106 + if err := book.SetProgress(1000); err == nil { 1107 + t.Error("SetProgress(1000) should fail") 1108 + } 1109 + }) 1110 + 1111 + t.Run("SetProgress error messages", func(t *testing.T) { 1112 + book := &Book{} 1113 + 1114 + err := book.SetProgress(-5) 1115 + if err == nil { 1116 + t.Fatal("Expected error for negative progress") 1117 + } 1118 + if err.Error() != "progress must be between 0 and 100, got -5" { 1119 + t.Errorf("Unexpected error message: %s", err.Error()) 1120 + } 1121 + 1122 + err = book.SetProgress(150) 1123 + if err == nil { 1124 + t.Fatal("Expected error for progress > 100") 1125 + } 1126 + if err.Error() != "progress must be between 0 and 100, got 150" { 1127 + t.Errorf("Unexpected error message: %s", err.Error()) 1128 + } 1129 + }) 1130 + }) 1131 + }) 1132 }
+301
internal/repo/base_media_repository_test.go
···
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + 7 + _ "github.com/mattn/go-sqlite3" 8 + "github.com/stormlightlabs/noteleaf/internal/models" 9 + ) 10 + 11 + func TestBaseMediaRepository(t *testing.T) { 12 + ctx := context.Background() 13 + 14 + t.Run("Books", func(t *testing.T) { 15 + t.Run("Create and Get", func(t *testing.T) { 16 + db := CreateTestDB(t) 17 + repo := NewBookRepository(db) 18 + 19 + book := &models.Book{ 20 + Title: "Test Book", 21 + Author: "Test Author", 22 + Status: "queued", 23 + } 24 + 25 + id, err := repo.Create(ctx, book) 26 + AssertNoError(t, err, "Failed to create book") 27 + AssertNotEqual(t, int64(0), id, "Expected non-zero ID") 28 + 29 + retrieved, err := repo.Get(ctx, id) 30 + AssertNoError(t, err, "Failed to get book") 31 + AssertEqual(t, book.Title, retrieved.Title, "Title mismatch") 32 + AssertEqual(t, book.Author, retrieved.Author, "Author mismatch") 33 + AssertEqual(t, book.Status, retrieved.Status, "Status mismatch") 34 + }) 35 + 36 + t.Run("Update", func(t *testing.T) { 37 + db := CreateTestDB(t) 38 + repo := NewBookRepository(db) 39 + 40 + book := &models.Book{ 41 + Title: "Original Title", 42 + Author: "Original Author", 43 + Status: "queued", 44 + } 45 + 46 + id, err := repo.Create(ctx, book) 47 + AssertNoError(t, err, "Failed to create book") 48 + 49 + book.Title = "Updated Title" 50 + book.Author = "Updated Author" 51 + book.Status = "reading" 52 + 53 + err = repo.Update(ctx, book) 54 + AssertNoError(t, err, "Failed to update book") 55 + 56 + retrieved, err := repo.Get(ctx, id) 57 + AssertNoError(t, err, "Failed to get updated book") 58 + AssertEqual(t, "Updated Title", retrieved.Title, "Title not updated") 59 + AssertEqual(t, "Updated Author", retrieved.Author, "Author not updated") 60 + AssertEqual(t, "reading", retrieved.Status, "Status not updated") 61 + }) 62 + 63 + t.Run("Delete", func(t *testing.T) { 64 + db := CreateTestDB(t) 65 + repo := NewBookRepository(db) 66 + 67 + book := &models.Book{ 68 + Title: "To Delete", 69 + Status: "queued", 70 + } 71 + 72 + id, err := repo.Create(ctx, book) 73 + AssertNoError(t, err, "Failed to create book") 74 + 75 + err = repo.Delete(ctx, id) 76 + AssertNoError(t, err, "Failed to delete book") 77 + 78 + _, err = repo.Get(ctx, id) 79 + AssertError(t, err, "Expected error when getting deleted book") 80 + }) 81 + 82 + t.Run("Get non-existent", func(t *testing.T) { 83 + db := CreateTestDB(t) 84 + repo := NewBookRepository(db) 85 + 86 + _, err := repo.Get(ctx, 9999) 87 + AssertError(t, err, "Expected error for non-existent book") 88 + AssertContains(t, err.Error(), "not found", "Error should mention 'not found'") 89 + }) 90 + 91 + t.Run("ListQuery with multiple books", func(t *testing.T) { 92 + db := CreateTestDB(t) 93 + repo := NewBookRepository(db) 94 + 95 + books := []*models.Book{ 96 + {Title: "Book 1", Author: "Author A", Status: "queued"}, 97 + {Title: "Book 2", Author: "Author B", Status: "reading"}, 98 + {Title: "Book 3", Author: "Author A", Status: "finished"}, 99 + } 100 + 101 + for _, book := range books { 102 + _, err := repo.Create(ctx, book) 103 + AssertNoError(t, err, "Failed to create book") 104 + } 105 + 106 + allBooks, err := repo.List(ctx, BookListOptions{}) 107 + AssertNoError(t, err, "Failed to list books") 108 + if len(allBooks) != 3 { 109 + t.Errorf("Expected 3 books, got %d", len(allBooks)) 110 + } 111 + }) 112 + 113 + t.Run("CountQuery", func(t *testing.T) { 114 + db := CreateTestDB(t) 115 + repo := NewBookRepository(db) 116 + 117 + for i := 0; i < 5; i++ { 118 + book := &models.Book{ 119 + Title: "Book", 120 + Status: "queued", 121 + } 122 + _, err := repo.Create(ctx, book) 123 + AssertNoError(t, err, "Failed to create book") 124 + } 125 + 126 + count, err := repo.Count(ctx, BookListOptions{}) 127 + AssertNoError(t, err, "Failed to count books") 128 + if count != 5 { 129 + t.Errorf("Expected count of 5, got %d", count) 130 + } 131 + }) 132 + }) 133 + 134 + t.Run("Movies", func(t *testing.T) { 135 + t.Run("Create and Get", func(t *testing.T) { 136 + db := CreateTestDB(t) 137 + repo := NewMovieRepository(db) 138 + 139 + movie := &models.Movie{ 140 + Title: "Test Movie", 141 + Year: 2023, 142 + Status: "queued", 143 + } 144 + 145 + id, err := repo.Create(ctx, movie) 146 + AssertNoError(t, err, "Failed to create movie") 147 + AssertNotEqual(t, int64(0), id, "Expected non-zero ID") 148 + 149 + retrieved, err := repo.Get(ctx, id) 150 + AssertNoError(t, err, "Failed to get movie") 151 + AssertEqual(t, movie.Title, retrieved.Title, "Title mismatch") 152 + AssertEqual(t, movie.Year, retrieved.Year, "Year mismatch") 153 + AssertEqual(t, movie.Status, retrieved.Status, "Status mismatch") 154 + }) 155 + 156 + t.Run("Update", func(t *testing.T) { 157 + db := CreateTestDB(t) 158 + repo := NewMovieRepository(db) 159 + 160 + movie := &models.Movie{ 161 + Title: "Original Movie", 162 + Year: 2020, 163 + Status: "queued", 164 + } 165 + 166 + id, err := repo.Create(ctx, movie) 167 + AssertNoError(t, err, "Failed to create movie") 168 + 169 + movie.Title = "Updated Movie" 170 + movie.Year = 2023 171 + movie.Status = "watched" 172 + 173 + err = repo.Update(ctx, movie) 174 + AssertNoError(t, err, "Failed to update movie") 175 + 176 + retrieved, err := repo.Get(ctx, id) 177 + AssertNoError(t, err, "Failed to get updated movie") 178 + AssertEqual(t, "Updated Movie", retrieved.Title, "Title not updated") 179 + AssertEqual(t, 2023, retrieved.Year, "Year not updated") 180 + AssertEqual(t, "watched", retrieved.Status, "Status not updated") 181 + }) 182 + 183 + t.Run("Delete", func(t *testing.T) { 184 + db := CreateTestDB(t) 185 + repo := NewMovieRepository(db) 186 + 187 + movie := &models.Movie{ 188 + Title: "To Delete", 189 + Status: "queued", 190 + } 191 + 192 + id, err := repo.Create(ctx, movie) 193 + AssertNoError(t, err, "Failed to create movie") 194 + 195 + err = repo.Delete(ctx, id) 196 + AssertNoError(t, err, "Failed to delete movie") 197 + 198 + _, err = repo.Get(ctx, id) 199 + AssertError(t, err, "Expected error when getting deleted movie") 200 + }) 201 + }) 202 + 203 + t.Run("TV Shows", func(t *testing.T) { 204 + t.Run("Create and Get", func(t *testing.T) { 205 + db := CreateTestDB(t) 206 + repo := NewTVRepository(db) 207 + 208 + show := &models.TVShow{ 209 + Title: "Test Show", 210 + Season: 1, 211 + Episode: 1, 212 + Status: "queued", 213 + } 214 + 215 + id, err := repo.Create(ctx, show) 216 + AssertNoError(t, err, "Failed to create TV show") 217 + AssertNotEqual(t, int64(0), id, "Expected non-zero ID") 218 + 219 + retrieved, err := repo.Get(ctx, id) 220 + AssertNoError(t, err, "Failed to get TV show") 221 + AssertEqual(t, show.Title, retrieved.Title, "Title mismatch") 222 + AssertEqual(t, show.Season, retrieved.Season, "Season mismatch") 223 + AssertEqual(t, show.Episode, retrieved.Episode, "Episode mismatch") 224 + AssertEqual(t, show.Status, retrieved.Status, "Status mismatch") 225 + }) 226 + 227 + t.Run("Update", func(t *testing.T) { 228 + db := CreateTestDB(t) 229 + repo := NewTVRepository(db) 230 + 231 + show := &models.TVShow{ 232 + Title: "Original Show", 233 + Season: 1, 234 + Episode: 1, 235 + Status: "queued", 236 + } 237 + 238 + id, err := repo.Create(ctx, show) 239 + AssertNoError(t, err, "Failed to create TV show") 240 + 241 + show.Title = "Updated Show" 242 + show.Season = 2 243 + show.Episode = 5 244 + show.Status = "watching" 245 + 246 + err = repo.Update(ctx, show) 247 + AssertNoError(t, err, "Failed to update TV show") 248 + 249 + retrieved, err := repo.Get(ctx, id) 250 + AssertNoError(t, err, "Failed to get updated TV show") 251 + AssertEqual(t, "Updated Show", retrieved.Title, "Title not updated") 252 + AssertEqual(t, 2, retrieved.Season, "Season not updated") 253 + AssertEqual(t, 5, retrieved.Episode, "Episode not updated") 254 + AssertEqual(t, "watching", retrieved.Status, "Status not updated") 255 + }) 256 + 257 + t.Run("Delete", func(t *testing.T) { 258 + db := CreateTestDB(t) 259 + repo := NewTVRepository(db) 260 + 261 + show := &models.TVShow{ 262 + Title: "To Delete", 263 + Status: "queued", 264 + } 265 + 266 + id, err := repo.Create(ctx, show) 267 + AssertNoError(t, err, "Failed to create TV show") 268 + 269 + err = repo.Delete(ctx, id) 270 + AssertNoError(t, err, "Failed to delete TV show") 271 + 272 + _, err = repo.Get(ctx, id) 273 + AssertError(t, err, "Expected error when getting deleted TV show") 274 + }) 275 + }) 276 + 277 + t.Run("Edge Cases", func(t *testing.T) { 278 + t.Run("buildPlaceholders", func(t *testing.T) { 279 + emptyResult := buildPlaceholders([]any{}) 280 + if emptyResult != "" { 281 + t.Errorf("Expected empty string for empty values, got '%s'", emptyResult) 282 + } 283 + 284 + singleResult := buildPlaceholders([]any{1}) 285 + if singleResult != "?" { 286 + t.Errorf("Expected '?' for single value, got '%s'", singleResult) 287 + } 288 + 289 + multipleResult := buildPlaceholders([]any{1, 2, 3}) 290 + if multipleResult != "?,?,?" { 291 + t.Errorf("Expected '?,?,?' for three values, got '%s'", multipleResult) 292 + } 293 + 294 + largeResult := buildPlaceholders(make([]any, 10)) 295 + expected := "?,?,?,?,?,?,?,?,?,?" 296 + if largeResult != expected { 297 + t.Errorf("Expected '%s' for ten values, got '%s'", expected, largeResult) 298 + } 299 + }) 300 + }) 301 + }
+53 -22
internal/repo/book_repository.go
··· 8 "time" 9 10 "github.com/stormlightlabs/noteleaf/internal/models" 11 ) 12 13 // BookRepository provides database operations for books 14 // 15 // Uses BaseMediaRepository for common CRUD operations. 16 - // TODO: Implement Repository interface (Validate method) similar to ArticleRepository 17 type BookRepository struct { 18 *BaseMediaRepository[*models.Book] 19 db *sql.DB ··· 26 New: func() *models.Book { return &models.Book{} }, 27 InsertColumns: "title, author, status, progress, pages, rating, notes, added, started, finished", 28 UpdateColumns: "title = ?, author = ?, status = ?, progress = ?, pages = ?, rating = ?, notes = ?, started = ?, finished = ?", 29 InsertValues: func(book *models.Book) []any { 30 return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Added, book.Started, book.Finished} 31 }, 32 UpdateValues: func(book *models.Book) []any { 33 return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Started, book.Finished, book.ID} 34 - }, 35 - Scan: func(rows *sql.Rows, book *models.Book) error { 36 - return scanBookRow(rows, book) 37 - }, 38 - ScanSingle: func(row *sql.Row, book *models.Book) error { 39 - return scanBookRowSingle(row, book) 40 }, 41 } 42 43 - return &BookRepository{ 44 - BaseMediaRepository: NewBaseMediaRepository(db, config), 45 - db: db, 46 - } 47 } 48 49 // Create stores a new book and returns its assigned ID 50 func (r *BookRepository) Create(ctx context.Context, book *models.Book) (int64, error) { 51 now := time.Now() 52 book.Added = now 53 ··· 60 return id, nil 61 } 62 63 // List retrieves books with optional filtering and sorting 64 func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) { 65 query := r.buildListQuery(opts) 66 args := r.buildListArgs(opts) 67 - 68 items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 69 if err != nil { 70 return nil, err ··· 119 query += fmt.Sprintf(" OFFSET %d", opts.Offset) 120 } 121 } 122 - 123 return query 124 } 125 ··· 143 searchPattern := "%" + opts.Search + "%" 144 args = append(args, searchPattern, searchPattern, searchPattern) 145 } 146 - 147 return args 148 } 149 150 // scanBookRow scans a database row into a book model 151 func scanBookRow(rows *sql.Rows, book *models.Book) error { 152 var pages sql.NullInt64 153 - 154 if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages, 155 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil { 156 return err 157 } 158 - 159 if pages.Valid { 160 book.Pages = int(pages.Int64) 161 } 162 - 163 return nil 164 } 165 166 // scanBookRowSingle scans a single database row into a book model 167 func scanBookRowSingle(row *sql.Row, book *models.Book) error { 168 var pages sql.NullInt64 169 - 170 if err := row.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages, 171 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil { 172 return err 173 } 174 - 175 if pages.Valid { 176 book.Pages = int(pages.Int64) 177 } 178 - 179 return nil 180 } 181 ··· 222 if len(conditions) > 0 { 223 query += " WHERE " + strings.Join(conditions, " AND ") 224 } 225 - 226 return r.BaseMediaRepository.CountQuery(ctx, query, args...) 227 } 228 ··· 295 book.Started = &now 296 } 297 } 298 - 299 return r.Update(ctx, book) 300 } 301 ··· 311 Limit int 312 Offset int 313 }
··· 8 "time" 9 10 "github.com/stormlightlabs/noteleaf/internal/models" 11 + "github.com/stormlightlabs/noteleaf/internal/services" 12 ) 13 14 // BookRepository provides database operations for books 15 // 16 // Uses BaseMediaRepository for common CRUD operations. 17 type BookRepository struct { 18 *BaseMediaRepository[*models.Book] 19 db *sql.DB ··· 26 New: func() *models.Book { return &models.Book{} }, 27 InsertColumns: "title, author, status, progress, pages, rating, notes, added, started, finished", 28 UpdateColumns: "title = ?, author = ?, status = ?, progress = ?, pages = ?, rating = ?, notes = ?, started = ?, finished = ?", 29 + Scan: func(rows *sql.Rows, book *models.Book) error { return scanBookRow(rows, book) }, 30 + ScanSingle: func(row *sql.Row, book *models.Book) error { return scanBookRowSingle(row, book) }, 31 InsertValues: func(book *models.Book) []any { 32 return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Added, book.Started, book.Finished} 33 }, 34 UpdateValues: func(book *models.Book) []any { 35 return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Started, book.Finished, book.ID} 36 }, 37 } 38 39 + return &BookRepository{BaseMediaRepository: NewBaseMediaRepository(db, config), db: db} 40 } 41 42 // Create stores a new book and returns its assigned ID 43 func (r *BookRepository) Create(ctx context.Context, book *models.Book) (int64, error) { 44 + if err := r.Validate(book); err != nil { 45 + return 0, err 46 + } 47 + 48 now := time.Now() 49 book.Added = now 50 ··· 57 return id, nil 58 } 59 60 + // Update modifies an existing book 61 + func (r *BookRepository) Update(ctx context.Context, book *models.Book) error { 62 + if err := r.Validate(book); err != nil { 63 + return err 64 + } 65 + return r.BaseMediaRepository.Update(ctx, book) 66 + } 67 + 68 // List retrieves books with optional filtering and sorting 69 func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) { 70 query := r.buildListQuery(opts) 71 args := r.buildListArgs(opts) 72 items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 73 if err != nil { 74 return nil, err ··· 123 query += fmt.Sprintf(" OFFSET %d", opts.Offset) 124 } 125 } 126 return query 127 } 128 ··· 146 searchPattern := "%" + opts.Search + "%" 147 args = append(args, searchPattern, searchPattern, searchPattern) 148 } 149 return args 150 } 151 152 // scanBookRow scans a database row into a book model 153 func scanBookRow(rows *sql.Rows, book *models.Book) error { 154 var pages sql.NullInt64 155 if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages, 156 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil { 157 return err 158 } 159 if pages.Valid { 160 book.Pages = int(pages.Int64) 161 } 162 return nil 163 } 164 165 // scanBookRowSingle scans a single database row into a book model 166 func scanBookRowSingle(row *sql.Row, book *models.Book) error { 167 var pages sql.NullInt64 168 if err := row.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages, 169 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil { 170 return err 171 } 172 if pages.Valid { 173 book.Pages = int(pages.Int64) 174 } 175 return nil 176 } 177 ··· 218 if len(conditions) > 0 { 219 query += " WHERE " + strings.Join(conditions, " AND ") 220 } 221 return r.BaseMediaRepository.CountQuery(ctx, query, args...) 222 } 223 ··· 290 book.Started = &now 291 } 292 } 293 return r.Update(ctx, book) 294 } 295 ··· 305 Limit int 306 Offset int 307 } 308 + 309 + // Validate validates a book model 310 + func (r *BookRepository) Validate(model models.Model) error { 311 + book, ok := model.(*models.Book) 312 + if !ok { 313 + return services.NewValidationError("model", "expected Book model") 314 + } 315 + 316 + validator := services.NewValidator() 317 + 318 + validator.Check(services.RequiredString("Title", book.Title)) 319 + validator.Check(services.ValidEnum("Status", book.Status, book.ValidStatuses())) 320 + validator.Check(services.StringLength("Title", book.Title, 1, 500)) 321 + validator.Check(services.StringLength("Author", book.Author, 0, 200)) 322 + validator.Check(services.StringLength("Notes", book.Notes, 0, 2000)) 323 + 324 + if book.Progress < 0 || book.Progress > 100 { 325 + validator.Check(services.NewValidationError("Progress", "must be between 0 and 100")) 326 + } 327 + 328 + if book.Rating < 0 || book.Rating > 5 { 329 + validator.Check(services.NewValidationError("Rating", "must be between 0 and 5")) 330 + } 331 + 332 + if book.Pages < 0 { 333 + validator.Check(services.NewValidationError("Pages", "must be non-negative")) 334 + } 335 + 336 + if book.ID > 0 { 337 + validator.Check(services.PositiveID("ID", book.ID)) 338 + } 339 + 340 + if !book.Added.IsZero() && book.Started != nil && book.Added.After(*book.Started) { 341 + validator.Check(services.NewValidationError("Added", "cannot be after Started timestamp")) 342 + } 343 + return validator.Errors() 344 + }
+16 -26
internal/services/validation.go
··· 10 ) 11 12 var ( 13 - emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) 14 - 15 dateFormats = []string{ 16 "2006-01-02", 17 "2006-01-02T15:04:05Z", ··· 30 return fmt.Sprintf("validation error for field '%s': %s", e.Field, e.Message) 31 } 32 33 // ValidationErrors represents multiple validation errors 34 type ValidationErrors []ValidationError 35 ··· 52 // RequiredString validates that a string field is not empty 53 func RequiredString(name, value string) error { 54 if strings.TrimSpace(value) == "" { 55 - return ValidationError{Field: name, Message: "is required and cannot be empty"} 56 } 57 return nil 58 } ··· 69 } 70 71 if parsed.Scheme != "http" && parsed.Scheme != "https" { 72 - return ValidationError{Field: name, Message: "must use http or https scheme"} 73 } 74 75 return nil ··· 82 } 83 84 if !emailRegex.MatchString(value) { 85 - return ValidationError{Field: name, Message: "must be a valid email address"} 86 } 87 88 return nil ··· 91 // StringLength validates string length constraints 92 func StringLength(name, value string, min, max int) error { 93 length := len(strings.TrimSpace(value)) 94 - 95 if min > 0 && length < min { 96 - return ValidationError{Field: name, Message: fmt.Sprintf("must be at least %d characters long", min)} 97 } 98 - 99 if max > 0 && length > max { 100 - return ValidationError{Field: name, Message: fmt.Sprintf("must not exceed %d characters", max)} 101 } 102 - 103 return nil 104 } 105 ··· 114 return nil 115 } 116 } 117 - 118 - return ValidationError{ 119 - Field: name, 120 - Message: "must be a valid date (YYYY-MM-DD, YYYY-MM-DDTHH:MM:SSZ, etc.)", 121 - } 122 } 123 124 // PositiveID validates that an ID is positive 125 func PositiveID(name string, value int64) error { 126 if value <= 0 { 127 - return ValidationError{Field: name, Message: "must be a positive integer"} 128 } 129 return nil 130 } ··· 134 if value == "" { 135 return nil 136 } 137 - 138 if slices.Contains(allowedValues, value) { 139 return nil 140 } 141 - 142 - message := fmt.Sprintf("must be one of: %s", strings.Join(allowedValues, ", ")) 143 - return ValidationError{Field: name, Message: message} 144 } 145 146 // ValidFilePath validates that a string looks like a valid file path ··· 148 if value == "" { 149 return nil 150 } 151 - 152 if strings.Contains(value, "..") { 153 - return ValidationError{Field: name, Message: "cannot contain '..' path traversal"} 154 } 155 - 156 if strings.ContainsAny(value, "<>:\"|?*") { 157 - return ValidationError{Field: name, Message: "contains invalid characters"} 158 } 159 - 160 return nil 161 } 162 ··· 176 if valErr, ok := err.(ValidationError); ok { 177 v.errors = append(v.errors, valErr) 178 } else { 179 - v.errors = append(v.errors, ValidationError{Field: "unknown", Message: err.Error()}) 180 } 181 } 182 return v
··· 10 ) 11 12 var ( 13 + emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) 14 dateFormats = []string{ 15 "2006-01-02", 16 "2006-01-02T15:04:05Z", ··· 29 return fmt.Sprintf("validation error for field '%s': %s", e.Field, e.Message) 30 } 31 32 + func NewValidationError(f, m string) ValidationError { 33 + return ValidationError{Field: f, Message: m} 34 + } 35 + 36 // ValidationErrors represents multiple validation errors 37 type ValidationErrors []ValidationError 38 ··· 55 // RequiredString validates that a string field is not empty 56 func RequiredString(name, value string) error { 57 if strings.TrimSpace(value) == "" { 58 + return NewValidationError(name, "is required and cannot be empty") 59 } 60 return nil 61 } ··· 72 } 73 74 if parsed.Scheme != "http" && parsed.Scheme != "https" { 75 + return NewValidationError(name, "must use http or https scheme") 76 } 77 78 return nil ··· 85 } 86 87 if !emailRegex.MatchString(value) { 88 + return NewValidationError(name, "must be a valid email address") 89 } 90 91 return nil ··· 94 // StringLength validates string length constraints 95 func StringLength(name, value string, min, max int) error { 96 length := len(strings.TrimSpace(value)) 97 if min > 0 && length < min { 98 + return NewValidationError(name, fmt.Sprintf("must be at least %d characters long", min)) 99 } 100 if max > 0 && length > max { 101 + return NewValidationError(name, fmt.Sprintf("must not exceed %d characters", max)) 102 } 103 return nil 104 } 105 ··· 114 return nil 115 } 116 } 117 + return NewValidationError(name, "must be a valid date (YYYY-MM-DD, YYYY-MM-DDTHH:MM:SSZ, etc.)") 118 } 119 120 // PositiveID validates that an ID is positive 121 func PositiveID(name string, value int64) error { 122 if value <= 0 { 123 + return NewValidationError(name, "must be a positive integer") 124 } 125 return nil 126 } ··· 130 if value == "" { 131 return nil 132 } 133 if slices.Contains(allowedValues, value) { 134 return nil 135 } 136 + return NewValidationError(name, fmt.Sprintf("must be one of: %s", strings.Join(allowedValues, ", "))) 137 } 138 139 // ValidFilePath validates that a string looks like a valid file path ··· 141 if value == "" { 142 return nil 143 } 144 if strings.Contains(value, "..") { 145 + return NewValidationError(name, "cannot contain '..' path traversal") 146 } 147 if strings.ContainsAny(value, "<>:\"|?*") { 148 + return NewValidationError(name, "contains invalid characters") 149 } 150 return nil 151 } 152 ··· 166 if valErr, ok := err.(ValidationError); ok { 167 v.errors = append(v.errors, valErr) 168 } else { 169 + v.errors = append(v.errors, NewValidationError("unknown", err.Error())) 170 } 171 } 172 return v