// Package portsfake provides hand-written, in-memory implementations of every // interface in internal/ports. They let handler tests run without PG or Redis, // while reproducing the real store's error semantics exactly (pgx.ErrNoRows for // missing rows, store.ErrLastAdmin / store.ErrUniqueViolation for the guarded // paths), so the branches under test behave as they do against the database. // // Fakes are safe for single-goroutine test use only; they are not locked. package portsfake import ( "context" "sort" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "booklib/internal/ports" "booklib/internal/store" ) // uniqueViolation 复刻真 PG 的唯一约束冲突:store.IsUniqueViolation 只认 // SQLSTATE 23505 的 *pgconn.PgError(生产链路里 pgx 原样透出),fake 必须同形。 func uniqueViolation(constraint string) error { return &pgconn.PgError{Code: "23505", ConstraintName: constraint} } // compile-time proof that the fakes satisfy the consumer-side interfaces. var ( _ ports.UserStore = (*Users)(nil) _ ports.LibraryStore = (*Libraries)(nil) _ ports.BookStore = (*Books)(nil) _ ports.ProgressStore = (*Progress)(nil) _ ports.BookmarkStore = (*Bookmarks)(nil) _ ports.RateLimiter = (*RateLimiter)(nil) _ ports.Scanner = (*Scanner)(nil) _ ports.Media = (*Media)(nil) _ ports.UploadSessions = (*Uploads)(nil) ) // now is frozen per-fake-set so CreatedAt comparisons are stable within a test. func now() time.Time { return time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) } // ---------- UserStore ---------- type Users struct { m map[int64]store.User next int64 } func NewUsers() *Users { return &Users{m: map[int64]store.User{}, next: 1} } // Seed adds a user and returns its id — test convenience, not part of the port. func (u *Users) Seed(username, hash, role string) int64 { id := u.next u.next++ u.m[id] = store.User{ID: id, Username: username, PasswordHash: hash, Role: role, CreatedAt: now()} return id } func (u *Users) CountUsers(_ context.Context) (int, error) { return len(u.m), nil } func (u *Users) CreateUser(_ context.Context, username, hash, role string) (int64, error) { for _, v := range u.m { if v.Username == username { return 0, uniqueViolation("users_username_key") } } return u.Seed(username, hash, role), nil } func (u *Users) GetUserByName(_ context.Context, username string) (store.User, error) { for _, v := range u.m { if v.Username == username { return v, nil } } return store.User{}, pgx.ErrNoRows } func (u *Users) GetUserByID(_ context.Context, id int64) (store.User, error) { if v, ok := u.m[id]; ok { return v, nil } return store.User{}, pgx.ErrNoRows } func (u *Users) ListUsers(_ context.Context) ([]store.User, error) { out := make([]store.User, 0, len(u.m)) for _, v := range u.m { out = append(out, v) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } func (u *Users) DeleteUser(_ context.Context, id int64) error { v, ok := u.m[id] if !ok { return pgx.ErrNoRows } if v.Role == "admin" { n := 0 for _, x := range u.m { if x.Role == "admin" { n++ } } if n <= 1 { return store.ErrLastAdmin } } delete(u.m, id) return nil } // ---------- LibraryStore ---------- type Libraries struct { m map[int64]store.Library next int64 } func NewLibraries() *Libraries { return &Libraries{m: map[int64]store.Library{}, next: 1} } // Seed adds a library and returns its id — test convenience. func (l *Libraries) Seed(name, root string) int64 { id := l.next l.next++ l.m[id] = store.Library{ID: id, Name: name, RootPath: root, CreatedAt: now()} return id } func (l *Libraries) CreateLibrary(_ context.Context, name, root string) (int64, error) { for _, v := range l.m { if v.Name == name { return 0, uniqueViolation("libraries_name_key") } } return l.Seed(name, root), nil } func (l *Libraries) ListLibraries(_ context.Context) ([]store.Library, error) { out := make([]store.Library, 0, len(l.m)) for _, v := range l.m { out = append(out, v) } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out, nil } func (l *Libraries) GetLibrary(_ context.Context, id int64) (store.Library, error) { if v, ok := l.m[id]; ok { return v, nil } return store.Library{}, pgx.ErrNoRows } // ---------- BookStore ---------- type Books struct { m map[int64]store.Book next int64 } func NewBooks() *Books { return &Books{m: map[int64]store.Book{}, next: 1} } // Seed adds a book and returns its id — test convenience. func (b *Books) Seed(libID int64, path, title, format string, size, modTS int64, pageCount int) int64 { id := b.next b.next++ b.m[id] = store.Book{ID: id, LibraryID: libID, Path: path, Title: title, Format: format, FileSize: size, ModTS: modTS, PageCount: pageCount, State: "ok", AddedAt: now()} return id } func (b *Books) InsertBook(_ context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) { return b.Seed(libID, path, title, format, size, modTS, pageCount), nil } func (b *Books) GetBook(_ context.Context, id int64) (store.Book, error) { if v, ok := b.m[id]; ok { return v, nil } return store.Book{}, pgx.ErrNoRows } func (b *Books) ListBookMeta(_ context.Context, libID int64) (map[string]store.BookMeta, error) { out := map[string]store.BookMeta{} for _, v := range b.m { if v.LibraryID == libID { out[v.Path] = store.BookMeta{ID: v.ID, Size: v.FileSize, ModTS: v.ModTS, Format: v.Format} } } return out, nil } func (b *Books) UpdateBookFile(_ context.Context, id, size, modTS int64, pageCount int) error { v, ok := b.m[id] if !ok { return pgx.ErrNoRows } v.FileSize, v.ModTS, v.PageCount = size, modTS, pageCount b.m[id] = v return nil } func (b *Books) DeleteBookByPath(_ context.Context, libID int64, path string) error { for id, v := range b.m { if v.LibraryID == libID && v.Path == path { delete(b.m, id) return nil } } return nil } func (b *Books) DeleteBook(_ context.Context, id int64) error { if _, ok := b.m[id]; !ok { return pgx.ErrNoRows } delete(b.m, id) return nil } func (b *Books) SetBookState(_ context.Context, id int64, state, msg string) error { v, ok := b.m[id] if !ok { return pgx.ErrNoRows } v.State, v.ErrMsg = state, msg b.m[id] = v return nil } // ListBooks applies the same q/prefix filter as the SQL view (q matches title or // path, prefix matches path prefix), then joins progress + library name. func (b *Books) ListBooks(_ context.Context, libID int64, q, prefix string, userID int64) ([]store.BookView, error) { out := []store.BookView{} q = strings.ToLower(q) for _, v := range b.m { if libID != 0 && v.LibraryID != libID { continue } if prefix != "" && !strings.HasPrefix(v.Path, prefix) { continue } if q != "" && !strings.Contains(strings.ToLower(v.Title), q) && !strings.Contains(strings.ToLower(v.Path), q) { continue } out = append(out, store.BookView{Book: v}) } sort.Slice(out, func(i, j int) bool { return out[i].Book.ID < out[j].Book.ID }) return out, nil } func (b *Books) BookHashes(_ context.Context) (map[int64][2]int64, error) { out := map[int64][2]int64{} for _, v := range b.m { out[v.ID] = [2]int64{v.FileSize, v.ModTS} } return out, nil } // ---------- ProgressStore ---------- type progressKey struct { userID, libID int64 bookPath string } type Progress struct { m map[progressKey]store.Progress libs *Libraries // for LibraryName join; may be nil books *Books // for Title join; may be nil } func NewProgress(libs *Libraries, books *Books) *Progress { return &Progress{m: map[progressKey]store.Progress{}, libs: libs, books: books} } func (p *Progress) UpsertProgress(_ context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error { k := progressKey{userID, libID, bookPath} old := p.m[k] p.m[k] = store.Progress{LibraryID: libID, BookPath: bookPath, Locator: locator, Percent: percent, UpdatedAt: now(), LibraryName: old.LibraryName, Title: old.Title} return nil } func (p *Progress) GetProgress(_ context.Context, userID, libID int64, bookPath string) (store.Progress, error) { if v, ok := p.m[progressKey{userID, libID, bookPath}]; ok { return v, nil } return store.Progress{}, pgx.ErrNoRows } func (p *Progress) ListProgress(_ context.Context, userID int64) ([]store.Progress, error) { out := []store.Progress{} for k, v := range p.m { if k.userID != userID { continue } if p.libs != nil { if l, e := p.libs.GetLibrary(context.Background(), v.LibraryID); e == nil { v.LibraryName = l.Name } } if p.books != nil { for _, b := range p.books.m { if b.LibraryID == v.LibraryID && b.Path == v.BookPath { v.Title = b.Title break } } } out = append(out, v) } sort.Slice(out, func(i, j int) bool { return out[i].BookPath < out[j].BookPath }) return out, nil } // ---------- BookmarkStore ---------- // bookmark pairs a row with its owner id. The real store carries the owner in a // users-scoped join; the fake keeps it alongside so ListBookmarks/Patch/Delete // can honour owner-scoped 404 semantics without a global index. type bookmark struct { row store.Bookmark userID int64 } type Bookmarks struct { m map[int64]bookmark next int64 } func NewBookmarks() *Bookmarks { return &Bookmarks{m: map[int64]bookmark{}, next: 1} } func (bm *Bookmarks) InsertBookmark(_ context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) { id := bm.next bm.next++ bm.m[id] = bookmark{ row: store.Bookmark{ID: id, LibraryID: libID, BookPath: bookPath, Locator: locator, Percent: percent, Note: note, CreatedAt: now()}, userID: userID, } return id, nil } func (bm *Bookmarks) ListBookmarks(_ context.Context, userID, libID int64, bookPath string) ([]store.Bookmark, error) { out := []store.Bookmark{} for _, v := range bm.m { if v.userID == userID && v.row.LibraryID == libID && v.row.BookPath == bookPath { out = append(out, v.row) } } sort.Slice(out, func(i, j int) bool { if out[i].Percent != out[j].Percent { return out[i].Percent < out[j].Percent } return out[i].ID < out[j].ID }) return out, nil } func (bm *Bookmarks) UpdateBookmarkNote(_ context.Context, userID, id int64, note string) (bool, error) { v, ok := bm.m[id] if !ok || v.userID != userID { // owner-scoped: foreign id is a 404, not a 403 return false, nil } v.row.Note = note bm.m[id] = v return true, nil } func (bm *Bookmarks) DeleteBookmark(_ context.Context, userID, id int64) (bool, error) { v, ok := bm.m[id] if !ok || v.userID != userID { return false, nil } delete(bm.m, id) return true, nil }