diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index e720644..58f27ca 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -148,6 +148,7 @@ func parseTmpl(files ...string) *template.Template { } return template.JS("[" + strings.Join(vals, ",") + "]") }, + "contains": strings.Contains, }).ParseFS(templateFS, files...)) } @@ -182,6 +183,7 @@ var ( autoImportTmpl = parseTmpl("templates/base.html", "templates/auto_import.html") peopleTmpl = parseTmpl("templates/base.html", "templates/people.html") settingsTmpl = parseTmpl("templates/base.html", "templates/settings.html") + accountTmpl = parseTmpl("templates/base.html", "templates/account.html") // Org — list/create/join stay on personal base; inner org pages use business base orgListTmpl = parseTmpl("templates/base.html", "templates/org_list.html") @@ -353,10 +355,14 @@ type storeIface interface { // Auth createAuthUser(ctx context.Context, u *AuthUser) error findAuthUserByEmail(ctx context.Context, email string) (*AuthUser, error) + findAuthUserByID(ctx context.Context, userID string) (*AuthUser, error) findAuthUserByProvider(ctx context.Context, provider, providerID string) (*AuthUser, error) createAuthSession(ctx context.Context, sess *AuthSession) error getAuthSession(ctx context.Context, id string) (*AuthSession, error) deleteAuthSession(ctx context.Context, id string) error + getSessionsByUserID(ctx context.Context, userID string) ([]AuthSession, error) + deleteSessionForUser(ctx context.Context, sessionID, userID string) error + deleteAllUserData(ctx context.Context, userID string) error } type Handler struct { @@ -2767,6 +2773,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /auth/logout", h.AuthLogout) mux.HandleFunc("GET /auth/oauth/google", h.AuthGoogleStart) mux.HandleFunc("GET /auth/oauth/google/callback", h.AuthGoogleCallback) + mux.HandleFunc("GET /account", h.authMW(h.AccountPage)) + mux.HandleFunc("POST /account/delete", h.authMW(h.DeleteAccount)) + mux.HandleFunc("DELETE /sessions/{id}", h.authMW(h.RevokeSession)) mux.HandleFunc("GET /{$}", h.Homepage) mux.HandleFunc("GET /dashboard", h.authMW(h.Dashboard)) diff --git a/apps/finance/services/api/main/handler_auth.go b/apps/finance/services/api/main/handler_auth.go index 91247c8..c471a73 100644 --- a/apps/finance/services/api/main/handler_auth.go +++ b/apps/finance/services/api/main/handler_auth.go @@ -163,6 +163,38 @@ func (h *Handler) authFromSession(r *http.Request) (authInfo, bool) { return authInfo{UserID: sess.UserID.Hex(), Email: sess.Email}, true } +func deviceHint(ua string) string { + lower := strings.ToLower(ua) + browser := "Unknown browser" + switch { + case strings.Contains(lower, "edg"): + browser = "Edge" + case strings.Contains(lower, "chrome"): + browser = "Chrome" + case strings.Contains(lower, "firefox"): + browser = "Firefox" + case strings.Contains(lower, "safari"): + browser = "Safari" + } + os := "" + switch { + case strings.Contains(lower, "iphone"): + os = "iPhone" + case strings.Contains(lower, "android"): + os = "Android" + case strings.Contains(lower, "windows"): + os = "Windows" + case strings.Contains(lower, "mac os"): + os = "macOS" + case strings.Contains(lower, "linux"): + os = "Linux" + } + if os != "" { + return browser + " on " + os + } + return browser +} + func (h *Handler) startSession(w http.ResponseWriter, r *http.Request, userID bson.ObjectID, email string) error { // Rotate: delete any existing session to prevent session fixation. if cookie, err := r.Cookie(cookieName); err == nil { @@ -174,6 +206,8 @@ func (h *Handler) startSession(w http.ResponseWriter, r *http.Request, userID bs UserID: userID, Email: email, ExpiresAt: time.Now().Add(sessionTTL), + IPAddress: clientIP(r), + Device: deviceHint(r.Header.Get("User-Agent")), } if err := h.store.createAuthSession(r.Context(), sess); err != nil { return err @@ -215,9 +249,14 @@ func (h *Handler) AuthLogin(w http.ResponseWriter, r *http.Request) { if r.URL.Query().Get("error") == "oauth" { errMsg = "Google sign-in failed. Please try again or use email and password." } + successMsg := "" + if r.URL.Query().Get("deleted") == "1" { + successMsg = h.t(r).Get("account.delete.success_login") + } renderRaw(w, authLoginTmpl, map[string]any{ "GoogleEnabled": h.googleID != "", "Error": errMsg, + "Success": successMsg, "T": h.t(r), }) } @@ -523,3 +562,134 @@ func (h *Handler) googleUserInfo(ctx context.Context, accessToken string) (*goog } return &u, nil } + +// ── Account / security page ─────────────────────────────────────────────────── + +func (h *Handler) AccountPage(w http.ResponseWriter, r *http.Request) { + a := h.getAuth(r) + if a.UserID == "" { + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + return + } + t := h.t(r) + + // Current session ID (to highlight it in the list) + currentID := "" + if cookie, err := r.Cookie(cookieName); err == nil { + currentID, _ = h.verifySessionToken(cookie.Value) + } + + sessions, _ := h.store.getSessionsByUserID(r.Context(), a.UserID) + var views []SessionView + for _, s := range sessions { + views = append(views, SessionView{ + ID: s.ID.Hex(), + CreatedAt: s.CreatedAt, + IPAddress: s.IPAddress, + Device: s.Device, + IsCurrent: s.ID.Hex() == currentID, + }) + } + + user, _ := h.store.findAuthUserByID(r.Context(), a.UserID) + + render(w, accountTmpl, AccountData{ + T: t, + UserID: a.UserID, + Email: a.Email, + Title: t.Get("account.title"), + Route: "account", + Sessions: views, + HasPassword: user != nil && user.PasswordHash != "", + Success: r.URL.Query().Get("success"), + }) +} + +func (h *Handler) RevokeSession(w http.ResponseWriter, r *http.Request) { + a := h.getAuth(r) + if a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + sessionID := r.PathValue("id") + // Prevent revoking your own current session via this endpoint (use logout instead) + if cookie, err := r.Cookie(cookieName); err == nil { + if cur, ok := h.verifySessionToken(cookie.Value); ok && cur == sessionID { + http.Error(w, "use /auth/logout to end your current session", http.StatusBadRequest) + return + } + } + _ = h.store.deleteSessionForUser(r.Context(), sessionID, a.UserID) + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) { + a := h.getAuth(r) + if a.UserID == "" { + http.Redirect(w, r, "/auth/login", http.StatusSeeOther) + return + } + t := h.t(r) + + fail := func(msg string) { + sessions, _ := h.store.getSessionsByUserID(r.Context(), a.UserID) + var views []SessionView + for _, s := range sessions { + views = append(views, SessionView{ + ID: s.ID.Hex(), + CreatedAt: s.CreatedAt, + IPAddress: s.IPAddress, + Device: s.Device, + }) + } + user, _ := h.store.findAuthUserByID(r.Context(), a.UserID) + render(w, accountTmpl, AccountData{ + T: t, + UserID: a.UserID, + Email: a.Email, + Title: t.Get("account.title"), + Route: "account", + Sessions: views, + HasPassword: user != nil && user.PasswordHash != "", + Error: msg, + }) + } + + user, err := h.store.findAuthUserByID(r.Context(), a.UserID) + if err != nil || user == nil { + fail(t.Get("account.delete.error_generic")) + return + } + + // Password accounts require password confirmation; OAuth accounts require typing email. + if user.PasswordHash != "" { + password := r.FormValue("password") + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + fail(t.Get("account.delete.error_wrong_password")) + return + } + } else { + confirm := r.FormValue("confirm_email") + if !strings.EqualFold(confirm, user.Email) { + fail(t.Get("account.delete.error_wrong_email")) + return + } + } + + if err := h.store.deleteAllUserData(r.Context(), a.UserID); err != nil { + slog.Error("deleteAllUserData", "user", a.UserID, "err", err) + fail(t.Get("account.delete.error_generic")) + return + } + + // Clear the session cookie. + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: h.isSecure(), + MaxAge: -1, + }) + http.Redirect(w, r, "/auth/login?deleted=1", http.StatusSeeOther) +} diff --git a/apps/finance/services/api/main/handler_coverage_test.go b/apps/finance/services/api/main/handler_coverage_test.go new file mode 100644 index 0000000..53c77c2 --- /dev/null +++ b/apps/finance/services/api/main/handler_coverage_test.go @@ -0,0 +1,3327 @@ +package main + +// Additional tests to raise coverage above 80%. +// These live in a separate file to keep handler_test.go manageable. + +import ( + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// ── deviceHint ──────────────────────────────────────────────────────────────── + +func TestDeviceHint(t *testing.T) { + cases := []struct { + ua string + want string + }{ + { + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Safari on iPhone", + }, + { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Chrome on Windows", + }, + { + "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0", + "Firefox on Linux", + }, + { + "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "Chrome on Android", + }, + { + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Safari on macOS", + }, + { + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", + "Edge on Windows", + }, + {"", "Unknown browser"}, + } + for _, tc := range cases { + t.Run(tc.want, func(t *testing.T) { + got := deviceHint(tc.ua) + if got != tc.want { + t.Errorf("deviceHint(%q) = %q, want %q", tc.ua, got, tc.want) + } + }) + } +} + +// ── clientIP ───────────────────────────────────────────────────────────────── + +func TestClientIP(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("X-Forwarded-For", "203.0.113.1, 10.0.0.1") + if got := clientIP(r); got != "203.0.113.1" { + t.Errorf("clientIP with XFF = %q, want 203.0.113.1", got) + } + + r2 := httptest.NewRequest("GET", "/", nil) + r2.RemoteAddr = "198.51.100.5:12345" + got2 := clientIP(r2) + if !strings.HasPrefix(got2, "198.51.100.5") { + t.Errorf("clientIP with RemoteAddr = %q, want prefix 198.51.100.5", got2) + } +} + +// ── sortWaterfallRows ───────────────────────────────────────────────────────── + +func TestSortWaterfallRows(t *testing.T) { + byCat := map[string]int64{"Food": 5000, "Housing": 20000, "Transport": 2000} + colors := map[string]string{"Food": "#f00", "Housing": "#0f0", "Transport": "#00f"} + rows := sortWaterfallRows(byCat, colors) + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + for i := 1; i < len(rows); i++ { + if rows[i].Cents > rows[i-1].Cents { + t.Errorf("rows not sorted desc at index %d: %d > %d", i, rows[i].Cents, rows[i-1].Cents) + } + } +} + +// ── txnFingerprint ──────────────────────────────────────────────────────────── + +func TestTxnFingerprint(t *testing.T) { + fp1 := txnFingerprint("2024-01-01", "Coffee", -500, "acc1") + fp2 := txnFingerprint("2024-01-01", "Coffee", -500, "acc1") + if fp1 != fp2 { + t.Error("identical inputs must produce identical fingerprint") + } + fp3 := txnFingerprint("2024-01-01", "Coffee", -600, "acc1") + if fp1 == fp3 { + t.Error("different amount must produce different fingerprint") + } + fp4 := txnFingerprint("2024-01-02", "Coffee", -500, "acc1") + if fp1 == fp4 { + t.Error("different date must produce different fingerprint") + } +} + +// ── AccountPage ─────────────────────────────────────────────────────────────── + +func TestAccountPage_NoUser(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AccountPage(w, authReq("GET", "/account", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), "Account") { + t.Error("expected account page content") + } +} + +func TestAccountPage_WithSessions(t *testing.T) { + store := &mockStore{ + sessions: []AuthSession{ + { + CreatedAt: time.Now().Add(-2 * time.Hour), + IPAddress: "10.0.0.1", + Device: "Chrome on macOS", + }, + }, + authUsers: map[string]*AuthUser{ + "user1": {Email: "test@example.com", PasswordHash: "hash"}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.AccountPage(w, authReq("GET", "/account", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), "10.0.0.1") { + t.Error("expected session IP in response") + } +} + +// ── RevokeSession ───────────────────────────────────────────────────────────── + +func TestRevokeSession(t *testing.T) { + h := newHandler(&mockStore{}) + r := authReq("DELETE", "/sessions/sess123", nil) + r.SetPathValue("id", "sess123") + w := httptest.NewRecorder() + h.RevokeSession(w, r) + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", w.Code) + } +} + +func TestRevokeSession_NoAuth(t *testing.T) { + h := newHandler(&mockStore{}) + r := httptest.NewRequest("DELETE", "/sessions/sess123", nil) // no auth headers + r.SetPathValue("id", "sess123") + w := httptest.NewRecorder() + h.RevokeSession(w, r) + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +// ── DeleteAccount ───────────────────────────────────────────────────────────── + +func TestDeleteAccount_PasswordSuccess(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), 4) + store := &mockStore{ + authUsers: map[string]*AuthUser{ + "user1": {Email: "test@example.com", PasswordHash: string(hash)}, + }, + } + h := newHandler(store) + form := url.Values{"password": {"secret123"}} + w := httptest.NewRecorder() + h.DeleteAccount(w, authReq("POST", "/account/delete", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } + if !strings.Contains(w.Header().Get("Location"), "deleted=1") { + t.Error("expected redirect to login?deleted=1") + } +} + +func TestDeleteAccount_WrongPassword(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), 4) + store := &mockStore{ + authUsers: map[string]*AuthUser{ + "user1": {Email: "test@example.com", PasswordHash: string(hash)}, + }, + } + h := newHandler(store) + form := url.Values{"password": {"wrong"}} + w := httptest.NewRecorder() + h.DeleteAccount(w, authReq("POST", "/account/delete", form)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error rendered)", w.Code) + } + if !strings.Contains(w.Body.String(), "wrong_password") && !strings.Contains(w.Body.String(), "password") { + t.Error("expected error message in response") + } +} + +func TestDeleteAccount_OAuthSuccess(t *testing.T) { + store := &mockStore{ + authUsers: map[string]*AuthUser{ + "user1": {Email: "test@example.com", PasswordHash: ""}, + }, + } + h := newHandler(store) + form := url.Values{"confirm_email": {"test@example.com"}} + w := httptest.NewRecorder() + h.DeleteAccount(w, authReq("POST", "/account/delete", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestDeleteAccount_WrongEmail(t *testing.T) { + store := &mockStore{ + authUsers: map[string]*AuthUser{ + "user1": {Email: "test@example.com", PasswordHash: ""}, + }, + } + h := newHandler(store) + form := url.Values{"confirm_email": {"wrong@example.com"}} + w := httptest.NewRecorder() + h.DeleteAccount(w, authReq("POST", "/account/delete", form)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error rendered)", w.Code) + } +} + +func TestDeleteAccount_UserNotFound(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"password": {"any"}} + w := httptest.NewRecorder() + h.DeleteAccount(w, authReq("POST", "/account/delete", form)) + // user not found → renders account page with error + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestDeleteAccount_StoreError(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("pass"), 4) + store := &mockStore{ + authUsers: map[string]*AuthUser{ + "user1": {Email: "test@example.com", PasswordHash: string(hash)}, + }, + deleteAllUserDataErr: fmt.Errorf("db down"), + } + h := newHandler(store) + form := url.Values{"password": {"pass"}} + w := httptest.NewRecorder() + h.DeleteAccount(w, authReq("POST", "/account/delete", form)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error rendered)", w.Code) + } +} + +// ── AuthLogin / AuthRegister / AuthLogout ────────────────────────────────────── + +func TestAuthLogin_GET(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthLogin(w, httptest.NewRequest("GET", "/auth/login", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestAuthRegister_GET(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthRegister(w, httptest.NewRequest("GET", "/auth/register", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestAuthLogout(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthLogout(w, httptest.NewRequest("POST", "/auth/logout", nil)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── Tax ─────────────────────────────────────────────────────────────────────── + +func TestTax_Empty(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.Tax(w, authReq("GET", "/tax", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestTax_WithData(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 300000, Category: "Income", Date: now.AddDate(0, -1, 0)}, + {ID: "t2", UserID: "user1", AmountCents: -5000, Category: "Transport", Date: now.AddDate(0, -1, 0)}, + {ID: "t3", UserID: "user1", AmountCents: -80000, Category: "Housing", Date: now.AddDate(0, -2, 0)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Tax(w, authReq("GET", "/tax", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), "Tax") { + t.Error("expected 'Tax' in response") + } +} + +func TestTax_WithYearFilter(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.Tax(w, authReq("GET", "/tax?year=2024", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── TaxExport ───────────────────────────────────────────────────────────────── + +func TestTaxExport_Empty(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.TaxExport(w, authReq("GET", "/tax/export", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "text/csv" { + t.Errorf("Content-Type = %q, want text/csv", ct) + } + if !strings.Contains(w.Body.String(), "Date,Description,Category,Amount") { + t.Error("expected CSV header in response") + } +} + +func TestTaxExport_WithTransactions(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: -5000, Category: "Transport", + Description: "Uber", Date: now.AddDate(0, -1, 0)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.TaxExport(w, authReq("GET", "/tax/export", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Settings ────────────────────────────────────────────────────────────────── + +func TestSettings_GET(t *testing.T) { + store := &mockStore{ + accounts: []Account{{ID: "a1", Name: "Main", Type: "checking"}}, + categories: []Category{ + {ID: "c1", Name: "Food", Color: "#f00", BudgetCents: 10000}, + }, + goals: []Goal{{ID: "g1", Name: "Holiday", TargetCents: 100000}}, + } + h := newHandler(store) + w := httptest.NewRecorder() + // Default tab is "accounts" — check that account name appears + h.Settings(w, authReq("GET", "/settings", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), "Main") { + t.Error("expected account name in settings") + } + + // Categories tab — check category name appears + w2 := httptest.NewRecorder() + h.Settings(w2, authReq("GET", "/settings?tab=categories", nil)) + if w2.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w2.Code) + } + if !strings.Contains(w2.Body.String(), "Food") { + t.Error("expected category name in settings categories tab") + } +} + +func TestSettings_TabCategories(t *testing.T) { + store := &mockStore{ + categories: []Category{{ID: "c1", Name: "Rent", Color: "#000", BudgetCents: 50000}}, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Settings(w, authReq("GET", "/settings?tab=categories", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── SetLang ─────────────────────────────────────────────────────────────────── + +func TestSetLang_PT(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"lang": {"pt"}} + r := authReq("POST", "/lang", form) + r.Header.Set("Referer", "/dashboard") + w := httptest.NewRecorder() + h.SetLang(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestSetLang_Invalid(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"lang": {"de"}} + r := authReq("POST", "/lang", form) + w := httptest.NewRecorder() + h.SetLang(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303 (still redirects even for unsupported lang)", w.Code) + } +} + +// ── AutoImport ──────────────────────────────────────────────────────────────── + +func TestAutoImport_GET(t *testing.T) { + store := &mockStore{ + accounts: []Account{{ID: "a1", Name: "Main", Type: "checking"}}, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.AutoImport(w, authReq("GET", "/auto-import", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Household ───────────────────────────────────────────────────────────────── + +func TestHousehold_GET_Empty(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.Household(w, authReq("GET", "/household", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestHousehold_POST(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"partner_email": {"partner@example.com"}} + w := httptest.NewRecorder() + h.Household(w, authReq("POST", "/household", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestHousehold_POST_MissingEmail(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"partner_email": {""}} + w := httptest.NewRecorder() + h.Household(w, authReq("POST", "/household", form)) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestHousehold_DELETE(t *testing.T) { + h := newHandler(&mockStore{}) + r := authReq("DELETE", "/household", nil) + w := httptest.NewRecorder() + h.Household(w, r) + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", w.Code) + } +} + +// ── People ──────────────────────────────────────────────────────────────────── + +func TestPeople_GET(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.People(w, authReq("GET", "/people", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestPeople_POST_Share(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"_action": {"share"}, "viewer_id": {"other-user"}} + w := httptest.NewRecorder() + h.People(w, authReq("POST", "/people", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestPeople_POST_Household(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"_action": {"household"}, "partner_email": {"partner@example.com"}} + w := httptest.NewRecorder() + h.People(w, authReq("POST", "/people", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestPeople_DELETE_Share(t *testing.T) { + store := &mockStore{ + permissions: []Permission{{ID: "p1", OwnerID: "user1", ViewerID: "other"}}, + } + h := newHandler(store) + r := authReq("DELETE", "/people/other?kind=share", nil) + r.SetPathValue("id", "other") + w := httptest.NewRecorder() + h.People(w, r) + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", w.Code) + } +} + +// ── SaveTickerMapping ──────────────────────────────────────────────────────── + +func TestSaveTickerMapping(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"isin": {"IE00B3WJKG14"}, "ticker": {"QDVE.DE"}} + w := httptest.NewRecorder() + h.SaveTickerMapping(w, authReq("POST", "/portfolio/ticker", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestSaveTickerMapping_MissingFields(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"isin": {"IE00B3WJKG14"}} // missing ticker + w := httptest.NewRecorder() + h.SaveTickerMapping(w, authReq("POST", "/portfolio/ticker", form)) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// ── Homepage ───────────────────────────────────────────────────────────────── + +func TestHomepage(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.Homepage(w, authReq("GET", "/", nil)) + // logged-in user is redirected to dashboard + if w.Code != http.StatusSeeOther && w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 or 303", w.Code) + } +} + +// ── Cross-page consistency ──────────────────────────────────────────────────── +// These tests verify that the same data source is reflected consistently +// across multiple pages (i.e., Dashboard, Transactions, Goals, Settings +// all use the same mock store and display consistent values). + +func TestConsistency_TransactionAppearsInBothPagesAndDashboard(t *testing.T) { + now := time.Now() + store := &mockStore{ + categories: []Category{{ID: "c1", UserID: "user1", Name: "Food", BudgetCents: 30000}}, + transactions: []Transaction{ + {ID: "income-1", UserID: "user1", AmountCents: 300000, Category: "Income", Date: now.AddDate(0, 0, -2)}, + {ID: "food-1", UserID: "user1", AmountCents: -12500, Category: "Food", Date: now.AddDate(0, 0, -1)}, + }, + } + h := newHandler(store) + + // Dashboard sees the spending + wd := httptest.NewRecorder() + h.Dashboard(wd, authReq("GET", "/", nil)) + if wd.Code != http.StatusOK { + t.Fatalf("dashboard status = %d", wd.Code) + } + if !strings.Contains(wd.Body.String(), "Food") { + t.Error("Dashboard should display Food category from the shared store") + } + + // Transactions page sees the same transactions + wt := httptest.NewRecorder() + h.Transactions(wt, authReq("GET", "/transactions", nil)) + if wt.Code != http.StatusOK { + t.Fatalf("transactions status = %d", wt.Code) + } + if !strings.Contains(wt.Body.String(), "food-1") { + t.Error("Transactions page should display the same transaction") + } +} + +func TestConsistency_CategoryBudgetInSettingsAndDashboard(t *testing.T) { + store := &mockStore{ + categories: []Category{ + {ID: "c1", UserID: "user1", Name: "Groceries", Color: "#4caf50", BudgetCents: 25000}, + }, + transactions: []Transaction{ + newTxn("income", "Income", 200000, 5), + newTxn("grocery-shop", "Groceries", -28000, 3), // over budget + }, + } + h := newHandler(store) + + // Settings page (categories tab) shows the category with budget + ws := httptest.NewRecorder() + h.Settings(ws, authReq("GET", "/settings?tab=categories", nil)) + if ws.Code != http.StatusOK { + t.Fatalf("settings status = %d", ws.Code) + } + if !strings.Contains(ws.Body.String(), "Groceries") { + t.Error("Settings should show Groceries category on categories tab") + } + + // Dashboard shows the same category exceeded budget + wd := httptest.NewRecorder() + h.Dashboard(wd, authReq("GET", "/dashboard", nil)) + if wd.Code != http.StatusOK { + t.Fatalf("dashboard status = %d", wd.Code) + } + if !strings.Contains(wd.Body.String(), "Groceries") { + t.Error("Dashboard should reflect the same Groceries category from Settings") + } +} + +func TestConsistency_CommittedGoalInGoalsAndDashboard(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "income", UserID: "user1", AmountCents: 300000, Category: "Income", Date: now.AddDate(0, 0, -1)}, + }, + goals: []Goal{{ + ID: "g1", + UserID: "user1", + Name: "NewCar", + TargetCents: 1500000, + SavedCents: 50000, + Deadline: now.AddDate(0, 12, 0), + Committed: true, + }}, + } + h := newHandler(store) + + // Goals page shows the goal + wg := httptest.NewRecorder() + h.Goals(wg, authReq("GET", "/goals", nil)) + if wg.Code != http.StatusOK { + t.Fatalf("goals status = %d", wg.Code) + } + if !strings.Contains(wg.Body.String(), "NewCar") { + t.Error("Goals page should display the committed goal") + } + + // Dashboard shows the committed goal in the widget + wd := httptest.NewRecorder() + h.Dashboard(wd, authReq("GET", "/dashboard", nil)) + if wd.Code != http.StatusOK { + t.Fatalf("dashboard status = %d", wd.Code) + } + if !strings.Contains(wd.Body.String(), "NewCar") { + t.Error("Dashboard widget should display the same committed goal") + } +} + +func TestConsistency_CreateTransactionThenRead(t *testing.T) { + store := &mockStore{ + accounts: []Account{{ID: "acc1", UserID: "user1", Name: "Main"}}, + } + h := newHandler(store) + + // Create transaction via API + body := `{"account_id":"acc1","date":"2024-06-01","description":"Spotify","amount_cents":-999,"category":"Entertainment"}` + r := authReq("POST", "/api/transactions", nil) + r.Body = bodyReader(body) + r.Header.Set("Content-Type", "application/json") + wc := httptest.NewRecorder() + h.CreateTransaction(wc, r) + if wc.Code != http.StatusCreated { + t.Fatalf("create status = %d, body = %s", wc.Code, wc.Body.String()) + } + + // The transaction must now be in the store + if len(store.transactions) != 1 { + t.Fatalf("expected 1 transaction in store after create, got %d", len(store.transactions)) + } + + // Transactions page reads from the same store and should show it + wt := httptest.NewRecorder() + h.Transactions(wt, authReq("GET", "/transactions", nil)) + if wt.Code != http.StatusOK { + t.Fatalf("transactions status = %d", wt.Code) + } + if !strings.Contains(wt.Body.String(), "Spotify") { + t.Error("Transactions page should display the newly created transaction") + } +} + +func TestConsistency_GoalProgressInDashboardAndGoalsPage(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "income", UserID: "user1", AmountCents: 500000, Category: "Income", Date: now.AddDate(0, 0, -3)}, + }, + goals: []Goal{ + { + ID: "g1", UserID: "user1", Name: "Vacation", + TargetCents: 200000, SavedCents: 80000, + Deadline: now.AddDate(0, 4, 0), Committed: true, + }, + { + ID: "g2", UserID: "user1", Name: "Laptop", + TargetCents: 150000, SavedCents: 0, + Deadline: now.AddDate(0, 6, 0), Committed: false, + }, + }, + } + h := newHandler(store) + + // Goals page shows both goals + wg := httptest.NewRecorder() + h.Goals(wg, authReq("GET", "/goals", nil)) + if wg.Code != http.StatusOK { + t.Fatalf("goals status = %d", wg.Code) + } + body := wg.Body.String() + if !strings.Contains(body, "Vacation") || !strings.Contains(body, "Laptop") { + t.Error("Goals page should show both goals") + } + + // Dashboard shows only committed goals in widget + wd := httptest.NewRecorder() + h.Dashboard(wd, authReq("GET", "/dashboard", nil)) + if wd.Code != http.StatusOK { + t.Fatalf("dashboard status = %d", wd.Code) + } + dashBody := wd.Body.String() + if !strings.Contains(dashBody, "Vacation") { + t.Error("Dashboard should show the committed goal (Vacation)") + } +} + +func TestConsistency_ReportsAndDashboardUseSameCategoryData(t *testing.T) { + now := time.Now() + store := &mockStore{ + categories: []Category{ + {ID: "c1", UserID: "user1", Name: "Utilities", Color: "#00f", BudgetCents: 15000}, + }, + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 400000, Category: "Income", Date: now.AddDate(0, 0, -5)}, + {ID: "t2", UserID: "user1", AmountCents: -12000, Category: "Utilities", Date: now.AddDate(0, 0, -3)}, + }, + } + h := newHandler(store) + + // Dashboard uses categories for budget tracking + wd := httptest.NewRecorder() + h.Dashboard(wd, authReq("GET", "/", nil)) + if wd.Code != http.StatusOK { + t.Fatalf("dashboard status = %d", wd.Code) + } + if !strings.Contains(wd.Body.String(), "Utilities") { + t.Error("Dashboard should display Utilities category") + } + + // Reports uses same categories + wr := httptest.NewRecorder() + h.Reports(wr, authReq("GET", "/reports", nil)) + if wr.Code != http.StatusOK { + t.Fatalf("reports status = %d", wr.Code) + } + if !strings.Contains(wr.Body.String(), "Utilities") { + t.Error("Reports should display the same Utilities category") + } +} + +// bodyReader returns a ReadCloser for the given string body. +func bodyReader(s string) io.ReadCloser { + return io.NopCloser(strings.NewReader(s)) +} + +// ── authLoginPost success path ──────────────────────────────────────────────── + +func TestAuthLoginPost_Success(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), 4) + store := &mockStore{ + authUsers: map[string]*AuthUser{ + "user1": {Email: "user@example.com", PasswordHash: string(hash)}, + }, + } + h := newHandler(store) + form := url.Values{"email": {"user@example.com"}, "password": {"password123"}} + r := httptest.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthLogin(w, r) + // Should redirect to /dashboard after successful login + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestAuthLoginPost_WrongPassword(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), 4) + store := &mockStore{ + authUsers: map[string]*AuthUser{ + "user1": {Email: "user@example.com", PasswordHash: string(hash)}, + }, + } + h := newHandler(store) + form := url.Values{"email": {"user@example.com"}, "password": {"wrong"}} + r := httptest.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthLogin(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error page)", w.Code) + } +} + +func TestAuthLoginPost_MissingEmail(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"email": {""}, "password": {"pass"}} + r := httptest.NewRequest("POST", "/auth/login", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthLogin(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error page)", w.Code) + } +} + +func TestAuthLoginPost_ErrorQuery(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthLogin(w, httptest.NewRequest("GET", "/auth/login?error=oauth", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── authRegisterPost ────────────────────────────────────────────────────────── + +func TestAuthRegisterPost_Success(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{ + "email": {"new@example.com"}, + "name": {"New User"}, + "password": {"securepass"}, + "confirm": {"securepass"}, + } + r := httptest.NewRequest("POST", "/auth/register", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthRegister(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303 (redirect to dashboard): %s", w.Code, w.Body.String()) + } +} + +func TestAuthRegisterPost_PasswordMismatch(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{ + "email": {"new@example.com"}, + "password": {"securepass"}, + "confirm": {"different"}, + } + r := httptest.NewRequest("POST", "/auth/register", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthRegister(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error rendered)", w.Code) + } +} + +func TestAuthRegisterPost_TooShort(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{ + "email": {"new@example.com"}, + "password": {"short"}, + "confirm": {"short"}, + } + r := httptest.NewRequest("POST", "/auth/register", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthRegister(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error rendered)", w.Code) + } +} + +func TestAuthRegisterPost_ExistingUser(t *testing.T) { + store := &mockStore{ + authUsers: map[string]*AuthUser{ + "u1": {Email: "existing@example.com", PasswordHash: "hash"}, + }, + } + h := newHandler(store) + form := url.Values{ + "email": {"existing@example.com"}, + "password": {"goodpassword"}, + "confirm": {"goodpassword"}, + } + r := httptest.NewRequest("POST", "/auth/register", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthRegister(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error: email taken)", w.Code) + } +} + +func TestAuthRegisterPost_MissingFields(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"email": {""}, "password": {""}} + r := httptest.NewRequest("POST", "/auth/register", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthRegister(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (error rendered)", w.Code) + } +} + +// ── OrgRequestDetail: found ─────────────────────────────────────────────────── + +func TestOrgRequestDetail_Found(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxPurchaseOrder, AmountCents: 50000, + Description: "Laptop purchase", + StatusLog: []StatusLogEntry{{Status: TxDraft}}, + }} + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/requests/req1", "acme", nil) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestDetail(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + +// ── OrgRequestAction: many actions ─────────────────────────────────────────── + +func newOrgStoreWithRequest(txType TxRequestType, status TxRequestStatus) *mockStore { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: txType, AmountCents: 50000, + FiscalYearID: "fy1", + StatusLog: []StatusLogEntry{{Status: status}}, + }} + return store +} + +func doOrgAction(t *testing.T, store *mockStore, action string, extra url.Values) int { + t.Helper() + h := newHandler(store) + form := url.Values{"action": {action}} + for k, vs := range extra { + form[k] = vs + } + r := orgReq("POST", "/orgs/acme/requests/req1/action", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestAction(w, r) + return w.Code +} + +func TestOrgRequestAction_Approve(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxSubmitted), "approve", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_Reject(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxSubmitted), "reject", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_Review(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxSubmitted), "review", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_RequestInfo(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxSubmitted), "request_info", url.Values{"comment": {"Need more details"}}) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_RequestInfo_NoComment(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxSubmitted), "request_info", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +func TestOrgRequestAction_MarkPaid(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "mark_paid", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_MarkPaid_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxApproved), "mark_paid", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +func TestOrgRequestAction_MarkOrdered(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxApproved), "mark_ordered", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_MarkDelivered(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxOrdered), "mark_delivered", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_Dispute(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxOrdered), "dispute", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_Disburse(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxCashAdvance, TxApproved), "disburse", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_SettlementDue(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxCashAdvance, TxDisbursed), "settlement_due", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_MarkPendingPayment(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxIncome, TxApproved), "mark_pending_payment", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_MarkReceived(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxIncome, TxPendingPayment), "mark_received", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_Reconcile(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "reconcile", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_Done(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxBudgetTransfer, TxApproved), "done", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +func TestOrgRequestAction_Unknown(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxDraft), "fly_to_moon", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +func TestOrgRequestAction_CancelNonCancellable(t *testing.T) { + // Approved request cannot be cancelled + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxApproved), "cancel", nil) + if code != http.StatusConflict { + t.Errorf("status = %d, want 409", code) + } +} + +func TestOrgRequestAction_SubmitNonDraft(t *testing.T) { + // Submitted request cannot be re-submitted + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxSubmitted), "submit", nil) + if code != http.StatusConflict { + t.Errorf("status = %d, want 409", code) + } +} + +// ── OrgRequestDelivery ──────────────────────────────────────────────────────── + +func TestOrgRequestDelivery_Success(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxPurchaseOrder, AmountCents: 50000, + StatusLog: []StatusLogEntry{{Status: TxOrdered}}, + }} + h := newHandler(store) + form := url.Values{ + "actual_amount": {"45.00"}, + "actual_vendor": {"ACME Supplier"}, + } + r := orgReq("POST", "/orgs/acme/requests/req1/delivery", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestDelivery(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgRequestDelivery_NotOrdered(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxPurchaseOrder, AmountCents: 50000, + StatusLog: []StatusLogEntry{{Status: TxApproved}}, + }} + h := newHandler(store) + form := url.Values{"actual_amount": {"45.00"}} + r := orgReq("POST", "/orgs/acme/requests/req1/delivery", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestDelivery(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409", w.Code) + } +} + +func TestOrgRequestDelivery_BadAmount(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxPurchaseOrder, AmountCents: 50000, + StatusLog: []StatusLogEntry{{Status: TxOrdered}}, + }} + h := newHandler(store) + form := url.Values{"actual_amount": {"not-a-number"}} + r := orgReq("POST", "/orgs/acme/requests/req1/delivery", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestDelivery(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// ── OrgRequestSettle ───────────────────────────────────────────────────────── + +func TestOrgRequestSettle_Success(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxCashAdvance, AmountCents: 100000, + StatusLog: []StatusLogEntry{{Status: TxDisbursed}}, + }} + h := newHandler(store) + form := url.Values{ + "amount_spent": {"80.00"}, + "amount_returned": {"20.00"}, + } + r := orgReq("POST", "/orgs/acme/requests/req1/settle", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestSettle(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgRequestSettle_BadAmount(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxCashAdvance, AmountCents: 100000, + StatusLog: []StatusLogEntry{{Status: TxDisbursed}}, + }} + h := newHandler(store) + form := url.Values{"amount_spent": {"not-a-number"}} + r := orgReq("POST", "/orgs/acme/requests/req1/settle", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestSettle(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestOrgRequestSettle_WrongStatus(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxCashAdvance, AmountCents: 100000, + StatusLog: []StatusLogEntry{{Status: TxApproved}}, + }} + h := newHandler(store) + form := url.Values{"amount_spent": {"80.00"}} + r := orgReq("POST", "/orgs/acme/requests/req1/settle", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestSettle(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409", w.Code) + } +} + +// ── OrgBankImport: multipart CSV upload ────────────────────────────────────── + +func buildCSVMultipart(csvContent, extraField, extraValue string) (*bytes.Buffer, string) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("csv", "bank.csv") + fw.Write([]byte(csvContent)) + if extraField != "" { + mw.WriteField(extraField, extraValue) + } + mw.Close() + return &buf, mw.FormDataContentType() +} + +func TestOrgBankImport_POST_Preview(t *testing.T) { + h := newHandler(newOrgStore()) + body, ct := buildCSVMultipart("date,description,amount\n2025-01-15,Coffee,-15.00\n", "", "") + r := httptest.NewRequest("POST", "/orgs/acme/bank-import", body) + r.Header.Set("Content-Type", ct) + r.Header.Set("X-Auth-User-Id", "user1") + r.Header.Set("X-Auth-Email", "test@example.com") + r.SetPathValue("slug", "acme") + w := httptest.NewRecorder() + h.OrgBankImport(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +func TestOrgBankImport_POST_Confirmed(t *testing.T) { + h := newHandler(newOrgStore()) + body, ct := buildCSVMultipart("date,description,amount\n2025-01-15,Coffee,-15.00\n", "confirm", "1") + r := httptest.NewRequest("POST", "/orgs/acme/bank-import", body) + r.Header.Set("Content-Type", ct) + r.Header.Set("X-Auth-User-Id", "user1") + r.Header.Set("X-Auth-Email", "test@example.com") + r.SetPathValue("slug", "acme") + w := httptest.NewRecorder() + h.OrgBankImport(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +func TestOrgBankImport_POST_NoFile(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/bank-import", "acme", url.Values{"confirm": {"0"}}) + w := httptest.NewRecorder() + h.OrgBankImport(w, r) + // Either 400 (no multipart) or 400 (no file) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +func TestOrgBankImport_POST_InvalidCSV(t *testing.T) { + h := newHandler(newOrgStore()) + body, ct := buildCSVMultipart("not,valid\n", "", "") + r := httptest.NewRequest("POST", "/orgs/acme/bank-import", body) + r.Header.Set("Content-Type", ct) + r.Header.Set("X-Auth-User-Id", "user1") + r.Header.Set("X-Auth-Email", "test@example.com") + r.SetPathValue("slug", "acme") + w := httptest.NewRecorder() + h.OrgBankImport(w, r) + // Invalid CSV → re-render form with error + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── SearchUsers ─────────────────────────────────────────────────────────────── + +func TestSearchUsers_VeryShortQuery(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.SearchUsers(w, authReq("GET", "/api/users?q=a", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestSearchUsers_EmptyQuery(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.SearchUsers(w, authReq("GET", "/api/users", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestSearchUsers_WithQuery(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + // Real HTTP call to http://users will fail — handler returns empty array gracefully + h.SearchUsers(w, authReq("GET", "/api/users?q=alice", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), "[]") && !strings.Contains(w.Body.String(), "null") { + t.Logf("body: %s", w.Body.String()) + } +} + +// ── OrgRequestList: with status filter ─────────────────────────────────────── + +func TestOrgRequestList_WithStatusFilter(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/requests?status=submitted", "acme", nil) + w := httptest.NewRecorder() + h.OrgRequestList(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgFiscalYearClose: success ─────────────────────────────────────────────── + +func TestOrgFiscalYearClose_WithActiveYear(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/years/fy1/close", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgFiscalYearClose(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgReport: with ledger entries ─────────────────────────────────────────── + +func TestOrgReport_WithLedgerEntries(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Conference", Status: EventApproved}} + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/report", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgReport(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), "Conference") { + t.Error("expected event name in report") + } +} + +// ── OrgEventList: with events ───────────────────────────────────────────────── + +func TestOrgEventList_WithEvents(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Annual Gala", Status: EventDraft}, + {ID: "evt2", OrgID: "org1", FiscalYearID: "fy1", Name: "Workshop", Status: EventApproved}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/years/fy1/events", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventList(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgRequestNew: BudgetTransfer type ─────────────────────────────────────── + +func TestOrgRequestNew_BudgetTransfer(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "type": {"budget_transfer"}, + "amount": {"500"}, + "from_budget_line_id": {"bl1"}, + "to_budget_line_id": {"bl2"}, + } + r := orgReq("POST", "/orgs/acme/requests/new", "acme", form) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgRequestNew: with due date ───────────────────────────────────────────── + +func TestOrgRequestNew_WithDueDate(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "type": {"reimbursement"}, + "amount": {"100"}, + "due_date": {"2025-06-30"}, + } + r := orgReq("POST", "/orgs/acme/requests/new", "acme", form) + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── People: DELETE household ────────────────────────────────────────────────── + +func TestPeople_DELETE_Household(t *testing.T) { + h := newHandler(&mockStore{}) + r := authReq("DELETE", "/people/partner-id?kind=household", nil) + r.SetPathValue("id", "partner-id") + w := httptest.NewRecorder() + h.People(w, r) + if w.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204", w.Code) + } +} + +// ── Goals: create and delete ────────────────────────────────────────────────── + +func TestGoals_POST_CreateEmergency(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{ + "action": {"create"}, + "name": {"Emergency Fund"}, + "target_cents": {"500000"}, + "deadline": {"2026-12-31"}, + } + w := httptest.NewRecorder() + h.Goals(w, authReq("POST", "/goals", form)) + if w.Code != http.StatusSeeOther && w.Code != http.StatusOK { + t.Errorf("status = %d, want 303 or 200", w.Code) + } +} + +func TestGoals_POST_DeleteFromStore(t *testing.T) { + store := &mockStore{goals: []Goal{{ID: "g1", UserID: "user1", Name: "Old Goal"}}} + h := newHandler(store) + form := url.Values{"action": {"delete"}, "goal_id": {"g1"}} + w := httptest.NewRecorder() + h.Goals(w, authReq("POST", "/goals", form)) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── NetWorth ────────────────────────────────────────────────────────────────── + +func TestNetWorth_WithAccounts(t *testing.T) { + now := time.Now() + store := &mockStore{ + accounts: []Account{ + {ID: "a1", UserID: "user1", Name: "Savings", Type: "savings"}, + }, + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 500000, Category: "Income", Date: now.AddDate(0, -1, 0)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.NetWorth(w, authReq("GET", "/net-worth", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgRequestAction: insufficient permissions (viewer role) ───────────────── + +func TestOrgRequestAction_InsufficientPermissions(t *testing.T) { + org, _, fy := testOrg() + viewer := &OrgMember{ID: "m2", OrgID: "org1", UserID: "user1", Role: OrgRoleViewer} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": viewer}, + fiscalYears: []FiscalYear{*fy}, + txRequests: []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxPurchaseOrder, AmountCents: 50000, + StatusLog: []StatusLogEntry{{Status: TxSubmitted}}, + }}, + } + h := newHandler(store) + form := url.Values{"action": {"approve"}} + r := orgReq("POST", "/orgs/acme/requests/req1/action", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestAction(w, r) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} + +// ── OrgBankImport_POST_ConfirmedNoActiveYear ────────────────────────────────── + +func TestOrgBankImport_POST_ConfirmedNoActiveYear(t *testing.T) { + // Store has no active fiscal year — close it first + org, member, _ := testOrg() + draft := FiscalYear{ID: "fy1", OrgID: "org1", Label: "2025", Status: FiscalYearDraft, + StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{draft}, + } + h := newHandler(store) + body, ct := buildCSVMultipart("date,description,amount\n2025-01-15,Coffee,-15.00\n", "confirm", "1") + r := httptest.NewRequest("POST", "/orgs/acme/bank-import", body) + r.Header.Set("Content-Type", ct) + r.Header.Set("X-Auth-User-Id", "user1") + r.Header.Set("X-Auth-Email", "test@example.com") + r.SetPathValue("slug", "acme") + w := httptest.NewRecorder() + h.OrgBankImport(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (no active year): %s", w.Code, w.Body.String()) + } +} + +// ── OrgHome: with active year ───────────────────────────────────────────────── + +func TestOrgHome_WithData(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{ + {ID: "req1", OrgID: "org1", Type: TxReimbursement, AmountCents: 10000, + FiscalYearID: "fy1", StatusLog: []StatusLogEntry{{Status: TxDraft}}}, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.OrgHome(w, orgReq("GET", "/orgs/acme", "acme", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgRequestSettle: partial settlement ───────────────────────────────────── + +func TestOrgRequestSettle_Partial(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxCashAdvance, AmountCents: 100000, + StatusLog: []StatusLogEntry{{Status: TxDisbursed}}, + }} + h := newHandler(store) + // Spent < amount, returned < remainder → partial settlement + form := url.Values{ + "amount_spent": {"60.00"}, + "amount_returned": {"10.00"}, + } + r := orgReq("POST", "/orgs/acme/requests/req1/settle", "acme", form) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestSettle(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgRequestDetail: with event, budget line, team ────────────────────────── + +func TestOrgRequestDetail_FullyLoaded(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req1", OrgID: "org1", Type: TxPurchaseOrder, + FiscalYearID: "fy1", EventID: "evt1", BudgetLineID: "bl1", TeamID: "t1", + AmountCents: 75000, + StatusLog: []StatusLogEntry{{Status: TxSubmitted}}, + }} + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Q1", Status: EventApproved}} + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/requests/req1", "acme", nil) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestDetail(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + +// ── OrgJoin: POST success ───────────────────────────────────────────────────── + +func TestOrgJoin_POST(t *testing.T) { + org, _, fy := testOrg() + store := &mockStore{ + orgsByID: map[string]*Org{"org1": org}, + orgsBySlug: map[string]*Org{"acme": org}, + membersByKey: map[string]*OrgMember{}, + fiscalYears: []FiscalYear{*fy}, + invitesByToken: map[string]*OrgInvite{ + "tok123": { + ID: "inv1", OrgID: "org1", Email: "test@example.com", + Role: OrgRoleMember, Token: "tok123", + ExpiresAt: time.Now().Add(24 * time.Hour), + }, + }, + } + h := newHandler(store) + r := authReq("POST", "/orgs/join/tok123", url.Values{"token": {"tok123"}}) + r.SetPathValue("token", "tok123") + w := httptest.NewRecorder() + h.OrgJoin(w, r) + // Successful join redirects or renders join page + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgInviteNew POST: valid email ──────────────────────────────────────────── + +func TestOrgInviteNew_POST_Success(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"email": {"newmember@example.com"}, "role": {"member"}} + r := orgReq("POST", "/orgs/acme/members/invite", "acme", form) + w := httptest.NewRecorder() + h.OrgInviteNew(w, r) + if w.Code != http.StatusSeeOther && w.Code != http.StatusOK { + t.Errorf("status = %d, want 303 or 200: %s", w.Code, w.Body.String()) + } +} + +// ── OrgFiscalYearCreate ──────────────────────────────────────────────────────── + +func TestOrgFiscalYearCreate_WithValidDates(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "label": {"2026"}, + "start_date": {"2026-01-01"}, + "end_date": {"2026-12-31"}, + } + r := orgReq("POST", "/orgs/acme/years", "acme", form) + w := httptest.NewRecorder() + h.OrgFiscalYearCreate(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── TaxExport: with trades year filter ─────────────────────────────────────── + +func TestTaxExport_WithYearFilter(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.TaxExport(w, authReq("GET", "/tax/export?year=2024", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Tax: with trades ────────────────────────────────────────────────────────── + +func TestTax_WithTrades(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 500000, Category: "Income", Date: now.AddDate(0, -1, 0)}, + }, + trades: []Trade{ + {ID: "tr1", UserID: "user1", ISIN: "IE00B3W", Name: "ETF Fund", Type: "buy", + Quantity: 10, PriceCents: 10000, TotalCents: 100000, Date: now.AddDate(-1, 0, 0)}, + {ID: "tr2", UserID: "user1", ISIN: "IE00B3W", Name: "ETF Fund", Type: "sell", + Quantity: 5, PriceCents: 12000, TotalCents: 60000, Date: now.AddDate(0, -1, 0)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Tax(w, authReq("GET", "/tax", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Accounts ────────────────────────────────────────────────────────────────── + +func TestAccounts_GET_WithAccount(t *testing.T) { + store := &mockStore{ + accounts: []Account{ + {ID: "a1", UserID: "user1", Name: "Checking", Type: "checking"}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Accounts(w, authReq("GET", "/accounts", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestAccounts_POST_CreateNew(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"name": {"New Account"}, "type": {"savings"}} + w := httptest.NewRecorder() + h.Accounts(w, authReq("POST", "/accounts", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── Categories ──────────────────────────────────────────────────────────────── + +func TestCategories_POST_CreateNew(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"name": {"Travel"}, "color": {"#ff0000"}, "budget_cents": {"50000"}} + w := httptest.NewRecorder() + h.Categories(w, authReq("POST", "/categories", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestCategories_POST_DeleteOld(t *testing.T) { + store := &mockStore{categories: []Category{{ID: "c1", UserID: "user1", Name: "Old"}}} + h := newHandler(store) + form := url.Values{"_method": {"DELETE"}, "id": {"c1"}} + w := httptest.NewRecorder() + h.Categories(w, authReq("POST", "/categories", form)) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── UpdateTransaction ───────────────────────────────────────────────────────── + +func TestUpdateTransaction_Success(t *testing.T) { + store := &mockStore{ + transactions: []Transaction{{ID: "t1", UserID: "user1", AmountCents: -1000, Category: "Food", Description: "Cafe"}}, + } + h := newHandler(store) + body := `{"description":"Coffee","category":"Beverages","amount_cents":-500}` + r := authReq("PATCH", "/api/transactions/t1", nil) + r.Body = bodyReader(body) + r.Header.Set("Content-Type", "application/json") + r.SetPathValue("id", "t1") + w := httptest.NewRecorder() + h.UpdateTransaction(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + +// ── Sharing ─────────────────────────────────────────────────────────────────── + +func TestSharing_GET_WithPerms(t *testing.T) { + store := &mockStore{ + permissions: []Permission{{ID: "p1", OwnerID: "user1", ViewerID: "viewer-id"}}, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Sharing(w, authReq("GET", "/sharing", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgInviteRevoke ─────────────────────────────────────────────────────────── + +func TestOrgInviteRevoke_Success(t *testing.T) { + store := newOrgStore() + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/invites/inv1", "acme", nil) + r.SetPathValue("invite_id", "inv1") + w := httptest.NewRecorder() + h.OrgInviteRevoke(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgGoalAdd with approved event ──────────────────────────────────────────── + +func TestOrgGoalAdd_ApprovedEvent(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}} + h := newHandler(store) + form := url.Values{"text": {"Buy new chairs"}} + r := orgReq("POST", "/orgs/acme/events/evt1/goals", "acme", form) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgGoalAdd(w, r) + // Approved events should allow goal addition or return conflict + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgGoalToggle with active year ──────────────────────────────────────────── + +func TestOrgGoalToggle_ActiveYear(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}} + h := newHandler(store) + form := url.Values{"done": {"1"}} + r := orgReq("POST", "/orgs/acme/events/evt1/goals/g1/toggle", "acme", form) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("goal_id", "g1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgGoalToggle(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgFiscalYearActivate with no events (draft year) ───────────────────────── + +func TestOrgFiscalYearActivate_NoEvents(t *testing.T) { + org, member, _ := testOrg() + draft := FiscalYear{ID: "fy2", OrgID: "org1", Label: "2026", Status: FiscalYearDraft, + StartDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{draft}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy2/activate", "acme", nil) + r.SetPathValue("year_id", "fy2") + w := httptest.NewRecorder() + h.OrgFiscalYearActivate(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgEventEdit: GET ───────────────────────────────────────────────────────── + +func TestOrgEventEdit_GET(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Q1", Status: EventDraft}} + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/events/evt1/edit", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventEdit(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgEventDetail: found ───────────────────────────────────────────────────── + +func TestOrgEventDetail_WithGoalsAndLines(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Annual", Status: EventApproved}} + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/events/evt1", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventDetail(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + +// ── OrgFiscalYearActivate: success ─────────────────────────────────────────── + +func TestOrgFiscalYearActivate_DraftToActive(t *testing.T) { + org, member, _ := testOrg() + draft := FiscalYear{ID: "fy3", OrgID: "org1", Label: "2027", Status: FiscalYearDraft, + StartDate: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2027, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{draft}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy3/activate", "acme", nil) + r.SetPathValue("year_id", "fy3") + w := httptest.NewRecorder() + h.OrgFiscalYearActivate(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgAnalysis: with events and budget lines ───────────────────────────────── + +func TestOrgAnalysis_WithBudgetLines(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Summit", Status: EventApproved}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/analysis", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgAnalysis(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── AuthRegister: already logged in ────────────────────────────────────────── + +func TestAuthRegister_AlreadyLoggedIn(t *testing.T) { + h := newHandler(&mockStore{}) + // When X-Auth-User-Id is set, authFromSession won't redirect (it checks cookie, not headers) + // But we can still test the GET path + w := httptest.NewRecorder() + h.AuthRegister(w, httptest.NewRequest("GET", "/auth/register", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── AuthLogin: deleted account success message ──────────────────────────────── + +func TestAuthLogin_DeletedQuery(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthLogin(w, httptest.NewRequest("GET", "/auth/login?deleted=1", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Projections: GET ────────────────────────────────────────────────────────── + +func TestProjections_GET_WithGoals(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 300000, Category: "Income", Date: now.AddDate(0, -1, 0)}, + {ID: "t2", UserID: "user1", AmountCents: -50000, Category: "Housing", Date: now.AddDate(0, -1, 0)}, + }, + goals: []Goal{{ID: "g1", UserID: "user1", Name: "Vacation", TargetCents: 100000, SavedCents: 20000, Committed: true}}, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Projections(w, authReq("GET", "/projections", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgEventNew: POST with DueDate ─────────────────────────────────────────── + +func TestOrgEventNew_POST_WithDates(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "name": {"Tech Summit"}, + "date_start": {"2025-03-01"}, + "date_end": {"2025-03-03"}, + } + r := orgReq("POST", "/orgs/acme/years/fy1/events/new", "acme", form) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventNew(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgBudgetLineCreate: success ───────────────────────────────────────────── + +func TestOrgBudgetLineCreate_Success(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}} + h := newHandler(store) + form := url.Values{"description": {"Catering"}, "amount": {"500"}, "type": {"expense"}} + r := orgReq("POST", "/orgs/acme/events/evt1/budget", "acme", form) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgBudgetLineCreate(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgGoalDelete: draft event ──────────────────────────────────────────────── + +func TestOrgGoalDelete_DraftEvent(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/events/evt1/goals/g1", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("goal_id", "g1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgGoalDelete(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgMemberTeam assignment (via OrgTeams) ─────────────────────────────────── + +func TestOrgTeams_WithTeams(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/teams", "acme", nil) + w := httptest.NewRecorder() + h.OrgTeams(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgRequestAction: cancel in info_requested status ──────────────────────── + +func TestOrgRequestAction_CancelInfoRequested(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxInfoRequested), "cancel", nil) + if code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", code) + } +} + +// ── OrgRequestAction: done wrong type ──────────────────────────────────────── + +func TestOrgRequestAction_Done_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "done", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +// ── OrgRequestAction: disburse wrong type ──────────────────────────────────── + +func TestOrgRequestAction_Disburse_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxPurchaseOrder, TxApproved), "disburse", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +// ── OrgRequestAction: mark_pending_payment wrong type ──────────────────────── + +func TestOrgRequestAction_MarkPendingPayment_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "mark_pending_payment", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +// ── OrgRequestAction: mark_received wrong type ─────────────────────────────── + +func TestOrgRequestAction_MarkReceived_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "mark_received", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +// ── OrgRequestAction: mark_ordered wrong type ──────────────────────────────── + +func TestOrgRequestAction_MarkOrdered_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "mark_ordered", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +// ── OrgRequestAction: mark_delivered wrong type ─────────────────────────────── + +func TestOrgRequestAction_MarkDelivered_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "mark_delivered", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +// ── OrgRequestAction: dispute wrong type ───────────────────────────────────── + +func TestOrgRequestAction_Dispute_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "dispute", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +// ── OrgRequestAction: settlement_due wrong type ─────────────────────────────── + +func TestOrgRequestAction_SettlementDue_WrongType(t *testing.T) { + code := doOrgAction(t, newOrgStoreWithRequest(TxReimbursement, TxApproved), "settlement_due", nil) + if code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", code) + } +} + +// ── Household: with existing household data ─────────────────────────────────── + +func TestHousehold_GET_WithData(t *testing.T) { + store := &mockStore{} + h := newHandler(store) + w := httptest.NewRecorder() + h.Household(w, authReq("GET", "/household", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgCreate: with description ─────────────────────────────────────────────── + +func TestOrgCreate_POST_WithDescription(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"name": {"My Corp"}, "slug": {"my-corp"}, "description": {"A company"}} + w := httptest.NewRecorder() + h.OrgCreate(w, authReq("POST", "/orgs/new", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgRequestDetail: request with team lookup ──────────────────────────────── + +func TestOrgRequestDetail_WithFiscalYear(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{{ + ID: "req2", OrgID: "org1", Type: TxReimbursement, + FiscalYearID: "fy1", AmountCents: 25000, + StatusLog: []StatusLogEntry{{Status: TxApproved}}, + }} + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/requests/req2", "acme", nil) + r.SetPathValue("req_id", "req2") + w := httptest.NewRecorder() + h.OrgRequestDetail(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Household: with existing household ─────────────────────────────────────── + +func TestHousehold_GET_ExistingHousehold(t *testing.T) { + now := time.Now() + store := &mockStore{ + household: &Household{ + ID: "hh1", + OwnerID: "user1", + PartnerID: "partner@example.com", + CreatedAt: now.AddDate(0, -3, 0), + }, + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 250000, Category: "Income", Date: now.AddDate(0, 0, -5)}, + {ID: "t2", UserID: "user1", AmountCents: -20000, Category: "Food", Date: now.AddDate(0, 0, -3)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Household(w, authReq("GET", "/household", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── People: with existing household ────────────────────────────────────────── + +func TestPeople_GET_WithHousehold(t *testing.T) { + now := time.Now() + store := &mockStore{ + household: &Household{ + ID: "hh1", + OwnerID: "user1", + PartnerID: "partner@example.com", + CreatedAt: now.AddDate(0, -1, 0), + }, + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 200000, Category: "Income", Date: now.AddDate(0, 0, -2)}, + }, + goals: []Goal{ + {ID: "g1", UserID: "user1", Name: "Joint Vacation", TargetCents: 500000, Committed: true}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + r := authReq("GET", "/people?tab=household", nil) + h.People(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestPeople_GET_TabSharing(t *testing.T) { + store := &mockStore{ + permissions: []Permission{ + {ID: "p1", OwnerID: "user1", ViewerID: "viewer-user"}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.People(w, authReq("GET", "/people?tab=sharing", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgAnalysis: with team membership in events ─────────────────────────────── + +func TestOrgAnalysis_WithTeamsInEvents(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Summit", Status: EventApproved, + TeamIDs: []string{"t1", "t2"}}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/analysis", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgAnalysis(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgRequestList: guest-only member filter ────────────────────────────────── + +func TestOrgRequestList_GuestMemberFilter(t *testing.T) { + org, _, fy := testOrg() + guestMember := &OrgMember{ + ID: "m2", OrgID: "org1", UserID: "user1", Role: OrgRoleMember, + TeamIDs: []string{"t1"}, + } + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": guestMember}, + fiscalYears: []FiscalYear{*fy}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/requests", "acme", nil) + w := httptest.NewRecorder() + h.OrgRequestList(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgReport: with negative actual (expense path) ─────────────────────────── + +func TestOrgReport_NegativeActual(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Conference", Status: EventApproved}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/report", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgReport(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgFiscalYearClose: with events and all-approved check ─────────────────── + +func TestOrgFiscalYearClose_WithAllApproved(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventApproved}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/close", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgFiscalYearClose(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── authFromSession: cookie paths ───────────────────────────────────────────── + +func TestAuthLogin_AlreadyLoggedIn(t *testing.T) { + h := newHandler(&mockStore{}) + // Sign a session token like the handler does + token := h.signSessionID("testsessid") + r := httptest.NewRequest("GET", "/auth/login", nil) + r.AddCookie(&http.Cookie{Name: "finsession", Value: token}) + w := httptest.NewRecorder() + h.AuthLogin(w, r) + // getAuthSession returns nil (session not in store) → authFromSession returns false → login page + if w.Code != http.StatusOK && w.Code != http.StatusFound { + t.Errorf("status = %d, want 200 or 302", w.Code) + } +} + +func TestAuthLogin_BadCookie(t *testing.T) { + h := newHandler(&mockStore{}) + // Invalid (unsigned) cookie value — verifySessionToken returns !ok + r := httptest.NewRequest("GET", "/auth/login", nil) + r.AddCookie(&http.Cookie{Name: "finsession", Value: "invalid-token-value"}) + w := httptest.NewRecorder() + h.AuthLogin(w, r) + // verifySessionToken fails → authFromSession returns false → renders login page + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestAuthLogin_ValidSessionRedirects(t *testing.T) { + // Register a new account so startSession is called and a session is stored + h := newHandler(&mockStore{}) + form := url.Values{ + "email": {"session@example.com"}, + "name": {"Test User"}, + "password": {"goodpassword"}, + "confirm": {"goodpassword"}, + } + regReq := httptest.NewRequest("POST", "/auth/register", strings.NewReader(form.Encode())) + regReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + regW := httptest.NewRecorder() + h.AuthRegister(regW, regReq) + if regW.Code != http.StatusSeeOther { + t.Fatalf("register failed: status = %d", regW.Code) + } + + // Extract the session cookie + cookies := regW.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("expected session cookie after registration") + } + sessionCookie := cookies[0] + + // Now use that cookie on the login page — should redirect to dashboard + loginReq := httptest.NewRequest("GET", "/auth/login", nil) + loginReq.AddCookie(sessionCookie) + loginW := httptest.NewRecorder() + h.AuthLogin(loginW, loginReq) + if loginW.Code != http.StatusFound { + t.Errorf("status = %d, want 302 (redirect to dashboard)", loginW.Code) + } +} + +// ── loginRateLimiter cleanup ─────────────────────────────────────────────────── + +func TestLoginRateLimiter_Cleanup(t *testing.T) { + rl := &loginRateLimiter{} + // Add a stale entry manually + rl.entries.Store("stale-ip", &rlEntry{ + windowStart: time.Now().Add(-2 * rlWindow), + lockedUntil: time.Now().Add(-2 * rlLockout), + }) + // Add an active entry + rl.entries.Store("active-ip", &rlEntry{ + windowStart: time.Now(), + lockedUntil: time.Now().Add(rlLockout), + }) + rl.cleanup() + if _, loaded := rl.entries.Load("stale-ip"); loaded { + t.Error("stale entry should have been cleaned up") + } + if _, loaded := rl.entries.Load("active-ip"); !loaded { + t.Error("active entry should NOT have been cleaned up") + } +} + +// ── AuthLogout: with valid cookie ───────────────────────────────────────────── + +func TestAuthLogout_WithValidCookie(t *testing.T) { + h := newHandler(&mockStore{}) + token := h.signSessionID("sess123") + r := httptest.NewRequest("POST", "/auth/logout", nil) + r.AddCookie(&http.Cookie{Name: "finsession", Value: token}) + w := httptest.NewRecorder() + h.AuthLogout(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── startSession: with existing cookie (session rotation) ──────────────────── + +func TestStartSession_WithExistingCookie(t *testing.T) { + h := newHandler(&mockStore{}) + // First, create an initial session + token := h.signSessionID("oldsessid") + // Then start a new session with an existing cookie (rotation) + r := httptest.NewRequest("POST", "/auth/login", nil) + r.AddCookie(&http.Cookie{Name: "finsession", Value: token}) + w := httptest.NewRecorder() + userID := bson.NewObjectID() + if err := h.startSession(w, r, userID, "user@example.com"); err != nil { + t.Fatalf("startSession error: %v", err) + } + cookies := w.Result().Cookies() + if len(cookies) == 0 { + t.Error("expected session cookie after startSession") + } +} + +// ── ImportPreview: with CSV file ───────────────────────────────────────────── + +func TestImportPreview_WithCSV(t *testing.T) { + store := &mockStore{ + accounts: []Account{{ID: "a1", UserID: "user1", Name: "Main", Type: "checking"}}, + } + h := newHandler(store) + body, ct := buildCSVMultipart("Date,Description,Amount\n2025-01-01,Coffee,-5.00\n", "account_id", "a1") + r := authReq("POST", "/import/preview", nil) + r.Body = io.NopCloser(body) + r.Header.Set("Content-Type", ct) + w := httptest.NewRecorder() + h.ImportPreview(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── Portfolio: GET ──────────────────────────────────────────────────────────── + +func TestPortfolio_GET(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.Portfolio(w, authReq("GET", "/portfolio", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgEventList: NotFound year ─────────────────────────────────────────────── + +func TestOrgEventList_YearNotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/years/bad-year/events", "acme", nil) + r.SetPathValue("year_id", "bad-year") + w := httptest.NewRecorder() + h.OrgEventList(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// ── OrgAnalysis: year not found ─────────────────────────────────────────────── + +func TestOrgAnalysis_YearNotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/analysis", "acme", nil) + r.SetPathValue("year_id", "bad-year") + w := httptest.NewRecorder() + h.OrgAnalysis(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// ── OrgReport: year not found ───────────────────────────────────────────────── + +func TestOrgReport_YearNotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/report", "acme", nil) + r.SetPathValue("year_id", "bad-year") + w := httptest.NewRecorder() + h.OrgReport(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// ── OrgRequestNew: income type ──────────────────────────────────────────────── + +func TestOrgRequestNew_IncomeType(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "type": {"income"}, + "amount": {"1000"}, + "payer_name": {"Client Corp"}, + "description": {"Consulting fee"}, + } + r := orgReq("POST", "/orgs/acme/requests/new", "acme", form) + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgRequestNew: cash advance type ───────────────────────────────────────── + +func TestOrgRequestNew_CashAdvanceType(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "type": {"cash_advance"}, + "amount": {"500"}, + } + r := orgReq("POST", "/orgs/acme/requests/new", "acme", form) + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgRequestNew: invalid amount ───────────────────────────────────────────── + +func TestOrgRequestNew_InvalidAmount(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"type": {"reimbursement"}, "amount": {"not-a-number"}} + r := orgReq("POST", "/orgs/acme/requests/new", "acme", form) + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// ── OrgEventNew: no active fiscal year ──────────────────────────────────────── + +func TestOrgRequestNew_NoActiveFiscalYear(t *testing.T) { + org, member, _ := testOrg() + draft := FiscalYear{ID: "fy2", OrgID: "org1", Label: "2026", Status: FiscalYearDraft, + StartDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{draft}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/requests/new", "acme", nil) + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (no active fiscal year)", w.Code) + } +} + +// ── OrgFiscalYearClose: unapproved events ──────────────────────────────────── + +func TestOrgFiscalYearClose_UnapprovedEvents(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventDraft}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/close", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgFiscalYearClose(w, r) + // Close is allowed even with unapproved events (handler just closes) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgEventFeedback: with approved event ──────────────────────────────────── + +func TestOrgEventFeedback_ClosedYear(t *testing.T) { + org, member, _ := testOrg() + closed := FiscalYear{ID: "fy1", OrgID: "org1", Label: "2025", Status: FiscalYearClosed, + StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{closed}, + orgEvents: []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventApproved, Name: "Summit"}}, + } + h := newHandler(store) + form := url.Values{"comment": {"Great event overall"}} + r := orgReq("POST", "/orgs/acme/events/evt1/feedback", "acme", form) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventFeedback(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── Properties: with data ──────────────────────────────────────────────────── + +func TestProperties_GET_WithData(t *testing.T) { + now := time.Now() + store := &mockStore{ + properties: []Property{ + {ID: "p1", UserID: "user1", Name: "Main Home", Status: PropertyOwned, + CurrentValueCents: 35000000, PurchasePriceCents: 30000000, PurchaseDate: now.AddDate(-3, 0, 0)}, + {ID: "p2", UserID: "user1", Name: "Old Flat", Status: PropertySold, + CurrentValueCents: 20000000, PurchasePriceCents: 18000000}, + }, + loans: []Loan{ + {ID: "l1", UserID: "user1", PropertyID: "p1", Name: "Mortgage", Type: LoanMortgage, + Status: LoanActive, PrincipalCents: 25000000, BalanceCents: 22000000, + InterestRatePct: 3.5, TermMonths: 360, MonthlyPaymentCents: 112000, + StartDate: now.AddDate(-3, 0, 0)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Properties(w, authReq("GET", "/property", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestProperties_GET_WithUnlinkedLoan(t *testing.T) { + now := time.Now() + store := &mockStore{ + loans: []Loan{ + {ID: "l1", UserID: "user1", PropertyID: "", Name: "Personal Loan", Type: LoanPersonal, + Status: LoanActive, PrincipalCents: 5000000, BalanceCents: 4000000, + InterestRatePct: 5.0, TermMonths: 60, MonthlyPaymentCents: 95000, + StartDate: now.AddDate(-1, 0, 0)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Properties(w, authReq("GET", "/property", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── loanBalanceAt: edge cases ───────────────────────────────────────────────── + +func TestLoanBalanceAt_ZeroRate(t *testing.T) { + // With zero interest rate + b := loanBalanceAt(100000, 0, 10000, 5) + if b != 50000 { + t.Errorf("loanBalanceAt zero-rate = %d, want 50000", b) + } +} + +func TestLoanBalanceAt_ZeroRateOverpaid(t *testing.T) { + // Many months → balance goes negative → returns 0 + b := loanBalanceAt(100000, 0, 10000, 20) + if b != 0 { + t.Errorf("loanBalanceAt zero-rate overpaid = %d, want 0", b) + } +} + +func TestLoanBalanceAt_HighInterest_LongTerm(t *testing.T) { + // Regular path with balance > 0 + b := loanBalanceAt(30000000, 4.5, 150000, 1) + if b <= 0 { + t.Errorf("loanBalanceAt high interest = %d, want > 0", b) + } +} + +// ── OrgJoin: already a member ──────────────────────────────────────────────── + +func TestOrgJoin_POST_AlreadyMember(t *testing.T) { + org, member, fy := testOrg() + store := &mockStore{ + orgsByID: map[string]*Org{"org1": org}, + orgsBySlug: map[string]*Org{"acme": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{*fy}, + invitesByToken: map[string]*OrgInvite{ + "tok456": { + ID: "inv2", OrgID: "org1", Email: "test@example.com", + Role: OrgRoleMember, Token: "tok456", + ExpiresAt: time.Now().Add(24 * time.Hour), + }, + }, + } + h := newHandler(store) + r := authReq("POST", "/join/tok456", url.Values{"token": {"tok456"}}) + r.SetPathValue("token", "tok456") + w := httptest.NewRecorder() + h.OrgJoin(w, r) + // Already a member → consume invite and redirect + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── detectLang ──────────────────────────────────────────────────────────────── + +func TestDetectLang_FromCookie(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"lang": {"pt"}} + r := authReq("POST", "/lang", form) + r.Header.Set("Referer", "/dashboard") + w := httptest.NewRecorder() + h.SetLang(w, r) // sets cookie + // Now use the cookie for language detection + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestDetectLang_FromAcceptHeader(t *testing.T) { + h := newHandler(&mockStore{}) + r := authReq("GET", "/", nil) + r.Header.Set("Accept-Language", "pt-PT,pt;q=0.9,en;q=0.8") + w := httptest.NewRecorder() + h.Dashboard(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── getAuth: via headers (owner path) ──────────────────────────────────────── + +func TestGetAuth_WithOwner(t *testing.T) { + h := newHandler(&mockStore{}) + // owner=true query param triggers owner check + r := authReq("GET", "/sharing?owner=other-user-id", nil) + w := httptest.NewRecorder() + h.Sharing(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Accounts: DELETE ────────────────────────────────────────────────────────── + +func TestAccounts_DELETE_Account(t *testing.T) { + store := &mockStore{ + accounts: []Account{{ID: "a1", UserID: "user1", Name: "Old", Type: "checking"}}, + } + h := newHandler(store) + r := authReq("DELETE", "/accounts/a1", nil) + r.SetPathValue("id", "a1") + w := httptest.NewRecorder() + h.Accounts(w, r) + if w.Code != http.StatusNoContent && w.Code != http.StatusSeeOther && w.Code != http.StatusOK { + t.Errorf("status = %d, unexpected", w.Code) + } +} + +// ── Goals: update action ────────────────────────────────────────────────────── + +func TestGoals_POST_Update(t *testing.T) { + store := &mockStore{ + goals: []Goal{{ID: "g1", UserID: "user1", Name: "Savings", TargetCents: 100000}}, + } + h := newHandler(store) + form := url.Values{ + "action": {"update"}, + "goal_id": {"g1"}, + "name": {"Better Savings"}, + "target_cents": {"200000"}, + } + w := httptest.NewRecorder() + h.Goals(w, authReq("POST", "/goals", form)) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── runDreamSim: more paths ──────────────────────────────────────────────────── + +func TestRunDreamSim_WithPurchaseSim(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 300000, Category: "Income", Date: now.AddDate(0, -1, 0)}, + {ID: "t2", UserID: "user1", AmountCents: -100000, Category: "Housing", Date: now.AddDate(0, -1, 0)}, + }, + } + h := newHandler(store) + form := url.Values{ + "cost": {"500000"}, + "down_pct": {"20"}, + "rate": {"4.5"}, + "term_months": {"240"}, + } + w := httptest.NewRecorder() + r := authReq("GET", "/dream?cost=500000&down_pct=20&rate=4.5&term_months=240", nil) + h.Simulator(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + _ = form +} + +// ── OrgHome: overview with requests ────────────────────────────────────────── + +func TestOrgHome_WithRequests(t *testing.T) { + store := newOrgStore() + store.txRequests = []TxRequest{ + {ID: "r1", OrgID: "org1", Type: TxReimbursement, AmountCents: 5000, + FiscalYearID: "fy1", SubmittedBy: "m1", + StatusLog: []StatusLogEntry{{Status: TxSubmitted, ChangedBy: "m1", ChangedAt: time.Now()}}}, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.OrgHome(w, orgReq("GET", "/orgs/acme", "acme", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgEventDetail: with team and attachments ──────────────────────────────── + +func TestOrgEventDetail_WithTeamIDs(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Gala", + Status: EventApproved, TeamIDs: []string{"t1", "t2"}}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/years/fy1/events/evt1", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventDetail(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgFiscalYearClose: error path ─────────────────────────────────────────── + +func TestOrgFiscalYearClose_Error(t *testing.T) { + org, member, fy := testOrg() + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{*fy}, + updateFiscalYearStatusErr: fmt.Errorf("db error"), + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/close", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgFiscalYearClose(w, r) + if w.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", w.Code) + } +} + +// ── Goals: adjust_deadline action ──────────────────────────────────────────── + +func TestGoals_POST_AdjustDeadline(t *testing.T) { + store := &mockStore{ + goals: []Goal{{ID: "g1", UserID: "user1", Name: "Savings", TargetCents: 100000}}, + } + h := newHandler(store) + form := url.Values{"action": {"adjust_deadline"}, "id": {"g1"}, "months": {"6"}} + w := httptest.NewRecorder() + h.Goals(w, authReq("POST", "/goals", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestGoals_POST_AdjustDeadline_ZeroMonths(t *testing.T) { + store := &mockStore{ + goals: []Goal{{ID: "g1", UserID: "user1", Name: "Savings", TargetCents: 100000}}, + } + h := newHandler(store) + // months=0 → skips updateGoal, still redirects + form := url.Values{"action": {"adjust_deadline"}, "id": {"g1"}, "months": {"0"}} + w := httptest.NewRecorder() + h.Goals(w, authReq("POST", "/goals", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── NetWorth: with properties and loans ────────────────────────────────────── + +func TestNetWorth_WithPropertiesAndLoans(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 100000, Category: "Income", Date: now.AddDate(0, -2, 0)}, + }, + properties: []Property{ + {ID: "p1", UserID: "user1", Name: "Home", Status: PropertyOwned, CurrentValueCents: 30000000}, + {ID: "p2", UserID: "user1", Name: "Sold", Status: PropertySold, CurrentValueCents: 10000000}, + }, + loans: []Loan{ + {ID: "l1", UserID: "user1", PropertyID: "p1", Name: "Mortgage", + Status: LoanActive, PrincipalCents: 25000000, BalanceCents: 22000000, + InterestRatePct: 3.5, TermMonths: 360, MonthlyPaymentCents: 0, + StartDate: now.AddDate(-2, 0, 0)}, + {ID: "l2", UserID: "user1", PropertyID: "p1", Name: "Paid Loan", + Status: LoanPaidOff, PrincipalCents: 5000000, BalanceCents: 0}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.NetWorth(w, authReq("GET", "/networth", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Accounts: GET via header auth (getAuth header path) ────────────────────── + +func TestGetAuth_FromSessionCookie(t *testing.T) { + // Register → get cookie → use cookie on Accounts (getAuth session path) + store := &mockStore{ + authUsers: map[string]*AuthUser{}, + } + h := newHandler(store) + + // Register a user + form := url.Values{ + "email": {"session@example.com"}, + "password": {"TestPass123!"}, + "name": {"Session User"}, + } + regW := httptest.NewRecorder() + regR := httptest.NewRequest("POST", "/auth/register", strings.NewReader(form.Encode())) + regR.Header.Set("Content-Type", "application/x-www-form-urlencoded") + h.AuthRegister(regW, regR) + if regW.Code != http.StatusSeeOther { + t.Skipf("register returned %d, skipping", regW.Code) + } + // Extract cookie + var sessionCookie *http.Cookie + for _, c := range regW.Result().Cookies() { + if c.Name == "finsession" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Skip("no finsession cookie from register") + } + + // Use session cookie on Accounts handler + r := httptest.NewRequest("GET", "/accounts", nil) + r.AddCookie(sessionCookie) + w := httptest.NewRecorder() + h.Accounts(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Goals: waterfall with GoalID txn ───────────────────────────────────────── + +func TestGoals_GET_WithGoalTransaction(t *testing.T) { + now := time.Now() + store := &mockStore{ + goals: []Goal{ + {ID: "g1", UserID: "user1", Name: "Emergency Fund", TargetCents: 100000, + Deadline: now.AddDate(0, 6, 0)}, + }, + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 300000, Category: "Income", + Date: now.AddDate(0, 0, -2)}, + {ID: "t2", UserID: "user1", AmountCents: -50000, Category: "Goals", + GoalID: "g1", Date: now.AddDate(0, 0, -1)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Goals(w, authReq("GET", "/goals", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── Goals: avg monthly savings positive path ────────────────────────────────── + +func TestGoals_GET_AvgSavingsPositive(t *testing.T) { + now := time.Now() + // transactions 45 days ago (last month, not current month) = within 3-month window + store := &mockStore{ + goals: []Goal{{ID: "g1", UserID: "user1", Name: "Travel", TargetCents: 50000, + Deadline: now.AddDate(1, 0, 0)}}, + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 200000, Category: "Income", + Date: now.AddDate(0, -1, -5)}, + {ID: "t2", UserID: "user1", AmountCents: -80000, Category: "Food", + Date: now.AddDate(0, -1, -5)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Goals(w, authReq("GET", "/goals", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── RevokeSession: success path ─────────────────────────────────────────────── + +func TestRevokeSession_Success(t *testing.T) { + now := time.Now() + userOID := bson.NewObjectID() + sessOID := bson.NewObjectID() + store := &mockStore{ + authUsers: map[string]*AuthUser{ + userOID.Hex(): {ID: userOID, Email: "test@example.com"}, + }, + sessions: []AuthSession{ + {ID: sessOID, UserID: userOID, ExpiresAt: now.Add(24 * time.Hour)}, + }, + } + h := newHandler(store) + r := authReq("POST", "/account/sessions/del", url.Values{"session_id": {sessOID.Hex()}}) + r.SetPathValue("session_id", sessOID.Hex()) + w := httptest.NewRecorder() + h.RevokeSession(w, r) + // accepts 204, 303, or 200 + if w.Code != http.StatusSeeOther && w.Code != http.StatusOK && w.Code != http.StatusNoContent { + t.Errorf("status = %d, unexpected", w.Code) + } +} + +// ── AuthRegister: already registered ───────────────────────────────────────── + +func TestAuthRegister_AlreadyRegistered(t *testing.T) { + hash, _ := bcrypt.GenerateFromPassword([]byte("Password1!"), 4) + existingID := bson.NewObjectID() + store := &mockStore{ + authUsers: map[string]*AuthUser{ + existingID.Hex(): {ID: existingID, Email: "existing@example.com", PasswordHash: string(hash)}, + }, + } + h := newHandler(store) + form := url.Values{ + "email": {"existing@example.com"}, + "password": {"Password1!"}, + "name": {"Test"}, + } + r := httptest.NewRequest("POST", "/auth/register", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.AuthRegister(w, r) + // Should return form with error (400 or 200 with error) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── AuthRegister GET (second path) ─────────────────────────────────────────── + +func TestAuthRegister_GET_NoSession(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/auth/register", nil) + h.AuthRegister(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgJoin: GET with no token ──────────────────────────────────────────────── + +func TestOrgJoin_GET_InvalidToken(t *testing.T) { + org, member, fy := testOrg() + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{*fy}, + invitesByToken: map[string]*OrgInvite{}, + } + h := newHandler(store) + r := authReq("GET", "/join/badtoken", nil) + r.SetPathValue("token", "badtoken") + w := httptest.NewRecorder() + h.OrgJoin(w, r) + // Token not found → show error page + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgFiscalYearActivate: error paths ─────────────────────────────────────── + +func TestOrgFiscalYearActivate_AlreadyActive(t *testing.T) { + org, member, _ := testOrg() + activeFY := &FiscalYear{ID: "fy1", OrgID: "org1", Label: "2024", Status: FiscalYearActive, + StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{*activeFY}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/activate", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgFiscalYearActivate(w, r) + // Already active → redirect or error, not 500 + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500") + } +} + +// ── OrgEventReview: request_changes path ───────────────────────────────────── + +func TestOrgEventReview_RequestChanges(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Gala", Status: EventReview}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/events/evt1/review", "acme", + url.Values{"action": {"request_changes"}}) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventReview(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgEventFeedback: with feedback ────────────────────────────────────────── + +func TestOrgEventFeedback_WithMessage(t *testing.T) { + org, member, _ := testOrg() + closedFY := &FiscalYear{ID: "fy1", OrgID: "org1", Label: "2024", Status: FiscalYearClosed, + StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{*closedFY}, + orgEvents: []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Gala", Status: EventApproved}, + }, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/events/evt1/feedback", "acme", + url.Values{"comment": {"Great event!"}}) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventFeedback(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── Tax handler ─────────────────────────────────────────────────────────────── + +func TestTax_GET_WithTransactions(t *testing.T) { + now := time.Now() + store := &mockStore{ + transactions: []Transaction{ + {ID: "t1", UserID: "user1", AmountCents: 500000, Category: "Income", + Date: now.AddDate(0, -1, 0)}, + {ID: "t2", UserID: "user1", AmountCents: -100000, Category: "Healthcare", + Date: now.AddDate(0, -1, 0)}, + }, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.Tax(w, authReq("GET", "/tax", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgTeamCreate: validation errors ───────────────────────────────────────── + +func TestOrgTeamCreate_EmptyName(t *testing.T) { + org, member, fy := testOrg() + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{*fy}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/teams", "acme", url.Values{"name": {""}}) + w := httptest.NewRecorder() + h.OrgTeamCreate(w, r) + // empty name → error response + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500") + } +} + +// ── OrgMemberRemove: remove other member ───────────────────────────────────── + +func TestOrgMemberRemove_OtherMember(t *testing.T) { + org, member, fy := testOrg() + other := &OrgMember{ID: "m2", OrgID: "org1", UserID: "user2", Role: OrgRoleMember} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member, "org1:user2": other}, + fiscalYears: []FiscalYear{*fy}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/members/user2/remove", "acme", nil) + r.SetPathValue("user_id", "user2") + w := httptest.NewRecorder() + h.OrgMemberRemove(w, r) + if w.Code != http.StatusSeeOther && w.Code != http.StatusOK { + t.Errorf("status = %d, unexpected", w.Code) + } +} + +// ── OrgReport: with data ────────────────────────────────────────────────────── + +func TestOrgReport_WithBudgetLines(t *testing.T) { + store := newOrgStore() + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/years/fy1/report", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgReport(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgLedger: with year filter ─────────────────────────────────────────────── + +func TestOrgLedger_GET_WithYear(t *testing.T) { + store := newOrgStore() + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/ledger?year=fy1", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgLedger(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgEventSubmit: submit event ────────────────────────────────────────────── + +func TestOrgEventSubmit_Success(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Conference", + Status: EventDraft, CreatedBy: "user1"}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/events/evt1/submit", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventSubmit(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgRequestNew: validation error ────────────────────────────────────────── + +func TestOrgRequestNew_EmptyAmount(t *testing.T) { + store := newOrgStore() + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/requests/new", "acme", + url.Values{"type": {string(TxReimbursement)}, "title": {"Test"}, "amount": {""}}) + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500") + } +} + +// ── OrgGoalDelete: success ──────────────────────────────────────────────────── + +func TestOrgGoalDelete_Success(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Event", + GoalItems: []EventGoal{{ID: "gi1", Text: "Feed 50 people"}}}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/events/evt1/goals/gi1/delete", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + r.SetPathValue("goal_id", "gi1") + w := httptest.NewRecorder() + h.OrgGoalDelete(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgBudgetLineDelete: success ────────────────────────────────────────────── + +func TestOrgBudgetLineDelete_Success(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Conference", + Status: EventDraft}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy1/events/evt1/budget/bl1/delete", "acme", nil) + r.SetPathValue("year_id", "fy1") + r.SetPathValue("event_id", "evt1") + r.SetPathValue("line_id", "bl1") + w := httptest.NewRecorder() + h.OrgBudgetLineDelete(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── bson import needed ──────────────────────────────────────────────────────── +var _ = bson.NewObjectID diff --git a/apps/finance/services/api/main/handler_org_test.go b/apps/finance/services/api/main/handler_org_test.go new file mode 100644 index 0000000..dab6fe7 --- /dev/null +++ b/apps/finance/services/api/main/handler_org_test.go @@ -0,0 +1,1781 @@ +package main + +// Org, Property, Dream, and Auth-helper tests. + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func testOrg() (*Org, *OrgMember, *FiscalYear) { + org := &Org{ID: "org1", Name: "ACME Corp", Slug: "acme", OwnerUserID: "user1", CreatedAt: time.Now()} + member := &OrgMember{ID: "m1", OrgID: "org1", UserID: "user1", Email: "test@example.com", Role: OrgRoleAdmin, CreatedAt: time.Now()} + fy := &FiscalYear{ID: "fy1", OrgID: "org1", Label: "2025", Status: FiscalYearActive, + StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), CreatedAt: time.Now()} + return org, member, fy +} + +func newOrgStore() *mockStore { + org, member, fy := testOrg() + return &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{*fy}, + } +} + +func orgReq(method, path, slug string, form url.Values) *http.Request { + r := authReq(method, path, form) + r.SetPathValue("slug", slug) + return r +} + +// ── OrgList ────────────────────────────────────────────────────────────────── + +func TestOrgList_Empty(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.OrgList(w, authReq("GET", "/orgs", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgList_WithOrg(t *testing.T) { + h := newHandler(newOrgStore()) + w := httptest.NewRecorder() + h.OrgList(w, authReq("GET", "/orgs", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgCreate ──────────────────────────────────────────────────────────────── + +func TestOrgCreate_GET(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.OrgCreate(w, authReq("GET", "/orgs/new", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgCreate_POST_Success(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"name": {"Test Corp"}, "slug": {"test-corp"}} + w := httptest.NewRecorder() + h.OrgCreate(w, authReq("POST", "/orgs/new", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgCreate_POST_EmptyName(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"name": {""}, "slug": {"test"}} + w := httptest.NewRecorder() + h.OrgCreate(w, authReq("POST", "/orgs/new", form)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgCreate_POST_BadSlug(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"name": {"Test Corp"}, "slug": {"TEST CORP!"}} + w := httptest.NewRecorder() + h.OrgCreate(w, authReq("POST", "/orgs/new", form)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgHome ────────────────────────────────────────────────────────────────── + +func TestOrgHome_NotFound(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.OrgHome(w, orgReq("GET", "/orgs/acme", "missing-slug", nil)) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgHome_WithOrg(t *testing.T) { + h := newHandler(newOrgStore()) + w := httptest.NewRecorder() + h.OrgHome(w, orgReq("GET", "/orgs/acme", "acme", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } + if !strings.Contains(w.Body.String(), "ACME Corp") { + t.Error("expected org name in response") + } +} + +// ── OrgTeams ───────────────────────────────────────────────────────────────── + +func TestOrgTeams_GET(t *testing.T) { + h := newHandler(newOrgStore()) + w := httptest.NewRecorder() + h.OrgTeams(w, orgReq("GET", "/orgs/acme/teams", "acme", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgTeamCreate(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"name": {"Engineering"}, "type": {"internal"}} + w := httptest.NewRecorder() + h.OrgTeamCreate(w, orgReq("POST", "/orgs/acme/teams", "acme", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgTeamCreate_MissingName(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"name": {""}} + w := httptest.NewRecorder() + h.OrgTeamCreate(w, orgReq("POST", "/orgs/acme/teams", "acme", form)) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestOrgTeamDelete(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("DELETE", "/orgs/acme/teams/t1", "acme", nil) + r.SetPathValue("team_id", "t1") + w := httptest.NewRecorder() + h.OrgTeamDelete(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgMembers ─────────────────────────────────────────────────────────────── + +func TestOrgMembers_GET(t *testing.T) { + h := newHandler(newOrgStore()) + w := httptest.NewRecorder() + h.OrgMembers(w, orgReq("GET", "/orgs/acme/members", "acme", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgMemberRoleUpdate(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"role": {"member"}} + r := orgReq("POST", "/orgs/acme/members/m2/role", "acme", form) + r.SetPathValue("member_id", "m2") + w := httptest.NewRecorder() + h.OrgMemberRoleUpdate(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgMemberRoleUpdate_InvalidRole(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"role": {"superuser"}} + r := orgReq("POST", "/orgs/acme/members/m2/role", "acme", form) + r.SetPathValue("member_id", "m2") + w := httptest.NewRecorder() + h.OrgMemberRoleUpdate(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestOrgMemberRemove(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("DELETE", "/orgs/acme/members/m2", "acme", nil) + r.SetPathValue("member_id", "m2") + w := httptest.NewRecorder() + h.OrgMemberRemove(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgMemberRemove_Self(t *testing.T) { + h := newHandler(newOrgStore()) + // Self-remove check: memberID must match me.ID (which is "m1"), not the user ID + r := orgReq("DELETE", "/orgs/acme/members/m1", "acme", nil) + r.SetPathValue("member_id", "m1") + w := httptest.NewRecorder() + h.OrgMemberRemove(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// ── OrgInviteNew ───────────────────────────────────────────────────────────── + +func TestOrgInviteNew_GET(t *testing.T) { + h := newHandler(newOrgStore()) + w := httptest.NewRecorder() + h.OrgInviteNew(w, orgReq("GET", "/orgs/acme/invite", "acme", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgInviteNew_POST(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"email": {"new@example.com"}, "role": {"member"}} + w := httptest.NewRecorder() + h.OrgInviteNew(w, orgReq("POST", "/orgs/acme/invite", "acme", form)) + if w.Code != http.StatusOK && w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 200 or 303", w.Code) + } +} + +func TestOrgInviteNew_POST_MissingEmail(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"email": {""}, "role": {"member"}} + w := httptest.NewRecorder() + h.OrgInviteNew(w, orgReq("POST", "/orgs/acme/invite", "acme", form)) + // Handler re-renders the form with 200 (template may fail to render fully) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgInviteRevoke(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("DELETE", "/orgs/acme/invites/inv1", "acme", nil) + r.SetPathValue("invite_id", "inv1") + w := httptest.NewRecorder() + h.OrgInviteRevoke(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgInviteRevoke_Forbidden(t *testing.T) { + org, _, fy := testOrg() + viewer := &OrgMember{ID: "m2", OrgID: "org1", UserID: "user1", Role: OrgRoleViewer} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": viewer}, + fiscalYears: []FiscalYear{*fy}, + } + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/invites/inv1", "acme", nil) + r.SetPathValue("invite_id", "inv1") + w := httptest.NewRecorder() + h.OrgInviteRevoke(w, r) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} + +// ── OrgJoin ────────────────────────────────────────────────────────────────── + +func TestOrgJoin_InvalidToken(t *testing.T) { + h := newHandler(&mockStore{}) + r := authReq("GET", "/orgs/join/bad-token", nil) + r.SetPathValue("token", "bad-token") + w := httptest.NewRecorder() + h.OrgJoin(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgJoin_ValidToken_GET(t *testing.T) { + org, _, _ := testOrg() + store := &mockStore{ + orgsByID: map[string]*Org{"org1": org}, + invitesByToken: map[string]*OrgInvite{ + "token123": {ID: "inv1", OrgID: "org1", Email: "new@example.com", + Role: OrgRoleMember, Token: "token123", ExpiresAt: time.Now().Add(24 * time.Hour)}, + }, + } + h := newHandler(store) + r := authReq("GET", "/orgs/join/token123", nil) + r.SetPathValue("token", "token123") + w := httptest.NewRecorder() + h.OrgJoin(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgJoin_POST_CreatesMember(t *testing.T) { + org, _, _ := testOrg() + store := &mockStore{ + orgsByID: map[string]*Org{"org1": org}, + invitesByToken: map[string]*OrgInvite{ + "join-token": {ID: "inv1", OrgID: "org1", Email: "newuser@example.com", + Role: OrgRoleMember, Token: "join-token", ExpiresAt: time.Now().Add(24 * time.Hour)}, + }, + } + h := newHandler(store) + r := authReq("POST", "/orgs/join/join-token", nil) + r.SetPathValue("token", "join-token") + w := httptest.NewRecorder() + h.OrgJoin(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgFiscalYears ─────────────────────────────────────────────────────────── + +func TestOrgFiscalYearCreate(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"label": {"2026"}, "start_date": {"2026-01-01"}, "end_date": {"2026-12-31"}} + w := httptest.NewRecorder() + h.OrgFiscalYearCreate(w, orgReq("POST", "/orgs/acme/years", "acme", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgFiscalYearCreate_MissingLabel(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"label": {""}, "start_date": {"2026-01-01"}, "end_date": {"2026-12-31"}} + w := httptest.NewRecorder() + h.OrgFiscalYearCreate(w, orgReq("POST", "/orgs/acme/years", "acme", form)) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestOrgFiscalYearCreate_MissingDates(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"label": {"2026"}, "start_date": {""}, "end_date": {""}} + w := httptest.NewRecorder() + h.OrgFiscalYearCreate(w, orgReq("POST", "/orgs/acme/years", "acme", form)) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestOrgFiscalYearActivate_ConflictWhenActiveExists(t *testing.T) { + // newOrgStore already has an active FY — activating another should return 409 + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/years/fy1/activate", "acme", url.Values{"year_id": {"fy1"}}) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgFiscalYearActivate(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (already active)", w.Code) + } +} + +func TestOrgFiscalYearActivate_NoDraftYears(t *testing.T) { + org, member, _ := testOrg() + // Store with a draft fiscal year (no active year) + draft := FiscalYear{ID: "fy2", OrgID: "org1", Label: "2026", Status: FiscalYearDraft, + StartDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{draft}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy2/activate", "acme", nil) + r.SetPathValue("year_id", "fy2") + w := httptest.NewRecorder() + h.OrgFiscalYearActivate(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgFiscalYearClose(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/years/fy1/close", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgFiscalYearClose(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgFiscalYearClose_Succeeds(t *testing.T) { + // OrgFiscalYearClose takes year_id from path and just closes it — no active check + org, _, _ := testOrg() + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": {ID: "m1", OrgID: "org1", UserID: "user1", Role: OrgRoleAdmin}}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/any-id/close", "acme", nil) + r.SetPathValue("year_id", "any-id") + w := httptest.NewRecorder() + h.OrgFiscalYearClose(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgEventList / New ──────────────────────────────────────────────────────── + +func TestOrgEventList_GET(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/events", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventList(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgEventNew_GET(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/events/new", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventNew(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgEventNew_POST(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "name": {"Annual Budget"}, "description": {"Review"}, + "date_start": {"2025-06-01"}, "date_end": {"2025-06-30"}, "year_id": {"fy1"}, + } + r := orgReq("POST", "/orgs/acme/events/new", "acme", form) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventNew(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgEventNew_POST_MissingName(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"name": {""}, "date_start": {"2025-06-01"}, "date_end": {"2025-06-30"}, "year_id": {"fy1"}} + r := orgReq("POST", "/orgs/acme/events/new", "acme", form) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventNew(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// ── OrgEventDetail ─────────────────────────────────────────────────────────── + +func TestOrgEventDetail_NotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/events/evt99", "acme", nil) + r.SetPathValue("event_id", "evt99") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventDetail(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgEventDetail_Found(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Budget Review", Status: EventDraft, CreatedAt: time.Now()}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/events/evt1", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventDetail(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + +// ── OrgEventEdit / Delete / Submit / Review / Feedback ─────────────────────── + +func TestOrgEventEdit_POST(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Budget Review", Status: EventDraft}} + h := newHandler(store) + form := url.Values{"name": {"Updated Budget"}, "description": {"Review"}, "date_start": {"2025-06-01"}, "date_end": {"2025-06-30"}} + r := orgReq("POST", "/orgs/acme/events/evt1/edit", "acme", form) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventEdit(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgEventEdit_NotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/events/bad/edit", "acme", nil) + r.SetPathValue("event_id", "bad") + w := httptest.NewRecorder() + h.OrgEventEdit(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgEventDelete(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventDraft}} + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/events/evt1", "acme", nil) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgEventDelete(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgEventSubmit(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/events/evt1/submit", "acme", nil) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgEventSubmit(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgEventSubmit_NotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/events/noexist/submit", "acme", nil) + r.SetPathValue("event_id", "noexist") + w := httptest.NewRecorder() + h.OrgEventSubmit(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgEventReview_Approve(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventReview, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/events/evt1/review", "acme", url.Values{"action": {"approve"}}) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgEventReview(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgEventReview_Reject(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventReview, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/events/evt1/review", "acme", url.Values{"action": {"reject"}, "comment": {"Too expensive"}}) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgEventReview(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgEventReview_CommentOnly(t *testing.T) { + // An unknown action (not "approve"/"reject") posts a comment and stays in review → 303 + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventReview, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/events/evt1/review", "acme", url.Values{"action": {"comment"}, "comment": {"looks good"}}) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventReview(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgEventFeedback(t *testing.T) { + // Feedback requires a closed fiscal year + org, member, _ := testOrg() + closedFY := FiscalYear{ID: "fy2", OrgID: "org1", Label: "2024", Status: FiscalYearClosed, + StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{closedFY}, + orgEvents: []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy2"}}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/events/evt1/feedback", "acme", url.Values{"comment": {"Looks good"}}) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy2") + w := httptest.NewRecorder() + h.OrgEventFeedback(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgEventFeedback_NotClosedYear(t *testing.T) { + // Feedback on active year returns 409 + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/events/evt1/feedback", "acme", url.Values{"comment": {"ok"}}) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventFeedback(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (year not closed)", w.Code) + } +} + +// ── OrgGoal CRUD ───────────────────────────────────────────────────────────── + +func TestOrgGoalAdd(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}} + h := newHandler(store) + // Handler uses "text" field, not "title" + form := url.Values{"text": {"Buy Equipment"}} + r := orgReq("POST", "/orgs/acme/events/evt1/goals", "acme", form) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgGoalAdd(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgGoalAdd_EmptyText(t *testing.T) { + // Handler checks text first (400), before looking up the event + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/events/evt1/goals", "acme", url.Values{"text": {""}}) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgGoalAdd(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestOrgGoalToggle(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}} + h := newHandler(store) + // done="1" to mark as done; handler also needs year_id (must be active) + r := orgReq("POST", "/orgs/acme/events/evt1/goals/g1/toggle", "acme", url.Values{"done": {"1"}}) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("goal_id", "g1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgGoalToggle(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +func TestOrgGoalDelete(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/events/evt1/goals/g1", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("goal_id", "g1") + w := httptest.NewRecorder() + h.OrgGoalDelete(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgBudgetLine ───────────────────────────────────────────────────────────── + +func TestOrgBudgetLineCreate(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}} + h := newHandler(store) + form := url.Values{"description": {"Office Rent"}, "amount": {"3000"}, "category": {"Facilities"}, "type": {"expense"}} + r := orgReq("POST", "/orgs/acme/events/evt1/budget", "acme", form) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgBudgetLineCreate(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgBudgetLineCreate_EventNotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/events/bad/budget", "acme", url.Values{"description": {"X"}, "amount": {"100"}}) + r.SetPathValue("event_id", "bad") + w := httptest.NewRecorder() + h.OrgBudgetLineCreate(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgBudgetLineDelete(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/events/evt1/budget/bl1", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("line_id", "bl1") + w := httptest.NewRecorder() + h.OrgBudgetLineDelete(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── OrgRequestList / New / Detail / Action ──────────────────────────────────── + +func TestOrgRequestList_GET(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/requests", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgRequestList(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgRequestNew_GET(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/requests/new", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgRequestNew_POST(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{"description": {"New server"}, "amount": {"2500"}, "type": {"purchase_order"}, "date": {"2025-04-01"}} + r := orgReq("POST", "/orgs/acme/requests/new", "acme", form) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgRequestDetail_NotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/requests/req99", "acme", nil) + r.SetPathValue("req_id", "req99") // handler uses "req_id" path value + w := httptest.NewRecorder() + h.OrgRequestDetail(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgRequestAction_NotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/requests/req99/action", "acme", url.Values{"action": {"approve"}}) + r.SetPathValue("req_id", "req99") // handler uses "req_id" path value + w := httptest.NewRecorder() + h.OrgRequestAction(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// ── OrgLedger ──────────────────────────────────────────────────────────────── + +func TestOrgLedger_GET(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/ledger", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgLedger(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgLedger_GET_WithQueryYear(t *testing.T) { + // OrgLedger is GET-only; pass year via query param + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/ledger?year_id=fy1", "acme", nil) + w := httptest.NewRecorder() + h.OrgLedger(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgBankImport ───────────────────────────────────────────────────────────── + +func TestOrgBankImport_GET(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/bank-import", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgBankImport(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200: %s", w.Code, w.Body.String()) + } +} + +func TestOrgBankImport_POST(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "csv_data": {"Date,Description,Amount\n2025-01-15,Office Supplies,-150.00\n"}, + "year_id": {"fy1"}, + } + r := orgReq("POST", "/orgs/acme/bank-import", "acme", form) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgBankImport(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgAnalysis ────────────────────────────────────────────────────────────── + +func TestOrgAnalysis_GET(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/analysis", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgAnalysis(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestOrgAnalysis_WithEvents(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Q1 Plan", Status: EventApproved, CreatedAt: time.Now()}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/analysis", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgAnalysis(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgReport ──────────────────────────────────────────────────────────────── + +func TestOrgReport_GET(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/report", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgReport(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── requireOrgMember / requireOrgRole ──────────────────────────────────────── + +func TestRequireOrgMember_NotMember(t *testing.T) { + org := &Org{ID: "org2", Name: "Other Corp", Slug: "other"} + store := &mockStore{ + orgsBySlug: map[string]*Org{"other": org}, + orgsByID: map[string]*Org{"org2": org}, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.OrgHome(w, orgReq("GET", "/orgs/other", "other", nil)) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} + +func TestRequireOrgRole_InsufficientRole(t *testing.T) { + org, _, fy := testOrg() + viewer := &OrgMember{ID: "m2", OrgID: "org1", UserID: "user1", Role: OrgRoleViewer} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": viewer}, + fiscalYears: []FiscalYear{*fy}, + } + h := newHandler(store) + w := httptest.NewRecorder() + h.OrgTeamCreate(w, orgReq("POST", "/orgs/acme/teams", "acme", url.Values{"name": {"Dev Team"}})) + if w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403", w.Code) + } +} + +// ── canManageOrg ────────────────────────────────────────────────────────────── + +func TestCanManageOrg(t *testing.T) { + if !canManageOrg(OrgRoleAdmin) { + t.Error("admin should manage") + } + if !canManageOrg(OrgRoleFinance) { + t.Error("finance should manage") + } + if canManageOrg(OrgRoleMember) { + t.Error("member should NOT manage") + } + if canManageOrg(OrgRoleViewer) { + t.Error("viewer should NOT manage") + } +} + +// ── models_org.go: CurrentStatus ───────────────────────────────────────────── + +func TestTxRequestCurrentStatus(t *testing.T) { + req := &TxRequest{} + if req.CurrentStatus() != TxDraft { + t.Errorf("empty log should return TxDraft, got %v", req.CurrentStatus()) + } + req.StatusLog = []StatusLogEntry{{Status: TxSubmitted}} + if req.CurrentStatus() != TxSubmitted { + t.Errorf("want TxSubmitted, got %v", req.CurrentStatus()) + } + req.StatusLog = append(req.StatusLog, StatusLogEntry{Status: TxApproved}) + if req.CurrentStatus() != TxApproved { + t.Errorf("want TxApproved, got %v", req.CurrentStatus()) + } +} + +// ── handler_property.go pure functions ─────────────────────────────────────── + +func TestLoanMonthlyPayment(t *testing.T) { + if p := loanMonthlyPayment(100000, 5.0, 0); p != 0 { + t.Errorf("zero term = %d, want 0", p) + } + if p := loanMonthlyPayment(120000, 0, 12); p != 10000 { + t.Errorf("zero rate = %d, want 10000", p) + } + p := loanMonthlyPayment(10000000, 5.0, 240) + if p < 60000 || p > 70000 { + t.Errorf("payment = %d, want ~66000", p) + } +} + +func TestLoanRemainingMonths(t *testing.T) { + if m := loanRemainingMonths(0, 5.0, 1000); m != 0 { + t.Errorf("zero balance = %d, want 0", m) + } + if m := loanRemainingMonths(100000, 5.0, 0); m != 0 { + t.Errorf("zero payment = %d, want 0", m) + } + if m := loanRemainingMonths(120000, 0, 10000); m != 12 { + t.Errorf("zero rate months = %d, want 12", m) + } + if m := loanRemainingMonths(1000000, 24.0, 100); m != 999 { + t.Errorf("insufficient payment = %d, want 999", m) + } +} + +func TestParseFormCents(t *testing.T) { + tests := []struct { + in string + want int64 + }{ + {"180000", 18000000}, + {"1500.50", 150050}, + {"1500,50", 150050}, + {"abc", 0}, + {"", 0}, + } + for _, tt := range tests { + if got := parseFormCents(tt.in); got != tt.want { + t.Errorf("parseFormCents(%q) = %d, want %d", tt.in, got, tt.want) + } + } +} + +func TestGenID(t *testing.T) { + a, b := genID(), genID() + if a == b { + t.Error("genID should return unique values") + } + if len(a) != 24 { + t.Errorf("genID length = %d, want 24", len(a)) + } +} + +func TestLoanBalanceAt(t *testing.T) { + if b := loanBalanceAt(10000000, 5.0, 66000, 0); b != 10000000 { + t.Errorf("balance at 0 = %d, want 10000000", b) + } + if b := loanBalanceAt(10000000, 5.0, 66000, 240); b < 0 || b > 500000 { + t.Errorf("balance at end = %d, want near 0", b) + } +} + +func TestProperties_GET(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.Properties(w, authReq("GET", "/property", nil)) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestProperties_POST_AddProperty(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"action": {"add_property"}, "name": {"My Flat"}, "address": {"123 Main St"}, "current_value": {"250000"}, "purchase_price": {"200000"}} + w := httptest.NewRecorder() + h.Properties(w, authReq("POST", "/property", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestProperties_POST_AddLoan(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"action": {"add_loan"}, "lender": {"Bank X"}, "balance": {"150000"}, "rate": {"3.5"}, "term_months": {"240"}, "monthly": {"800"}} + w := httptest.NewRecorder() + h.Properties(w, authReq("POST", "/property", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestProperties_POST_UpdateProperty(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"action": {"update_property"}, "id": {"prop1"}, "current_value": {"260000"}, "status": {"active"}} + w := httptest.NewRecorder() + h.Properties(w, authReq("POST", "/property", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestProperties_POST_DeleteProperty(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"action": {"delete_property"}, "id": {"prop1"}} + w := httptest.NewRecorder() + h.Properties(w, authReq("POST", "/property", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestProperties_POST_DeleteLoan(t *testing.T) { + h := newHandler(&mockStore{}) + form := url.Values{"action": {"delete_loan"}, "id": {"loan1"}} + w := httptest.NewRecorder() + h.Properties(w, authReq("POST", "/property", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestToLoanView(t *testing.T) { + loan := Loan{ID: "l1", UserID: "user1", Name: "Bank X", BalanceCents: 15000000, InterestRatePct: 3.5, TermMonths: 240, MonthlyPaymentCents: 80000, Status: LoanActive} + v := toLoanView(loan) + if v.ID != "l1" { + t.Errorf("ID = %q, want l1", v.ID) + } + if v.BalanceCents != 15000000 { + t.Errorf("BalanceCents = %d, want 15000000", v.BalanceCents) + } +} + +func TestToPropertyView_NoLoan(t *testing.T) { + prop := Property{ID: "p1", UserID: "user1", Name: "My Flat", CurrentValueCents: 25000000, Status: PropertyOwned} + v := toPropertyView(prop, nil) + if v.ID != "p1" { + t.Errorf("ID = %q, want p1", v.ID) + } + if v.EquityCents != 25000000 { + t.Errorf("EquityCents = %d, want 25000000", v.EquityCents) + } +} + +func TestToPropertyView_WithLoan(t *testing.T) { + prop := Property{ID: "p1", UserID: "user1", Name: "My Flat", CurrentValueCents: 25000000, Status: PropertyOwned} + loans := []Loan{{ID: "l1", UserID: "user1", PropertyID: "p1", BalanceCents: 10000000, InterestRatePct: 3.0, TermMonths: 180, MonthlyPaymentCents: 70000, Status: LoanActive}} + v := toPropertyView(prop, loans) + if v.LinkedLoan == nil { + t.Error("expected loan to be linked") + } + if v.EquityCents != 15000000 { + t.Errorf("EquityCents = %d, want 15000000", v.EquityCents) + } +} + +// ── handler_dream.go ───────────────────────────────────────────────────────── + +func TestDream_Redirect(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.Dream(w, authReq("GET", "/plan", nil)) + if w.Code != http.StatusMovedPermanently { + t.Errorf("status = %d, want 301", w.Code) + } + if !strings.Contains(w.Header().Get("Location"), "/goals") { + t.Error("expected redirect to /goals") + } +} + +func TestParseFloatParam(t *testing.T) { + if v := parseFloatParam("", 1.5); v != 1.5 { + t.Errorf("empty = %f, want 1.5", v) + } + if v := parseFloatParam("3.14", 0); v != 3.14 { + t.Errorf("valid = %f, want 3.14", v) + } + if v := parseFloatParam("abc", 2.0); v != 2.0 { + t.Errorf("invalid = %f, want 2.0", v) + } + if v := parseFloatParam("-1", 5.0); v != 5.0 { + t.Errorf("negative = %f, want 5.0", v) + } +} + +func TestParseIntParam(t *testing.T) { + if v := parseIntParam("", 10); v != 10 { + t.Errorf("empty = %d, want 10", v) + } + if v := parseIntParam("42", 0); v != 42 { + t.Errorf("valid = %d, want 42", v) + } + if v := parseIntParam("abc", 5); v != 5 { + t.Errorf("invalid = %d, want 5", v) + } + if v := parseIntParam("-3", 5); v != 5 { + t.Errorf("negative = %d, want 5", v) + } +} + +func TestRunDreamSim_Empty(t *testing.T) { + if result := runDreamSim(DreamForm{}, nil, nil); result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestRunDreamSim_WithValues(t *testing.T) { + form := DreamForm{ + DreamCostCents: 30000000, DownPaymentPct: 20.0, + ConstructionRatePct: 4.5, ConstructionTermYears: 20, MonthlySavingsCents: 200000, + } + if result := runDreamSim(form, nil, nil); result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestRunPurchaseSim(t *testing.T) { + if result := runPurchaseSim("Holiday", 500000, 50000, "2026-12"); result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestRunPurchaseSim_PastDeadline(t *testing.T) { + if result := runPurchaseSim("Old Goal", 100000, 1000, "2020-01"); result == nil { + t.Fatal("expected non-nil result") + } +} + +func TestRunPurchaseSim_AlreadySaved(t *testing.T) { + if result := runPurchaseSim("Done", 100000, 500000, "2026-12"); result == nil { + t.Fatal("expected non-nil result") + } +} + +// ── handler_auth.go helpers ─────────────────────────────────────────────────── + +func TestSignAndVerifySessionID(t *testing.T) { + h := newHandler(&mockStore{}) + id := "sess-abc-123" + token := h.signSessionID(id) + got, ok := h.verifySessionToken(token) + if !ok { + t.Error("expected valid token") + } + if got != id { + t.Errorf("got %q, want %q", got, id) + } +} + +func TestVerifySessionToken_Invalid(t *testing.T) { + h := newHandler(&mockStore{}) + if _, ok := h.verifySessionToken("no-dot-here"); ok { + t.Error("expected invalid: no dot") + } + if _, ok := h.verifySessionToken("abc.wrongsig"); ok { + t.Error("expected invalid: wrong sig") + } +} + +func TestRateLimiter_AllowAndFail(t *testing.T) { + rl := newLoginRateLimiter() + ip := "10.0.0.1" + if !rl.allow(ip) { + t.Error("expected allow before any failures") + } + for i := 0; i < rlMaxFailures; i++ { + rl.failure(ip) + } + if rl.allow(ip) { + t.Error("expected blocked after max failures") + } + rl.success(ip) + if !rl.allow(ip) { + t.Error("expected allow after success") + } +} + +func TestAuthLoginPost_EmptyFields(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthLogin(w, authReq("POST", "/auth/login", url.Values{"email": {""}, "password": {""}})) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestAuthLoginPost_UserNotFound(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthLogin(w, authReq("POST", "/auth/login", url.Values{"email": {"nobody@example.com"}, "password": {"pass123"}})) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestAuthRegisterPost_InvalidEmail(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthRegister(w, authReq("POST", "/auth/register", url.Values{"email": {"not-an-email"}, "password": {"secret123"}})) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestAuthRegisterPost_ShortPassword(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthRegister(w, authReq("POST", "/auth/register", url.Values{"email": {"user@example.com"}, "password": {"short"}})) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +func TestAuthLogout_Redirect(t *testing.T) { + h := newHandler(&mockStore{}) + w := httptest.NewRecorder() + h.AuthLogout(w, httptest.NewRequest("POST", "/auth/logout", nil)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } + if !strings.Contains(w.Header().Get("Location"), "login") { + t.Error("expected redirect to login") + } +} + +// ── i18n ───────────────────────────────────────────────────────────────────── + +func TestTranslator_MissingKey(t *testing.T) { + v := newT("en").Get("no.such.key.xyz") + if v != "" && !strings.Contains(v, "no.such.key") { + t.Errorf("missing key = %q, expected empty or key name", v) + } +} + +func TestTranslator_Lang(t *testing.T) { + if l := newT("en").Lang(); l != "en" { + t.Errorf("Lang() = %q, want en", l) + } + if l := newT("pt").Lang(); l != "pt" { + t.Errorf("Lang() = %q, want pt", l) + } +} + +func TestTranslator_UnsupportedLang(t *testing.T) { + if tx := newT("xx"); tx == nil { + t.Fatal("expected non-nil translator") + } +} + +func TestTranslator_FallsBackToEN(t *testing.T) { + enVal := newT("en").Get("nav.dashboard") + ptVal := newT("pt").Get("nav.dashboard") + if enVal == "" { + t.Error("EN nav.dashboard should not be empty") + } + if ptVal == "" { + t.Error("PT nav.dashboard should not be empty") + } +} + +// ── securityHeaders middleware ──────────────────────────────────────────────── + +func TestSecurityHeaders(t *testing.T) { + h := newHandler(&mockStore{}) + called := false + mw := h.securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + mw.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) + if !called { + t.Error("handler not called through securityHeaders") + } +} + +// ── parseEuroAmount ──────────────────────────────────────────────────────────── + +func TestParseEuroAmount(t *testing.T) { + tests := []struct { + in string + want int64 + wantErr bool + }{ + {"150", 15000, false}, + {"150.50", 15050, false}, + {"150,50", 15050, false}, // comma as decimal + {"150.5", 15050, false}, // one decimal digit + {"150.505", 15050, false}, // truncates to 2 decimals + {"0", 0, false}, + {"", 0, false}, + {"abc", 0, true}, + {"-50", -5000, false}, // negative amounts are valid (expenses) + } + for _, tt := range tests { + got, err := parseEuroAmount(tt.in) + if tt.wantErr { + if err == nil { + t.Errorf("parseEuroAmount(%q) expected error, got %d", tt.in, got) + } + continue + } + if err != nil { + t.Errorf("parseEuroAmount(%q) unexpected error: %v", tt.in, err) + continue + } + if got != tt.want { + t.Errorf("parseEuroAmount(%q) = %d, want %d", tt.in, got, tt.want) + } + } +} + +func TestParseInt64(t *testing.T) { + if v, err := parseInt64(""); v != 0 || err != nil { + t.Errorf("empty = %d/%v, want 0/nil", v, err) + } + if v, err := parseInt64("42"); v != 42 || err != nil { + t.Errorf("42 = %d/%v", v, err) + } + if v, err := parseInt64("-10"); v != -10 || err != nil { + t.Errorf("-10 = %d/%v", v, err) + } + if _, err := parseInt64("abc"); err == nil { + t.Error("expected error for 'abc'") + } +} + +func TestMaxHelper(t *testing.T) { + if max(3, 5) != 5 { + t.Error("max(3,5) should be 5") + } + if max(5, 3) != 5 { + t.Error("max(5,3) should be 5") + } + if max(4, 4) != 4 { + t.Error("max(4,4) should be 4") + } +} + +// ── csvReader / parseBankCSV ─────────────────────────────────────────────────── + +func TestCsvReader(t *testing.T) { + data := "Name,Value\nAlice,42\nBob,99\n" + rows, err := csvReader(strings.NewReader(data)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rows) != 3 { + t.Errorf("rows = %d, want 3", len(rows)) + } +} + +func TestParseBankCSV_Valid(t *testing.T) { + // Note: '+' prefix on amounts fails parseEuroAmount, use plain numbers + data := "date,description,amount\n2025-01-15,Office Supplies,-150.00\n2025-01-20,Income,500.00\n" + rows, err := parseBankCSV(strings.NewReader(data)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(rows) != 2 { + t.Errorf("rows = %d, want 2", len(rows)) + } +} + +func TestParseBankCSV_Empty(t *testing.T) { + _, err := parseBankCSV(strings.NewReader("")) + if err == nil { + t.Error("expected error for empty CSV") + } +} + +func TestParseBankCSV_HeaderOnly(t *testing.T) { + // Only 1 record total (the header) → "CSV is empty" error + _, err := parseBankCSV(strings.NewReader("date,description,amount\n")) + if err == nil { + t.Error("header-only (1 record) should return 'CSV is empty' error") + } +} + +func TestParseBankCSV_MissingColumns(t *testing.T) { + _, err := parseBankCSV(strings.NewReader("name,value\nAlice,42\n")) + if err == nil { + t.Error("expected error for CSV missing required columns") + } +} + +// ── OrgRequestDelivery / Settle: not-found paths ────────────────────────────── + +func TestOrgRequestDelivery_NotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/requests/bad/delivery", "acme", url.Values{"actual_amount": {"150"}}) + r.SetPathValue("req_id", "bad") + w := httptest.NewRecorder() + h.OrgRequestDelivery(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgRequestSettle_NotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/requests/bad/settle", "acme", nil) + r.SetPathValue("req_id", "bad") + w := httptest.NewRecorder() + h.OrgRequestSettle(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +func TestOrgRequestUpload_NotFound(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("POST", "/orgs/acme/requests/bad/upload", "acme", nil) + r.SetPathValue("req_id", "bad") + w := httptest.NewRecorder() + h.OrgRequestUpload(w, r) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want 404", w.Code) + } +} + +// ── OrgRequestAction: found + submit ───────────────────────────────────────── + +func TestOrgRequestAction_SubmitDraft(t *testing.T) { + store := newOrgStore() + req := TxRequest{ + ID: "req1", OrgID: "org1", SubmittedBy: "user1", + Type: TxPurchaseOrder, AmountCents: 50000, + StatusLog: []StatusLogEntry{{Status: TxDraft}}, + } + store.txRequests = []TxRequest{req} + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/requests/req1/action", "acme", url.Values{"action": {"submit"}}) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestAction(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +func TestOrgRequestAction_Cancel(t *testing.T) { + store := newOrgStore() + req := TxRequest{ + ID: "req1", OrgID: "org1", SubmittedBy: "user1", + Type: TxPurchaseOrder, AmountCents: 50000, + StatusLog: []StatusLogEntry{{Status: TxDraft}}, + } + store.txRequests = []TxRequest{req} + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/requests/req1/action", "acme", url.Values{"action": {"cancel"}}) + r.SetPathValue("req_id", "req1") + w := httptest.NewRecorder() + h.OrgRequestAction(w, r) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String()) + } +} + +// ── OrgEventList with year_id ───────────────────────────────────────────────── + +func TestOrgEventList_WithYearID(t *testing.T) { + h := newHandler(newOrgStore()) + r := orgReq("GET", "/orgs/acme/years/fy1/events", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventList(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgReport with events ───────────────────────────────────────────────────── + +func TestOrgReport_WithEvents(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{ + {ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Q1", Status: EventApproved}, + } + h := newHandler(store) + r := orgReq("GET", "/orgs/acme/report", "acme", nil) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgReport(w, r) + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200", w.Code) + } +} + +// ── OrgRequestNew: invalid type ─────────────────────────────────────────────── + +func TestOrgRequestNew_InvalidType(t *testing.T) { + // OrgRequestNew validates "type" field — description emptiness is allowed + h := newHandler(newOrgStore()) + form := url.Values{"description": {""}, "amount": {"100"}, "type": {"unknown_type"}} + r := orgReq("POST", "/orgs/acme/requests/new", "acme", form) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgRequestNew(w, r) + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +// ── OrgFiscalYearActivate: all events approved ──────────────────────────────── + +func TestOrgFiscalYearActivate_UnapprovedEvent(t *testing.T) { + org, member, _ := testOrg() + draft := FiscalYear{ID: "fy2", OrgID: "org1", Label: "2026", Status: FiscalYearDraft, + StartDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{draft}, + orgEvents: []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy2", Name: "Pending", Status: EventDraft}}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/years/fy2/activate", "acme", nil) + r.SetPathValue("year_id", "fy2") + w := httptest.NewRecorder() + h.OrgFiscalYearActivate(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (unapproved event)", w.Code) + } +} + +// ── OrgBankImport POST: valid CSV import ────────────────────────────────────── + +func TestOrgBankImport_POST_ValidCSV(t *testing.T) { + h := newHandler(newOrgStore()) + form := url.Values{ + "csv_data": {"date,description,amount\n2025-01-15,Coffee,-15.00\n2025-01-20,Revenue,1000.00\n"}, + } + r := orgReq("POST", "/orgs/acme/bank-import", "acme", form) + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgBankImport(w, r) + if w.Code == http.StatusInternalServerError { + t.Errorf("unexpected 500: %s", w.Body.String()) + } +} + +// ── OrgJoin: expired token ──────────────────────────────────────────────────── + +func TestOrgJoin_ExpiredToken(t *testing.T) { + // The mock returns the invite regardless of expiry (real store would filter). + // Handler renders the join page for any found invite — expiry is enforced in store. + org, _, _ := testOrg() + store := &mockStore{ + orgsByID: map[string]*Org{"org1": org}, + invitesByToken: map[string]*OrgInvite{ + "expired": { + ID: "inv1", OrgID: "org1", Email: "old@example.com", + Role: OrgRoleMember, Token: "expired", + ExpiresAt: time.Now().Add(-24 * time.Hour), + }, + }, + } + h := newHandler(store) + r := authReq("GET", "/orgs/join/expired", nil) + r.SetPathValue("token", "expired") + w := httptest.NewRecorder() + h.OrgJoin(w, r) + // Mock returns the invite (no expiry check in mock) → handler renders join page + if w.Code == http.StatusInternalServerError { + t.Error("unexpected 500") + } +} + +// ── OrgEventDelete: non-draft event ────────────────────────────────────────── + +func TestOrgEventDelete_NonDraft(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventReview}} + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/events/evt1", "acme", nil) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgEventDelete(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (can't delete non-draft)", w.Code) + } +} + +// ── OrgEventSubmit: non-draft event ────────────────────────────────────────── + +func TestOrgEventSubmit_NonDraft(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventReview}} + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/events/evt1/submit", "acme", nil) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgEventSubmit(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (can't submit non-draft)", w.Code) + } +} + +// ── OrgGoalDelete: approved event ──────────────────────────────────────────── + +func TestOrgGoalDelete_ApprovedEvent(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventApproved}} + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/events/evt1/goals/g1", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("goal_id", "g1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgGoalDelete(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (can't delete goals from approved event)", w.Code) + } +} + +// ── OrgBudgetLineCreate: approved event ────────────────────────────────────── + +func TestOrgBudgetLineCreate_ApprovedEvent(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}} + h := newHandler(store) + form := url.Values{"description": {"X"}, "amount": {"100"}, "type": {"expense"}} + r := orgReq("POST", "/orgs/acme/events/evt1/budget", "acme", form) + r.SetPathValue("event_id", "evt1") + w := httptest.NewRecorder() + h.OrgBudgetLineCreate(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409", w.Code) + } +} + +// ── OrgBudgetLineDelete: approved event ────────────────────────────────────── + +func TestOrgBudgetLineDelete_ApprovedEvent(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("DELETE", "/orgs/acme/events/evt1/budget/bl1", "acme", nil) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("line_id", "bl1") + w := httptest.NewRecorder() + h.OrgBudgetLineDelete(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409", w.Code) + } +} + +// ── OrgEventReview: not-under-review ───────────────────────────────────────── + +func TestOrgEventReview_NotUnderReview(t *testing.T) { + store := newOrgStore() + store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}} + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/events/evt1/review", "acme", url.Values{"action": {"approve"}}) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgEventReview(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (event not under review)", w.Code) + } +} + +// ── OrgGoalToggle: non-active year ─────────────────────────────────────────── + +func TestOrgGoalToggle_InactiveYear(t *testing.T) { + org, member, _ := testOrg() + closed := FiscalYear{ID: "fy1", OrgID: "org1", Label: "2025", Status: FiscalYearClosed, + StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)} + store := &mockStore{ + orgsBySlug: map[string]*Org{"acme": org}, + orgsByID: map[string]*Org{"org1": org}, + membersByKey: map[string]*OrgMember{"org1:user1": member}, + fiscalYears: []FiscalYear{closed}, + orgEvents: []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}}, + } + h := newHandler(store) + r := orgReq("POST", "/orgs/acme/events/evt1/goals/g1/toggle", "acme", url.Values{"done": {"1"}}) + r.SetPathValue("event_id", "evt1") + r.SetPathValue("goal_id", "g1") + r.SetPathValue("year_id", "fy1") + w := httptest.NewRecorder() + h.OrgGoalToggle(w, r) + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409 (closed year)", w.Code) + } +} + +// ── OrgCreate: duplicate slug ───────────────────────────────────────────────── + +func TestOrgCreate_POST_SlugCheck(t *testing.T) { + // Mock's slugExists always returns false, so any slug succeeds + h := newHandler(&mockStore{}) + form := url.Values{"name": {"Test Corp"}, "slug": {"test-corp"}} + w := httptest.NewRecorder() + h.OrgCreate(w, authReq("POST", "/orgs/new", form)) + if w.Code != http.StatusSeeOther { + t.Errorf("status = %d, want 303", w.Code) + } +} + +// ── fmt sentinel ────────────────────────────────────────────────────────────── + +var _ = fmt.Sprintf diff --git a/apps/finance/services/api/main/handler_test.go b/apps/finance/services/api/main/handler_test.go index 01cfe3f..48f3bf0 100644 --- a/apps/finance/services/api/main/handler_test.go +++ b/apps/finance/services/api/main/handler_test.go @@ -25,12 +25,29 @@ type mockStore struct { trades []Trade goals []Goal permissions []Permission + properties []Property + loans []Loan - createGoalErr error - updateGoalErr error - deleteTransactionErr error - createTransactionErr error - createTradesErr error + createGoalErr error + updateGoalErr error + deleteTransactionErr error + createTransactionErr error + createTradesErr error + updateFiscalYearStatusErr error + + authUsers map[string]*AuthUser + sessions []AuthSession + household *Household + deleteAllUserDataErr error + + // Org support (keyed for lookup) + orgsBySlug map[string]*Org + orgsByID map[string]*Org + membersByKey map[string]*OrgMember // key: orgID+":"+userID + invitesByToken map[string]*OrgInvite + fiscalYears []FiscalYear + orgEvents []OrgEvent + txRequests []TxRequest } func (m *mockStore) getAccounts(_ context.Context, _ string) ([]Account, error) { @@ -160,6 +177,9 @@ func (m *mockStore) getTickerMappings(_ context.Context, _ string) ([]TickerMapp func (m *mockStore) saveTickerMapping(_ context.Context, _, _, _ string) error { return nil } func (m *mockStore) getHousehold(_ context.Context, _ string) (*Household, error) { + if m.household != nil { + return m.household, nil + } return nil, fmt.Errorf("not found") } func (m *mockStore) createHousehold(_ context.Context, _ *Household) error { return nil } @@ -172,24 +192,74 @@ func (m *mockStore) deleteImportSchedule(_ context.Context, _, _ string) error // ── Property & Loan stubs ───────────────────────────────────────────────────── -func (m *mockStore) getProperties(_ context.Context, _ string) ([]Property, error) { return nil, nil } -func (m *mockStore) getProperty(_ context.Context, _, _ string) (*Property, error) { return nil, nil } -func (m *mockStore) createProperty(_ context.Context, _ *Property) error { return nil } -func (m *mockStore) updateProperty(_ context.Context, _, _ string, _ bson.M) error { return nil } -func (m *mockStore) deleteProperty(_ context.Context, _, _ string) error { return nil } -func (m *mockStore) getLoans(_ context.Context, _ string) ([]Loan, error) { return nil, nil } -func (m *mockStore) getLoan(_ context.Context, _, _ string) (*Loan, error) { return nil, nil } -func (m *mockStore) createLoan(_ context.Context, _ *Loan) error { return nil } -func (m *mockStore) updateLoan(_ context.Context, _, _ string, _ bson.M) error { return nil } -func (m *mockStore) deleteLoan(_ context.Context, _, _ string) error { return nil } +func (m *mockStore) getProperties(_ context.Context, _ string) ([]Property, error) { + return m.properties, nil +} +func (m *mockStore) getProperty(_ context.Context, id, _ string) (*Property, error) { + for i := range m.properties { + if m.properties[i].ID == id { + return &m.properties[i], nil + } + } + return nil, nil +} +func (m *mockStore) createProperty(_ context.Context, p *Property) error { + m.properties = append(m.properties, *p) + return nil +} +func (m *mockStore) updateProperty(_ context.Context, _, _ string, _ bson.M) error { return nil } +func (m *mockStore) deleteProperty(_ context.Context, id, _ string) error { + for i, p := range m.properties { + if p.ID == id { + m.properties = append(m.properties[:i], m.properties[i+1:]...) + return nil + } + } + return nil +} +func (m *mockStore) getLoans(_ context.Context, _ string) ([]Loan, error) { + return m.loans, nil +} +func (m *mockStore) getLoan(_ context.Context, id, _ string) (*Loan, error) { + for i := range m.loans { + if m.loans[i].ID == id { + return &m.loans[i], nil + } + } + return nil, nil +} +func (m *mockStore) createLoan(_ context.Context, l *Loan) error { + m.loans = append(m.loans, *l) + return nil +} +func (m *mockStore) updateLoan(_ context.Context, _, _ string, _ bson.M) error { return nil } +func (m *mockStore) deleteLoan(_ context.Context, id, _ string) error { + for i, l := range m.loans { + if l.ID == id { + m.loans = append(m.loans[:i], m.loans[i+1:]...) + return nil + } + } + return nil +} // ── Org stubs (not exercised in unit tests) ─────────────────────────────────── func (m *mockStore) getOrgsForUser(_ context.Context, _ string) ([]OrgWithRole, error) { return nil, nil } -func (m *mockStore) getOrg(_ context.Context, _ string) (*Org, error) { return nil, nil } -func (m *mockStore) getOrgBySlug(_ context.Context, _ string) (*Org, error) { return nil, nil } +func (m *mockStore) getOrg(_ context.Context, id string) (*Org, error) { + if o, ok := m.orgsByID[id]; ok { + return o, nil + } + return nil, fmt.Errorf("not found") +} +func (m *mockStore) getOrgBySlug(_ context.Context, slug string) (*Org, error) { + if o, ok := m.orgsBySlug[slug]; ok { + return o, nil + } + return nil, fmt.Errorf("not found") +} func (m *mockStore) createOrg(_ context.Context, _ *Org) error { return nil } func (m *mockStore) slugExists(_ context.Context, _ string) (bool, error) { return false, nil } func (m *mockStore) getTeams(_ context.Context, _ string) ([]OrgTeam, error) { return nil, nil } @@ -197,26 +267,60 @@ func (m *mockStore) getTeam(_ context.Context, _, _ string) (*OrgTeam, error) func (m *mockStore) createTeam(_ context.Context, _ *OrgTeam) error { return nil } func (m *mockStore) deleteTeam(_ context.Context, _, _ string) error { return nil } func (m *mockStore) getMembers(_ context.Context, _ string) ([]OrgMember, error) { return nil, nil } -func (m *mockStore) getMember(_ context.Context, _, _ string) (*OrgMember, error) { - return nil, nil +func (m *mockStore) getMember(_ context.Context, orgID, userID string) (*OrgMember, error) { + key := orgID + ":" + userID + if me, ok := m.membersByKey[key]; ok { + return me, nil + } + return nil, fmt.Errorf("not a member") } func (m *mockStore) createMember(_ context.Context, _ *OrgMember) error { return nil } func (m *mockStore) updateMemberRole(_ context.Context, _, _ string, _ OrgRole) error { return nil } func (m *mockStore) removeMember(_ context.Context, _, _ string) error { return nil } func (m *mockStore) getInvites(_ context.Context, _ string) ([]OrgInvite, error) { return nil, nil } -func (m *mockStore) getInviteByToken(_ context.Context, _ string) (*OrgInvite, error) { return nil, nil } +func (m *mockStore) getInviteByToken(_ context.Context, token string) (*OrgInvite, error) { + if inv, ok := m.invitesByToken[token]; ok { + return inv, nil + } + return nil, fmt.Errorf("not found") +} func (m *mockStore) createInvite(_ context.Context, _ *OrgInvite) error { return nil } func (m *mockStore) consumeInvite(_ context.Context, _ string) error { return nil } func (m *mockStore) revokeInvite(_ context.Context, _, _ string) error { return nil } -func (m *mockStore) getFiscalYears(_ context.Context, _ string) ([]FiscalYear, error) { return nil, nil } -func (m *mockStore) getFiscalYear(_ context.Context, _, _ string) (*FiscalYear, error) { return nil, nil } -func (m *mockStore) getActiveFiscalYear(_ context.Context, _ string) (*FiscalYear, error) { return nil, nil } +func (m *mockStore) getFiscalYears(_ context.Context, _ string) ([]FiscalYear, error) { + return m.fiscalYears, nil +} +func (m *mockStore) getFiscalYear(_ context.Context, id, _ string) (*FiscalYear, error) { + for _, y := range m.fiscalYears { + if y.ID == id { + return &y, nil + } + } + return nil, fmt.Errorf("not found") +} +func (m *mockStore) getActiveFiscalYear(_ context.Context, _ string) (*FiscalYear, error) { + for _, y := range m.fiscalYears { + if y.Status == FiscalYearActive { + return &y, nil + } + } + return nil, fmt.Errorf("no active fiscal year") +} func (m *mockStore) createFiscalYear(_ context.Context, _ *FiscalYear) error { return nil } func (m *mockStore) updateFiscalYearStatus(_ context.Context, _, _ string, _ FiscalYearStatus, _ bson.M) error { - return nil + return m.updateFiscalYearStatusErr +} +func (m *mockStore) getEvents(_ context.Context, _, _ string) ([]OrgEvent, error) { + return m.orgEvents, nil +} +func (m *mockStore) getEvent(_ context.Context, id, _ string) (*OrgEvent, error) { + for i := range m.orgEvents { + if m.orgEvents[i].ID == id { + return &m.orgEvents[i], nil + } + } + return nil, fmt.Errorf("event not found") } -func (m *mockStore) getEvents(_ context.Context, _, _ string) ([]OrgEvent, error) { return nil, nil } -func (m *mockStore) getEvent(_ context.Context, _, _ string) (*OrgEvent, error) { return nil, nil } func (m *mockStore) createEvent(_ context.Context, _ *OrgEvent) error { return nil } func (m *mockStore) updateEvent(_ context.Context, _, _ string, _ bson.M) error { return nil } func (m *mockStore) deleteEvent(_ context.Context, _, _ string) error { return nil } @@ -231,7 +335,14 @@ func (m *mockStore) deleteBudgetLine(_ context.Context, _, _ string) error func (m *mockStore) getEventComments(_ context.Context, _, _ string) ([]EventComment, error) { return nil, nil } func (m *mockStore) createEventComment(_ context.Context, _ *EventComment) error { return nil } func (m *mockStore) getTxRequests(_ context.Context, _ string, _ bson.M) ([]TxRequest, error) { return nil, nil } -func (m *mockStore) getTxRequest(_ context.Context, _, _ string) (*TxRequest, error) { return nil, nil } +func (m *mockStore) getTxRequest(_ context.Context, id, _ string) (*TxRequest, error) { + for i := range m.txRequests { + if m.txRequests[i].ID == id { + return &m.txRequests[i], nil + } + } + return nil, fmt.Errorf("not found") +} func (m *mockStore) createTxRequest(_ context.Context, _ *TxRequest) error { return nil } func (m *mockStore) appendStatusLog(_ context.Context, _, _ string, _ StatusLogEntry) error { return nil } func (m *mockStore) updateTxRequest(_ context.Context, _, _ string, _ bson.M) error { return nil } @@ -245,18 +356,54 @@ func (m *mockStore) getAttachments(_ context.Context, _, _ string) ([]OrgAttachm } func (m *mockStore) createAttachment(_ context.Context, _ *OrgAttachment) error { return nil } -func (m *mockStore) createAuthUser(_ context.Context, _ *AuthUser) error { return nil } -func (m *mockStore) findAuthUserByEmail(_ context.Context, _ string) (*AuthUser, error) { +func (m *mockStore) createAuthUser(_ context.Context, _ *AuthUser) error { return nil } +func (m *mockStore) findAuthUserByEmail(_ context.Context, email string) (*AuthUser, error) { + if m.authUsers != nil { + for _, u := range m.authUsers { + if u.Email == email { + return u, nil + } + } + } return nil, nil } func (m *mockStore) findAuthUserByProvider(_ context.Context, _, _ string) (*AuthUser, error) { return nil, nil } -func (m *mockStore) createAuthSession(_ context.Context, _ *AuthSession) error { return nil } -func (m *mockStore) getAuthSession(_ context.Context, _ string) (*AuthSession, error) { +func (m *mockStore) createAuthSession(_ context.Context, sess *AuthSession) error { + m.sessions = append(m.sessions, *sess) + return nil +} +func (m *mockStore) getAuthSession(_ context.Context, id string) (*AuthSession, error) { + for i := range m.sessions { + if m.sessions[i].ID.Hex() == id { + return &m.sessions[i], nil + } + } return nil, nil } func (m *mockStore) deleteAuthSession(_ context.Context, _ string) error { return nil } +func (m *mockStore) findAuthUserByID(_ context.Context, userID string) (*AuthUser, error) { + if m.authUsers != nil { + if u, ok := m.authUsers[userID]; ok { + return u, nil + } + } + return nil, nil +} +func (m *mockStore) getSessionsByUserID(_ context.Context, _ string) ([]AuthSession, error) { + return m.sessions, nil +} +func (m *mockStore) deleteSessionForUser(_ context.Context, _, _ string) error { return nil } +func (m *mockStore) deleteAllUserData(_ context.Context, _ string) error { + return m.deleteAllUserDataErr +} +func (m *mockStore) getGoalFundedCentsAll(_ context.Context, _ string) (map[string]int64, error) { + return nil, nil +} +func (m *mockStore) getGoalTransactions(_ context.Context, _, _ string) ([]Transaction, error) { + return nil, nil +} // ── helpers ─────────────────────────────────────────────────────────────────── @@ -907,6 +1054,7 @@ func TestDashboard_GoalMissAlert(t *testing.T) { TargetCents: 5000000, SavedCents: 0, Deadline: now.AddDate(0, 5, 0), + Committed: true, }}, } h := newHandler(store) diff --git a/apps/finance/services/api/main/locales/en.toml b/apps/finance/services/api/main/locales/en.toml index 3190361..842ee9d 100644 --- a/apps/finance/services/api/main/locales/en.toml +++ b/apps/finance/services/api/main/locales/en.toml @@ -8,7 +8,8 @@ portfolio = "Portfolio" goals = "Goals" property = "Property" people = "People" -business = "🏢 Business" +business = "Business" +account = "Account" hub_back = "← Hub" [nav.analysis] @@ -1085,3 +1086,29 @@ body = "How many months it would actually take based on your average monthly title = "Free cash after this goal" body = "Your estimated monthly free cash if you commit to this goal. Red means you'd need to cut spending elsewhere." formula = "Income − Living − All committed goals − This goal" + +# ── Account / security page ─────────────────────────────────────────────────── + +[account] +title = "Account & Security" + +[account.sessions] +title = "Active sessions" +subtitle = "Every device currently signed into your account. Revoke any session you don't recognise." +this_device = "This device" +signed_in = "Signed in" +btn_revoke = "Revoke" +confirm_revoke = "Sign out this session? The device will need to log in again." +none = "No active sessions found." + +[account.delete] +title = "Delete account" +subtitle = "Permanently deletes your account and all associated data — transactions, goals, accounts, portfolio, and everything else. This cannot be undone." +label_password = "Confirm your password" +label_confirm_email = "Type your email address to confirm" +btn_delete = "Delete my account permanently" +confirm = "This will permanently delete all your data. There is no undo. Are you sure?" +error_wrong_password = "Incorrect password." +error_wrong_email = "Email address does not match." +error_generic = "Something went wrong. Please try again." +success_login = "Your account has been deleted. All data has been removed." diff --git a/apps/finance/services/api/main/locales/pt.toml b/apps/finance/services/api/main/locales/pt.toml index 7ca4eca..94ec723 100644 --- a/apps/finance/services/api/main/locales/pt.toml +++ b/apps/finance/services/api/main/locales/pt.toml @@ -8,7 +8,8 @@ portfolio = "Carteira" goals = "Objetivos" property = "Imóveis" people = "Pessoas" -business = "🏢 Empresa" +business = "Empresa" +account = "Conta" hub_back = "← Hub" [nav.analysis] @@ -1085,3 +1086,29 @@ body = "Quantos meses levaria realmente com base na sua poupança mensal méd title = "Dinheiro livre após este objetivo" body = "O seu dinheiro livre mensal estimado se se comprometer com este objetivo. Vermelho significa que precisaria de reduzir despesas." formula = "Rendimento − Vida − Todos os objetivos comprometidos − Este objetivo" + +# ── Página de conta / segurança ─────────────────────────────────────────────── + +[account] +title = "Conta & Segurança" + +[account.sessions] +title = "Sessões ativas" +subtitle = "Todos os dispositivos com sessão iniciada na sua conta. Revogue qualquer sessão que não reconheça." +this_device = "Este dispositivo" +signed_in = "Sessão iniciada" +btn_revoke = "Revogar" +confirm_revoke = "Terminar esta sessão? O dispositivo terá de iniciar sessão novamente." +none = "Nenhuma sessão ativa encontrada." + +[account.delete] +title = "Eliminar conta" +subtitle = "Elimina permanentemente a sua conta e todos os dados associados — transações, objetivos, contas, carteira e tudo o mais. Esta ação não pode ser desfeita." +label_password = "Confirme a sua palavra-passe" +label_confirm_email = "Escreva o seu endereço de e-mail para confirmar" +btn_delete = "Eliminar a minha conta permanentemente" +confirm = "Isto irá eliminar permanentemente todos os seus dados. Não há forma de desfazer. Tem a certeza?" +error_wrong_password = "Palavra-passe incorreta." +error_wrong_email = "O endereço de e-mail não corresponde." +error_generic = "Algo correu mal. Por favor, tente novamente." +success_login = "A sua conta foi eliminada. Todos os dados foram removidos." diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 976d1c7..de38316 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -314,6 +314,28 @@ type PeopleData struct { PartnerGoals []GoalPlan } +// SessionView is a display-safe projection of AuthSession. +type SessionView struct { + ID string + CreatedAt time.Time + IPAddress string + Device string + IsCurrent bool +} + +// AccountData backs the /account security page. +type AccountData struct { + T *Translator + UserID string + Email string + Title string + Route string + Sessions []SessionView + HasPassword bool // false for OAuth-only accounts + Error string + Success string +} + // SettingsData combines Accounts and Categories into a single page. type SettingsData struct { T *Translator diff --git a/apps/finance/services/api/main/models_auth.go b/apps/finance/services/api/main/models_auth.go index e80b484..e08898e 100644 --- a/apps/finance/services/api/main/models_auth.go +++ b/apps/finance/services/api/main/models_auth.go @@ -22,4 +22,6 @@ type AuthSession struct { Email string `bson:"email"` ExpiresAt time.Time `bson:"expires_at"` CreatedAt time.Time `bson:"created_at"` + IPAddress string `bson:"ip,omitempty"` + Device string `bson:"device,omitempty"` // "Chrome on macOS" etc. } diff --git a/apps/finance/services/api/main/store_auth.go b/apps/finance/services/api/main/store_auth.go index 68ced60..6948f00 100644 --- a/apps/finance/services/api/main/store_auth.go +++ b/apps/finance/services/api/main/store_auth.go @@ -25,6 +25,19 @@ func (s *Store) findAuthUserByEmail(ctx context.Context, email string) (*AuthUse return &u, err } +func (s *Store) findAuthUserByID(ctx context.Context, userID string) (*AuthUser, error) { + oid, err := bson.ObjectIDFromHex(userID) + if err != nil { + return nil, nil + } + var u AuthUser + err = s.db.Collection("finance_users").FindOne(ctx, bson.M{"_id": oid}).Decode(&u) + if err == mongo.ErrNoDocuments { + return nil, nil + } + return &u, err +} + func (s *Store) findAuthUserByProvider(ctx context.Context, provider, providerID string) (*AuthUser, error) { var u AuthUser err := s.db.Collection("finance_users").FindOne(ctx, bson.M{ @@ -66,6 +79,75 @@ func (s *Store) deleteAuthSession(ctx context.Context, id string) error { return err } +func (s *Store) getSessionsByUserID(ctx context.Context, userID string) ([]AuthSession, error) { + oid, err := bson.ObjectIDFromHex(userID) + if err != nil { + return nil, nil + } + cur, err := s.db.Collection("finance_sessions").Find(ctx, + bson.M{"user_id": oid}, + options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}), + ) + if err != nil { + return nil, err + } + var sessions []AuthSession + if err := cur.All(ctx, &sessions); err != nil { + return nil, err + } + return sessions, nil +} + +func (s *Store) deleteSessionForUser(ctx context.Context, sessionID, userID string) error { + sid, err := bson.ObjectIDFromHex(sessionID) + if err != nil { + return nil + } + uid, err := bson.ObjectIDFromHex(userID) + if err != nil { + return nil + } + _, err = s.db.Collection("finance_sessions").DeleteOne(ctx, bson.M{"_id": sid, "user_id": uid}) + return err +} + +// deleteAllUserData purges every record belonging to userID across all collections, +// then deletes the user account itself. Irreversible. +func (s *Store) deleteAllUserData(ctx context.Context, userID string) error { + uid, err := bson.ObjectIDFromHex(userID) + if err != nil { + return err + } + filter := bson.M{"user_id": uid} + orFilter := bson.M{"$or": bson.A{bson.M{"owner_id": uid}, bson.M{"partner_id": uid}}} + orFilterPerms := bson.M{"$or": bson.A{bson.M{"owner_id": uid}, bson.M{"viewer_id": uid}}} + + collections := []struct { + name string + filter interface{} + }{ + {"finance_accounts", filter}, + {"finance_categories", filter}, + {"finance_transactions", filter}, + {"finance_trades", filter}, + {"finance_ticker_mappings", filter}, + {"finance_goals", filter}, + {"finance_import_schedules", filter}, + {"finance_properties", filter}, + {"finance_loans", filter}, + {"finance_permissions", orFilterPerms}, + {"finance_households", orFilter}, + {"finance_sessions", bson.M{"user_id": uid}}, + } + for _, c := range collections { + if _, err := s.db.Collection(c.name).DeleteMany(ctx, c.filter); err != nil { + return err + } + } + _, err = s.db.Collection("finance_users").DeleteOne(ctx, bson.M{"_id": uid}) + return err +} + func (s *Store) ensureAuthIndexes(ctx context.Context) { s.db.Collection("finance_users").Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "email", Value: 1}}, diff --git a/apps/finance/services/api/main/templates/account.html b/apps/finance/services/api/main/templates/account.html new file mode 100644 index 0000000..8985541 --- /dev/null +++ b/apps/finance/services/api/main/templates/account.html @@ -0,0 +1,104 @@ +{{template "base" .}} + +{{define "content"}} +{{$d := .}} + + +

{{$d.T.Get "account.title"}}

+

{{$d.Email}}

+ +{{if $d.Error}} +
{{$d.Error}}
+{{end}} +{{if $d.Success}} +
{{$d.Success}}
+{{end}} + + +
+

{{$d.T.Get "account.sessions.title"}}

+

{{$d.T.Get "account.sessions.subtitle"}}

+ + {{if $d.Sessions}} +
+ {{range $d.Sessions}} +
+
+ {{if or (contains .Device "iPhone") (contains .Device "Android")}}📱 + {{else}}💻{{end}} +
+
+
+ {{if .Device}}{{.Device}}{{else}}Unknown device{{end}} + {{if .IsCurrent}}{{$d.T.Get "account.sessions.this_device"}}{{end}} +
+
+ {{if .IPAddress}}{{.IPAddress}} · {{end}}{{$d.T.Get "account.sessions.signed_in"}} {{.CreatedAt.Format "02 Jan 2006, 15:04"}} +
+
+ {{if not .IsCurrent}} + + {{end}} +
+ {{end}} +
+ {{else}} +

{{$d.T.Get "account.sessions.none"}}

+ {{end}} +
+ + +
+

{{$d.T.Get "account.delete.title"}}

+

{{$d.T.Get "account.delete.subtitle"}}

+ +
+ {{if $d.HasPassword}} + + + {{else}} + + + {{end}} + +
+
+ + + +{{end}} diff --git a/apps/finance/services/api/main/templates/auth_login.html b/apps/finance/services/api/main/templates/auth_login.html index d7cff00..9f738d6 100644 --- a/apps/finance/services/api/main/templates/auth_login.html +++ b/apps/finance/services/api/main/templates/auth_login.html @@ -176,6 +176,9 @@

{{.T.Get "auth.login.heading"}}

{{.T.Get "auth.login.subtext"}} {{.T.Get "auth.login.subtext_link"}}

+ {{if .Success}} +
{{.Success}}
+ {{end}} {{if .Error}}
{{.Error}}
{{end}} diff --git a/apps/finance/services/api/main/templates/base.html b/apps/finance/services/api/main/templates/base.html index d765952..9d07c7b 100644 --- a/apps/finance/services/api/main/templates/base.html +++ b/apps/finance/services/api/main/templates/base.html @@ -659,7 +659,8 @@ - {{.T.Get "nav.business"}} + {{.T.Get "nav.business"}} + {{.T.Get "nav.account"}} {{.Email}}