test: portsfake + router contract test + handler unit tests (Task 27)

- 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)
This commit is contained in:
2026-09-14 23:23:10 +08:00
parent 7baadedeb4
commit cc1470f6e4
9 changed files with 1184 additions and 23 deletions
@@ -0,0 +1,198 @@
package portsfake
import (
"context"
"fmt"
"io"
"sync"
"time"
"booklib/internal/ports"
)
// ---------- RateLimiter ----------
// RateLimiter counts IncrWindow calls per key and returns a programmable
// window value. Default: always allow (return 1).
type RateLimiter struct {
mu sync.Mutex
calls map[string]int
Result int // value returned by IncrWindow; 0 means "call count"
Hook func(key string, n int) int // optional override
}
func NewRateLimiter() *RateLimiter { return &RateLimiter{calls: map[string]int{}} }
func (r *RateLimiter) IncrWindow(_ context.Context, key string, _ time.Duration) int {
r.mu.Lock()
r.calls[key]++
n := r.calls[key]
r.mu.Unlock()
if r.Hook != nil {
return r.Hook(key, n)
}
if r.Result > 0 {
return r.Result
}
return n
}
// Calls reports how many times IncrWindow was invoked for key.
func (r *RateLimiter) Calls(key string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.calls[key]
}
// ---------- Scanner ----------
// Scanner records ScanLibraryByID calls. The handler invokes it in a goroutine,
// so reads go through the mutex; WaitForScan blocks until at least n calls
// landed (or the timeout expires) to keep tests deterministic.
type Scanner struct {
mu sync.Mutex
seen []int64
}
func NewScanner() *Scanner { return &Scanner{} }
func (s *Scanner) ScanLibraryByID(_ context.Context, id int64) {
s.mu.Lock()
s.seen = append(s.seen, id)
s.mu.Unlock()
}
// Seen returns the library ids scanned so far, in call order.
func (s *Scanner) Seen() []int64 {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]int64, len(s.seen))
copy(out, s.seen)
return out
}
// WaitForScan blocks until len(Seen()) >= n or timeout elapses; reports success.
func (s *Scanner) WaitForScan(n int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if len(s.Seen()) >= n {
return true
}
time.Sleep(time.Millisecond)
}
return len(s.Seen()) >= n
}
// ---------- Media ----------
// Media is a programmable stand-in for the media service. Defaults answer
// successfully with the configured fixture data; individual hooks let a test
// force one error branch without touching the others.
type Media struct {
Pages []string // PageIndex result
Chapters []ports.Chapter // ChaptersOf result
CoverErr error // EnsureCover result
IndexErr error // PageIndex result
PagePath string // EnsurePage result path (test writes the file first)
PageErr error // EnsurePage result
EnsureCoverCalls int
EnsurePageCalls int
}
func NewMedia() *Media { return &Media{} }
func (m *Media) EnsureCover(_ context.Context, _ int64, _ string, _, _ int64, _, _ string) error {
m.EnsureCoverCalls++
return m.CoverErr
}
func (m *Media) EnsurePage(_ context.Context, _ int64, _, _ int64, _, _ string, _ int, _ []string) (string, error) {
m.EnsurePageCalls++
if m.PageErr != nil {
return "", m.PageErr
}
return m.PagePath, nil
}
func (m *Media) ChaptersOf(_ []string) []ports.Chapter { return m.Chapters }
func (m *Media) PageIndex(_ context.Context, _ int64, _, _ int64, _, _ string) ([]string, error) {
if m.IndexErr != nil {
return nil, m.IndexErr
}
return m.Pages, nil
}
func (m *Media) CacheBuster(size, modTS int64) string { return fmt.Sprintf("%d-%d", size, modTS) }
// ---------- UploadSessions ----------
// Uploads is a programmable stand-in for the chunked-upload subsystem.
// Defaults simulate a happy session (uid "fakeuid…", LibraryID 1);
// hooks force error branches.
type Uploads struct {
UID string // returned by Init
LibID int64 // returned by LibraryID
Received []int64 // returned by Status
RelPath string // returned by Complete
InitErr error
LibErr error
StatusErr error
PutErr error
CompleteErr error
PutCalls []int64 // part indices passed to PutPart
SweepCalls int
UniqueCalls int
}
func NewUploads() *Uploads {
return &Uploads{UID: "0123456789abcdef0123456789abcdef", LibID: 1, RelPath: "book.cbz"}
}
func (u *Uploads) Init(_ context.Context, _ int64, _ string, _, _ int64) (string, error) {
if u.InitErr != nil {
return "", u.InitErr
}
return u.UID, nil
}
func (u *Uploads) LibraryID(_ context.Context, _ string) (int64, error) {
if u.LibErr != nil {
return 0, u.LibErr
}
return u.LibID, nil
}
func (u *Uploads) Status(_ context.Context, _ string) ([]int64, error) {
if u.StatusErr != nil {
return nil, u.StatusErr
}
return u.Received, nil
}
func (u *Uploads) PutPart(_ context.Context, _ string, index int64, body io.Reader, _ int64) error {
u.PutCalls = append(u.PutCalls, index)
if body != nil { // drain so callers using pipes don't block
io.Copy(io.Discard, body)
}
return u.PutErr
}
func (u *Uploads) Complete(_ context.Context, _, _ string) (string, error) {
if u.CompleteErr != nil {
return "", u.CompleteErr
}
return u.RelPath, nil
}
func (u *Uploads) Sweep(_ context.Context) error {
u.SweepCalls++
return nil
}
func (u *Uploads) UniquePath(root, name string) (string, error) {
u.UniqueCalls++
return root + "/" + name, nil
}
+384
View File
@@ -0,0 +1,384 @@
// 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
}