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 261 defer handler.Close() 262 262 263 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 { 264 + if _, err = handler.repos.Books.Create(ctx, 265 + &models.Book{ 266 + Title: "Interactive Test Book", Author: "Interactive Author", Status: "finished", 267 + }); err != nil { 269 268 t.Fatalf("Failed to create test book: %v", err) 270 269 } 271 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 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "net/url" 6 7 "slices" 7 8 "time" ··· 311 312 } 312 313 313 314 // IsStarted returns true if the task has a start time set. 314 - func (t *Task) IsStarted() bool { 315 - return t.Start != nil 316 - } 315 + func (t *Task) IsStarted() bool { return t.Start != nil } 317 316 318 317 // IsOverdue returns true if the task is overdue. 319 318 func (t *Task) IsOverdue(now time.Time) bool { ··· 321 320 } 322 321 323 322 // HasDueDate returns true if the task has a due date set. 324 - func (t *Task) HasDueDate() bool { 325 - return t.Due != nil 326 - } 323 + func (t *Task) HasDueDate() bool { return t.Due != nil } 327 324 328 325 // IsRecurring returns true if the task has recurrence defined. 329 - func (t *Task) IsRecurring() bool { 330 - return t.Recur != "" 331 - } 326 + func (t *Task) IsRecurring() bool { return t.Recur != "" } 332 327 333 328 // IsRecurExpired checks if the recurrence has an end (until) date and is past it. 334 329 func (t *Task) IsRecurExpired(now time.Time) bool { ··· 336 331 } 337 332 338 333 // HasDependencies returns true if the task depends on other tasks. 339 - func (t *Task) HasDependencies() bool { 340 - return len(t.DependsOn) > 0 341 - } 334 + func (t *Task) HasDependencies() bool { return len(t.DependsOn) > 0 } 342 335 343 336 // Blocks checks if this task blocks another given task. 344 337 func (t *Task) Blocks(other *Task) bool { ··· 361 354 return score 362 355 } 363 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 + 364 368 // IsWatched returns true if the movie has been watched 365 - func (m *Movie) IsWatched() bool { 366 - return m.Status == "watched" 367 - } 369 + func (m *Movie) IsWatched() bool { return m.Status == "watched" } 368 370 369 371 // IsQueued returns true if the movie is in the queue 370 - func (m *Movie) IsQueued() bool { 371 - return m.Status == "queued" 372 - } 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 } 373 385 374 386 // IsWatching returns true if the TV show is currently being watched 375 - func (tv *TVShow) IsWatching() bool { 376 - return tv.Status == "watching" 377 - } 387 + func (tv *TVShow) IsWatching() bool { return tv.Status == "watching" } 378 388 379 389 // IsWatched returns true if the TV show has been watched 380 - func (tv *TVShow) IsWatched() bool { 381 - return tv.Status == "watched" 382 - } 390 + func (tv *TVShow) IsWatched() bool { return tv.Status == "watched" } 383 391 384 392 // IsQueued returns true if the TV show is in the queue 385 - func (tv *TVShow) IsQueued() bool { 386 - return tv.Status == "queued" 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"} 387 401 } 388 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 + 389 409 // IsReading returns true if the book is currently being read 390 - func (b *Book) IsReading() bool { 391 - return b.Status == "reading" 392 - } 410 + func (b *Book) IsReading() bool { return b.Status == "reading" } 393 411 394 412 // IsFinished returns true if the book has been finished 395 - func (b *Book) IsFinished() bool { 396 - return b.Status == "finished" 397 - } 413 + func (b *Book) IsFinished() bool { return b.Status == "finished" } 398 414 399 415 // IsQueued returns true if the book is in the queue 400 - func (b *Book) IsQueued() bool { 401 - return b.Status == "queued" 402 - } 416 + func (b *Book) IsQueued() bool { return b.Status == "queued" } 403 417 404 418 // ProgressPercent returns the reading progress as a percentage 405 - func (b *Book) ProgressPercent() int { 406 - return b.Progress 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 407 443 } 408 444 409 445 func (t *Task) GetID() int64 { return t.ID }
+223
internal/models/models_test.go
··· 906 906 } 907 907 }) 908 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 + }) 909 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 8 "time" 9 9 10 10 "github.com/stormlightlabs/noteleaf/internal/models" 11 + "github.com/stormlightlabs/noteleaf/internal/services" 11 12 ) 12 13 13 14 // BookRepository provides database operations for books 14 15 // 15 16 // Uses BaseMediaRepository for common CRUD operations. 16 - // TODO: Implement Repository interface (Validate method) similar to ArticleRepository 17 17 type BookRepository struct { 18 18 *BaseMediaRepository[*models.Book] 19 19 db *sql.DB ··· 26 26 New: func() *models.Book { return &models.Book{} }, 27 27 InsertColumns: "title, author, status, progress, pages, rating, notes, added, started, finished", 28 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) }, 29 31 InsertValues: func(book *models.Book) []any { 30 32 return []any{book.Title, book.Author, book.Status, book.Progress, book.Pages, book.Rating, book.Notes, book.Added, book.Started, book.Finished} 31 33 }, 32 34 UpdateValues: func(book *models.Book) []any { 33 35 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 36 }, 41 37 } 42 38 43 - return &BookRepository{ 44 - BaseMediaRepository: NewBaseMediaRepository(db, config), 45 - db: db, 46 - } 39 + return &BookRepository{BaseMediaRepository: NewBaseMediaRepository(db, config), db: db} 47 40 } 48 41 49 42 // Create stores a new book and returns its assigned ID 50 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 + 51 48 now := time.Now() 52 49 book.Added = now 53 50 ··· 60 57 return id, nil 61 58 } 62 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 + 63 68 // List retrieves books with optional filtering and sorting 64 69 func (r *BookRepository) List(ctx context.Context, opts BookListOptions) ([]*models.Book, error) { 65 70 query := r.buildListQuery(opts) 66 71 args := r.buildListArgs(opts) 67 - 68 72 items, err := r.BaseMediaRepository.ListQuery(ctx, query, args...) 69 73 if err != nil { 70 74 return nil, err ··· 119 123 query += fmt.Sprintf(" OFFSET %d", opts.Offset) 120 124 } 121 125 } 122 - 123 126 return query 124 127 } 125 128 ··· 143 146 searchPattern := "%" + opts.Search + "%" 144 147 args = append(args, searchPattern, searchPattern, searchPattern) 145 148 } 146 - 147 149 return args 148 150 } 149 151 150 152 // scanBookRow scans a database row into a book model 151 153 func scanBookRow(rows *sql.Rows, book *models.Book) error { 152 154 var pages sql.NullInt64 153 - 154 155 if err := rows.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages, 155 156 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil { 156 157 return err 157 158 } 158 - 159 159 if pages.Valid { 160 160 book.Pages = int(pages.Int64) 161 161 } 162 - 163 162 return nil 164 163 } 165 164 166 165 // scanBookRowSingle scans a single database row into a book model 167 166 func scanBookRowSingle(row *sql.Row, book *models.Book) error { 168 167 var pages sql.NullInt64 169 - 170 168 if err := row.Scan(&book.ID, &book.Title, &book.Author, &book.Status, &book.Progress, &pages, 171 169 &book.Rating, &book.Notes, &book.Added, &book.Started, &book.Finished); err != nil { 172 170 return err 173 171 } 174 - 175 172 if pages.Valid { 176 173 book.Pages = int(pages.Int64) 177 174 } 178 - 179 175 return nil 180 176 } 181 177 ··· 222 218 if len(conditions) > 0 { 223 219 query += " WHERE " + strings.Join(conditions, " AND ") 224 220 } 225 - 226 221 return r.BaseMediaRepository.CountQuery(ctx, query, args...) 227 222 } 228 223 ··· 295 290 book.Started = &now 296 291 } 297 292 } 298 - 299 293 return r.Update(ctx, book) 300 294 } 301 295 ··· 311 305 Limit int 312 306 Offset int 313 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 10 ) 11 11 12 12 var ( 13 - emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) 14 - 13 + emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) 15 14 dateFormats = []string{ 16 15 "2006-01-02", 17 16 "2006-01-02T15:04:05Z", ··· 30 29 return fmt.Sprintf("validation error for field '%s': %s", e.Field, e.Message) 31 30 } 32 31 32 + func NewValidationError(f, m string) ValidationError { 33 + return ValidationError{Field: f, Message: m} 34 + } 35 + 33 36 // ValidationErrors represents multiple validation errors 34 37 type ValidationErrors []ValidationError 35 38 ··· 52 55 // RequiredString validates that a string field is not empty 53 56 func RequiredString(name, value string) error { 54 57 if strings.TrimSpace(value) == "" { 55 - return ValidationError{Field: name, Message: "is required and cannot be empty"} 58 + return NewValidationError(name, "is required and cannot be empty") 56 59 } 57 60 return nil 58 61 } ··· 69 72 } 70 73 71 74 if parsed.Scheme != "http" && parsed.Scheme != "https" { 72 - return ValidationError{Field: name, Message: "must use http or https scheme"} 75 + return NewValidationError(name, "must use http or https scheme") 73 76 } 74 77 75 78 return nil ··· 82 85 } 83 86 84 87 if !emailRegex.MatchString(value) { 85 - return ValidationError{Field: name, Message: "must be a valid email address"} 88 + return NewValidationError(name, "must be a valid email address") 86 89 } 87 90 88 91 return nil ··· 91 94 // StringLength validates string length constraints 92 95 func StringLength(name, value string, min, max int) error { 93 96 length := len(strings.TrimSpace(value)) 94 - 95 97 if min > 0 && length < min { 96 - return ValidationError{Field: name, Message: fmt.Sprintf("must be at least %d characters long", min)} 98 + return NewValidationError(name, fmt.Sprintf("must be at least %d characters long", min)) 97 99 } 98 - 99 100 if max > 0 && length > max { 100 - return ValidationError{Field: name, Message: fmt.Sprintf("must not exceed %d characters", max)} 101 + return NewValidationError(name, fmt.Sprintf("must not exceed %d characters", max)) 101 102 } 102 - 103 103 return nil 104 104 } 105 105 ··· 114 114 return nil 115 115 } 116 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 - } 117 + return NewValidationError(name, "must be a valid date (YYYY-MM-DD, YYYY-MM-DDTHH:MM:SSZ, etc.)") 122 118 } 123 119 124 120 // PositiveID validates that an ID is positive 125 121 func PositiveID(name string, value int64) error { 126 122 if value <= 0 { 127 - return ValidationError{Field: name, Message: "must be a positive integer"} 123 + return NewValidationError(name, "must be a positive integer") 128 124 } 129 125 return nil 130 126 } ··· 134 130 if value == "" { 135 131 return nil 136 132 } 137 - 138 133 if slices.Contains(allowedValues, value) { 139 134 return nil 140 135 } 141 - 142 - message := fmt.Sprintf("must be one of: %s", strings.Join(allowedValues, ", ")) 143 - return ValidationError{Field: name, Message: message} 136 + return NewValidationError(name, fmt.Sprintf("must be one of: %s", strings.Join(allowedValues, ", "))) 144 137 } 145 138 146 139 // ValidFilePath validates that a string looks like a valid file path ··· 148 141 if value == "" { 149 142 return nil 150 143 } 151 - 152 144 if strings.Contains(value, "..") { 153 - return ValidationError{Field: name, Message: "cannot contain '..' path traversal"} 145 + return NewValidationError(name, "cannot contain '..' path traversal") 154 146 } 155 - 156 147 if strings.ContainsAny(value, "<>:\"|?*") { 157 - return ValidationError{Field: name, Message: "contains invalid characters"} 148 + return NewValidationError(name, "contains invalid characters") 158 149 } 159 - 160 150 return nil 161 151 } 162 152 ··· 176 166 if valErr, ok := err.(ValidationError); ok { 177 167 v.errors = append(v.errors, valErr) 178 168 } else { 179 - v.errors = append(v.errors, ValidationError{Field: "unknown", Message: err.Error()}) 169 + v.errors = append(v.errors, NewValidationError("unknown", err.Error())) 180 170 } 181 171 } 182 172 return v