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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user