homelab/apps/finance/services/api/main/handler_coverage_test.go
Gonçalo Rodrigues 91796c9fb9 test(finance): expand unit test coverage from ~55% to 64.7% (#34)
* infra(terraform): manage finance session secret via random_password

Replace the hand-rolled variable (with insecure hardcoded default) with a
random_password resource so Terraform auto-generates a 48-char secret and
owns the finance-api-secrets k8s Secret lifecycle.

To rotate: terraform taint random_password.finance_session_secret && terraform apply

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(finance): active sessions panel + account deletion with full data purge

Sessions panel (/account):
- AuthSession now stores IPAddress and Device (browser + OS hint)
  populated from X-Forwarded-For / User-Agent on every login
- Lists all active sessions with device icon, IP, sign-in time
- Current session badge ("This device") — cannot be self-revoked
- DELETE /sessions/:id revokes any other session (user-scoped)

Account deletion (POST /account/delete):
- Password accounts require password confirmation
- OAuth accounts require typing email address to confirm
- deleteAllUserData purges all 12 finance collections + user record
  in a single call: accounts, categories, transactions, trades,
  ticker_mappings, goals, import_schedules, properties, loans,
  permissions, households, sessions → then the user itself
- Clears session cookie and redirects to login with success message

Infrastructure:
- findAuthUserByID added to store + storeIface
- getSessionsByUserID, deleteSessionForUser added to store + storeIface
- contains() added to template FuncMap
- accountTmpl registered; GET /account, POST /account/delete,
  DELETE /sessions/:id routes wired
- 🔐 nav icon links to /account page
- Full EN + PT i18n coverage for all new strings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(finance): expand unit test coverage from ~55% to 64.7%

- Add handler_coverage_test.go (~3300 lines) covering auth flows,
  org request lifecycle, CSV bank import, property/loan views,
  fiscal year operations, session management, and cross-handler
  consistency (values shown on one page match actions on others)
- Add handler_org_test.go (~1800 lines) covering the full org
  handler surface: teams, members, invites, events, budget lines,
  tx requests (all status transitions), ledger, analysis, and reports
- Extend handler_test.go mockStore with: properties/loans slice fields,
  authUsers map with session-aware lookup, household field, org maps,
  and updateFiscalYearStatusErr for error-path testing
- Fix nav bar: Business and Account links now show active state and
  use i18n keys (removes hardcoded emoji); add account key to en/pt locales

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 15:07:29 +01:00

3328 lines
119 KiB
Go

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