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