Split backend into internal packages by responsibility

All Go files lived flat in backend/ as one package main. Move store,
latest-chapter polling, sessions, HTTP middleware, the JSON API, the
userscript handler, and the web UI (with its templates/static assets)
into backend/internal/{store,latest,session,httpmw,api,userscript,web},
each with an exported API. main.go becomes the composition root wiring
them into newRouter; root-level tests cover the assembled router while
package-local tests cover unit behavior. Update Dockerfile/.dockerignore
for the new internal/ tree and CLAUDE.md to describe the layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 19:41:38 +07:00
parent e250762ea6
commit eeb601cbe2
36 changed files with 971 additions and 843 deletions
+89 -85
View File
@@ -10,6 +10,10 @@ import (
"strings"
"testing"
"time"
"mangabm/backend/internal/session"
"mangabm/backend/internal/store"
"mangabm/backend/internal/web"
)
const testPassword = "hunter2"
@@ -22,22 +26,22 @@ func webConfig() Config {
// newWebTestServer returns the full router plus the store behind it, so tests
// can seed rows and assert on what the handlers wrote back.
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *Store) {
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
t.Helper()
store, err := OpenStore(filepath.Join(t.TempDir(), "test.db"))
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("OpenStore: %v", err)
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { store.Close() })
return newRouter(store, cfg), store
t.Cleanup(func() { st.Close() })
return newRouter(st, cfg), st
}
// sessionCookie returns a cookie a handler will accept for cfg's API token.
func sessionCookie(t *testing.T, cfg Config) *http.Cookie {
t.Helper()
return &http.Cookie{
Name: sessionCookieName,
Value: signSession(sessionKey(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
Name: session.CookieName,
Value: session.Sign(session.Key(cfg.Token, cfg.WebPassword), time.Now().Add(time.Hour).UnixMilli()),
}
}
@@ -56,8 +60,8 @@ func TestIndexWithoutSessionShowsLogin(t *testing.T) {
func TestIndexWithSessionShowsList(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
if _, err := store.Upsert(Bookmark{
srv, st := newWebTestServer(t, cfg)
if _, err := st.Upsert(store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
UpdatedAt: time.Now().UnixMilli(),
@@ -90,8 +94,8 @@ func TestLoginSuccessSetsCookie(t *testing.T) {
t.Fatalf("POST /login status = %d, want 303", rr.Code)
}
cookies := rr.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != sessionCookieName || cookies[0].Value == "" {
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, sessionCookieName)
if len(cookies) != 1 || cookies[0].Name != session.CookieName || cookies[0].Value == "" {
t.Fatalf("POST /login cookies = %+v, want one non-empty %s", cookies, session.CookieName)
}
}
@@ -122,14 +126,14 @@ func TestLoginRateLimited(t *testing.T) {
srv.ServeHTTP(rr, req)
return rr
}
for i := 0; i < loginMaxFailures; i++ {
for i := 0; i < session.MaxFailures; i++ {
if code := post().Code; code != http.StatusUnauthorized {
t.Fatalf("attempt %d status = %d, want 401", i+1, code)
}
}
rr := post()
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("attempt %d status = %d, want 429", loginMaxFailures+1, rr.Code)
t.Fatalf("attempt %d status = %d, want 429", session.MaxFailures+1, rr.Code)
}
if after := rr.Header().Get("Retry-After"); after == "" {
t.Fatal("429 response has no Retry-After header")
@@ -202,9 +206,9 @@ func TestStaticAssetsServed(t *testing.T) {
}
// seed inserts one bookmark and returns it as stored.
func seed(t *testing.T, store *Store, b Bookmark) Bookmark {
func seed(t *testing.T, st *store.Store, b store.Bookmark) store.Bookmark {
t.Helper()
stored, err := store.Upsert(b)
stored, err := st.Upsert(b)
if err != nil {
t.Fatalf("Upsert: %v", err)
}
@@ -245,8 +249,8 @@ func TestUIRoutesRequireSession(t *testing.T) {
func TestFavoriteTogglesWithoutReordering(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
before := seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
UpdatedAt: 1_000_000,
@@ -258,7 +262,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
t.Fatalf("favorite status = %d, want 200", rr.Code)
}
after, ok, err := store.Get("asura:solo")
after, ok, err := st.Get("asura:solo")
if err != nil || !ok {
t.Fatalf("Get after favorite: %v ok=%v", err, ok)
}
@@ -276,7 +280,7 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
// Toggling again turns it back off.
rr = httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
back, _, _ := store.Get("asura:solo")
back, _, _ := st.Get("asura:solo")
if back.Favorite {
t.Fatal("Favorite = true after a second toggle, want false")
}
@@ -294,8 +298,8 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
// selector, just a regression guard against reintroducing the bare-id form.
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
UpdatedAt: 1_000_000,
@@ -320,8 +324,8 @@ func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
before := seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
@@ -335,7 +339,7 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
t.Fatalf("chapter override status = %d, want 200", rr.Code)
}
after, ok, err := store.Get("asura:solo")
after, ok, err := st.Get("asura:solo")
if err != nil || !ok {
t.Fatalf("Get after override: %v ok=%v", err, ok)
}
@@ -355,8 +359,8 @@ func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
before := seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
before := seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapter: "45.0", LastChapterNum: 45,
LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
@@ -375,7 +379,7 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
t.Fatalf("chapter no-op status = %d, want 200", rr.Code)
}
after, ok, err := store.Get("asura:solo")
after, ok, err := st.Get("asura:solo")
if err != nil || !ok {
t.Fatalf("Get after no-op override: %v ok=%v", err, ok)
}
@@ -395,8 +399,8 @@ func TestChapterOverrideNoOpPreservesURLAndUpdatedAt(t *testing.T) {
func TestChapterOverrideRejectsBadInput(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
})
@@ -409,7 +413,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
after, _, _ := store.Get("asura:solo")
after, _, _ := st.Get("asura:solo")
if after.LastChapterNum != 45 {
t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
}
@@ -440,8 +444,8 @@ func TestMutationsOnMissingKey(t *testing.T) {
func TestUIDeleteRemovesRow(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", UpdatedAt: 1_000_000,
})
@@ -460,19 +464,19 @@ func TestUIDeleteRemovesRow(t *testing.T) {
if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
t.Fatalf("delete body = %q, want the out-of-band badge", body)
}
if _, ok, _ := store.Get("asura:solo"); ok {
if _, ok, _ := st.Get("asura:solo"); ok {
t.Fatal("row still present after delete")
}
}
func TestUIListFavouritesTab(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
})
seed(t, store, Bookmark{
seed(t, st, store.Bookmark{
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
})
@@ -493,14 +497,14 @@ func TestUIListFavouritesTab(t *testing.T) {
func TestUIListNewTab(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo",
Title: "Solo Leveling", LastChapterNum: 10,
LatestChapter: "Chapter 12", LatestChapterNum: floatPtr(12),
UpdatedAt: 2_000_000,
})
seed(t, store, Bookmark{
seed(t, st, store.Bookmark{
Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
Title: "Tower of God", LastChapterNum: 5,
LatestChapter: "Chapter 5", LatestChapterNum: floatPtr(5),
@@ -524,22 +528,22 @@ func TestUIListNewTab(t *testing.T) {
// seedStatusRows puts one series in each bucket, the archived one also
// favourited and with a new chapter out, so a leak into any reading-bucket tab
// shows up as a failure rather than passing by accident.
func seedStatusRows(t *testing.T, store *Store) {
func seedStatusRows(t *testing.T, st *store.Store) {
t.Helper()
// floatPtr already exists in store_test.go — same package, reuse it.
rows := []Bookmark{
rows := []store.Bookmark{
{Key: "asura:reading", Site: "asura", SeriesID: "reading", Title: "ReadingOne",
Status: statusReading, LastChapterNum: 10, Favorite: true,
Status: store.StatusReading, LastChapterNum: 10, Favorite: true,
LatestChapter: "11", LatestChapterNum: floatPtr(11)},
{Key: "asura:archived", Site: "asura", SeriesID: "archived", Title: "ArchivedOne",
Status: statusArchived, LastChapterNum: 5, Favorite: true,
Status: store.StatusArchived, LastChapterNum: 5, Favorite: true,
LatestChapter: "99", LatestChapterNum: floatPtr(99)},
{Key: "asura:finished", Site: "asura", SeriesID: "finished", Title: "FinishedOne",
Status: statusFinished, LastChapterNum: 200, Favorite: true},
Status: store.StatusFinished, LastChapterNum: 200, Favorite: true},
}
for _, b := range rows {
b.UpdatedAt = time.Now().UnixMilli()
if _, err := store.Upsert(b); err != nil {
if _, err := st.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
@@ -547,8 +551,8 @@ func seedStatusRows(t *testing.T, store *Store) {
func TestTabsShowOnlyTheirBucket(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
cases := []struct {
tab string
@@ -604,16 +608,16 @@ func stripOf(t *testing.T, srv http.Handler, cfg Config, tab string) string {
// updated_at-ordered list below it does not already say — and only on All.
func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store) // ReadingOne is at 10 with 11 out; the rest are not reading
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st) // ReadingOne is at 10 with 11 out; the rest are not reading
// A reading series that is caught up has nothing waiting, so it stays out.
caught := Bookmark{
caught := store.Bookmark{
Key: "asura:caught", Site: "asura", SeriesID: "caught", Title: "CaughtUpOne",
Status: statusReading, LastChapterNum: 40, LatestChapter: "40",
Status: store.StatusReading, LastChapterNum: 40, LatestChapter: "40",
LatestChapterNum: floatPtr(40), UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(caught); err != nil {
if _, err := st.Upsert(caught); err != nil {
t.Fatalf("seed %s: %v", caught.Key, err)
}
@@ -633,12 +637,12 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
}
// Nothing new anywhere: the strip has nothing to say and does not render.
reading, _, err := store.Get("asura:reading")
reading, _, err := st.Get("asura:reading")
if err != nil {
t.Fatalf("Get: %v", err)
}
reading.LatestChapterNum = floatPtr(reading.LastChapterNum)
if _, err := store.Upsert(reading); err != nil {
if _, err := st.Upsert(reading); err != nil {
t.Fatalf("Upsert: %v", err)
}
// The section still ships (an out-of-band swap needs the id to exist) but
@@ -652,23 +656,23 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
}
}
// The strip never grows past recentCount, however many series are waiting.
// The strip never grows past web.RecentCount, however many series are waiting.
func TestRecentStripCapped(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
for i := 0; i <= recentCount; i++ {
b := Bookmark{
srv, st := newWebTestServer(t, cfg)
for i := 0; i <= web.RecentCount; i++ {
b := store.Bookmark{
Key: fmt.Sprintf("asura:new%d", i), Site: "asura",
SeriesID: fmt.Sprintf("new%d", i), Title: fmt.Sprintf("Waiting%d", i),
Status: statusReading, LastChapterNum: 1, LatestChapter: "2",
Status: store.StatusReading, LastChapterNum: 1, LatestChapter: "2",
LatestChapterNum: floatPtr(2), UpdatedAt: time.Now().UnixMilli() + int64(i),
}
if _, err := store.Upsert(b); err != nil {
if _, err := st.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != recentCount {
t.Fatalf("strip rendered %d cards, want %d", got, recentCount)
if got := strings.Count(stripOf(t, srv, cfg, "all"), "recent-card"); got != web.RecentCount {
t.Fatalf("strip rendered %d cards, want %d", got, web.RecentCount)
}
}
@@ -686,14 +690,14 @@ func postStatus(t *testing.T, srv http.Handler, cfg Config, key, status string)
func TestUIStatusSetsBucket(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
for _, want := range []string{statusArchived, statusFinished, statusReading} {
for _, want := range []string{store.StatusArchived, store.StatusFinished, store.StatusReading} {
if rr := postStatus(t, srv, cfg, "asura:reading", want); rr.Code != http.StatusOK {
t.Fatalf("set %s: status = %d, body %s", want, rr.Code, rr.Body.String())
}
b, ok, err := store.Get("asura:reading")
b, ok, err := st.Get("asura:reading")
if err != nil || !ok {
t.Fatalf("Get: ok=%v err=%v", ok, err)
}
@@ -705,21 +709,21 @@ func TestUIStatusSetsBucket(t *testing.T) {
func TestUIStatusRejectsUnknownValue(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
if rr := postStatus(t, srv, cfg, "asura:reading", "dropped"); rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
b, _, _ := store.Get("asura:reading")
if b.Status != statusReading {
b, _, _ := st.Get("asura:reading")
if b.Status != store.StatusReading {
t.Fatalf("stored status = %q, want it untouched", b.Status)
}
}
func TestUIStatusRequiresSession(t *testing.T) {
srv, store := newWebTestServer(t, webConfig())
seedStatusRows(t, store)
srv, st := newWebTestServer(t, webConfig())
seedStatusRows(t, st)
req := httptest.NewRequest(http.MethodPost, "/ui/bookmarks/asura:reading/status",
strings.NewReader("status=archived"))
@@ -734,15 +738,15 @@ func TestUIStatusRequiresSession(t *testing.T) {
func TestUIStatusDoesNotReorderList(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
before, _, _ := store.Get("asura:reading")
before, _, _ := st.Get("asura:reading")
time.Sleep(2 * time.Millisecond)
if rr := postStatus(t, srv, cfg, "asura:reading", statusArchived); rr.Code != http.StatusOK {
if rr := postStatus(t, srv, cfg, "asura:reading", store.StatusArchived); rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
after, _, _ := store.Get("asura:reading")
after, _, _ := st.Get("asura:reading")
if after.UpdatedAt != before.UpdatedAt {
t.Fatalf("UpdatedAt moved %d -> %d", before.UpdatedAt, after.UpdatedAt)
}
@@ -750,8 +754,8 @@ func TestUIStatusDoesNotReorderList(t *testing.T) {
func TestCardShowsStatusControls(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
cases := []struct {
tab string
@@ -788,8 +792,8 @@ func TestCardShowsStatusControls(t *testing.T) {
func TestAppRendersNewTabs(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
srv, st := newWebTestServer(t, cfg)
seedStatusRows(t, st)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(sessionCookie(t, cfg))
@@ -807,10 +811,10 @@ func TestAppRendersNewTabs(t *testing.T) {
// outside the swapped card, so nothing else would correct them.
func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
seed(t, store, Bookmark{
srv, st := newWebTestServer(t, cfg)
seed(t, st, store.Bookmark{
Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
Status: statusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
Status: store.StatusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
})
@@ -820,7 +824,7 @@ func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
}
req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
url.Values{"status": {statusArchived}})
url.Values{"status": {store.StatusArchived}})
req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)