- internal/ports/portsfake: hand-written in-memory fakes for all 9 ports
(Users/Libraries/Books/Progress/Bookmarks/RateLimiter/Scanner/Media/Uploads);
error semantics mirror the real store exactly — pgx.ErrNoRows for misses,
store.ErrLastAdmin guard, and *pgconn.PgError{Code:23505} for unique
violations (ports.IsUniqueViolation only accepts the PgError shape, so the
fakes must produce it to exercise the 409 branch without touching prod code)
- Media/Uploads fakes are hook-programmable: one field per error branch, so a
test can force e.g. CompleteErr=ErrIncomplete without stubbing the rest
- Scanner fake is mutex-guarded + WaitForScan: handler fires ScanLibraryByID
in a goroutine, tests stay deterministic
- router_test.go: TestRouterContract pins all 27 routes — any route table
change now fails the test explicitly
- handler unit tests (~30 cases, no PG/Redis): users CRUD branches (self-delete
400, last-admin 400, dup 409, 204 ok), library reserved names (contract:
code=bad_request message=reserved_name, per original impl), upload sentinel
mapping (413/400/404 per branch)
- NewRouter takes pure port interfaces; main.go distributes *store.Store
across the 5 store ports at the composition root
Full gate green: gofmt, vet, go test -p 1 (real PG+Redis, 0 skip)
385 lines
11 KiB
Go
385 lines
11 KiB
Go
// 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
|
|
}
|