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 }