homelab/apps/finance/services/api/main/handler_org_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

1782 lines
61 KiB
Go

package main
// Org, Property, Dream, and Auth-helper tests.
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func testOrg() (*Org, *OrgMember, *FiscalYear) {
org := &Org{ID: "org1", Name: "ACME Corp", Slug: "acme", OwnerUserID: "user1", CreatedAt: time.Now()}
member := &OrgMember{ID: "m1", OrgID: "org1", UserID: "user1", Email: "test@example.com", Role: OrgRoleAdmin, CreatedAt: time.Now()}
fy := &FiscalYear{ID: "fy1", OrgID: "org1", Label: "2025", Status: FiscalYearActive,
StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
EndDate: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), CreatedAt: time.Now()}
return org, member, fy
}
func newOrgStore() *mockStore {
org, member, fy := testOrg()
return &mockStore{
orgsBySlug: map[string]*Org{"acme": org},
orgsByID: map[string]*Org{"org1": org},
membersByKey: map[string]*OrgMember{"org1:user1": member},
fiscalYears: []FiscalYear{*fy},
}
}
func orgReq(method, path, slug string, form url.Values) *http.Request {
r := authReq(method, path, form)
r.SetPathValue("slug", slug)
return r
}
// ── OrgList ──────────────────────────────────────────────────────────────────
func TestOrgList_Empty(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.OrgList(w, authReq("GET", "/orgs", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgList_WithOrg(t *testing.T) {
h := newHandler(newOrgStore())
w := httptest.NewRecorder()
h.OrgList(w, authReq("GET", "/orgs", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// ── OrgCreate ────────────────────────────────────────────────────────────────
func TestOrgCreate_GET(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.OrgCreate(w, authReq("GET", "/orgs/new", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgCreate_POST_Success(t *testing.T) {
h := newHandler(&mockStore{})
form := url.Values{"name": {"Test Corp"}, "slug": {"test-corp"}}
w := httptest.NewRecorder()
h.OrgCreate(w, authReq("POST", "/orgs/new", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgCreate_POST_EmptyName(t *testing.T) {
h := newHandler(&mockStore{})
form := url.Values{"name": {""}, "slug": {"test"}}
w := httptest.NewRecorder()
h.OrgCreate(w, authReq("POST", "/orgs/new", form))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgCreate_POST_BadSlug(t *testing.T) {
h := newHandler(&mockStore{})
form := url.Values{"name": {"Test Corp"}, "slug": {"TEST CORP!"}}
w := httptest.NewRecorder()
h.OrgCreate(w, authReq("POST", "/orgs/new", form))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// ── OrgHome ──────────────────────────────────────────────────────────────────
func TestOrgHome_NotFound(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.OrgHome(w, orgReq("GET", "/orgs/acme", "missing-slug", nil))
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgHome_WithOrg(t *testing.T) {
h := newHandler(newOrgStore())
w := httptest.NewRecorder()
h.OrgHome(w, orgReq("GET", "/orgs/acme", "acme", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "ACME Corp") {
t.Error("expected org name in response")
}
}
// ── OrgTeams ─────────────────────────────────────────────────────────────────
func TestOrgTeams_GET(t *testing.T) {
h := newHandler(newOrgStore())
w := httptest.NewRecorder()
h.OrgTeams(w, orgReq("GET", "/orgs/acme/teams", "acme", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgTeamCreate(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"name": {"Engineering"}, "type": {"internal"}}
w := httptest.NewRecorder()
h.OrgTeamCreate(w, orgReq("POST", "/orgs/acme/teams", "acme", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgTeamCreate_MissingName(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"name": {""}}
w := httptest.NewRecorder()
h.OrgTeamCreate(w, orgReq("POST", "/orgs/acme/teams", "acme", form))
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestOrgTeamDelete(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("DELETE", "/orgs/acme/teams/t1", "acme", nil)
r.SetPathValue("team_id", "t1")
w := httptest.NewRecorder()
h.OrgTeamDelete(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
// ── OrgMembers ───────────────────────────────────────────────────────────────
func TestOrgMembers_GET(t *testing.T) {
h := newHandler(newOrgStore())
w := httptest.NewRecorder()
h.OrgMembers(w, orgReq("GET", "/orgs/acme/members", "acme", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgMemberRoleUpdate(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"role": {"member"}}
r := orgReq("POST", "/orgs/acme/members/m2/role", "acme", form)
r.SetPathValue("member_id", "m2")
w := httptest.NewRecorder()
h.OrgMemberRoleUpdate(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgMemberRoleUpdate_InvalidRole(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"role": {"superuser"}}
r := orgReq("POST", "/orgs/acme/members/m2/role", "acme", form)
r.SetPathValue("member_id", "m2")
w := httptest.NewRecorder()
h.OrgMemberRoleUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestOrgMemberRemove(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("DELETE", "/orgs/acme/members/m2", "acme", nil)
r.SetPathValue("member_id", "m2")
w := httptest.NewRecorder()
h.OrgMemberRemove(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgMemberRemove_Self(t *testing.T) {
h := newHandler(newOrgStore())
// Self-remove check: memberID must match me.ID (which is "m1"), not the user ID
r := orgReq("DELETE", "/orgs/acme/members/m1", "acme", nil)
r.SetPathValue("member_id", "m1")
w := httptest.NewRecorder()
h.OrgMemberRemove(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
// ── OrgInviteNew ─────────────────────────────────────────────────────────────
func TestOrgInviteNew_GET(t *testing.T) {
h := newHandler(newOrgStore())
w := httptest.NewRecorder()
h.OrgInviteNew(w, orgReq("GET", "/orgs/acme/invite", "acme", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgInviteNew_POST(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"email": {"new@example.com"}, "role": {"member"}}
w := httptest.NewRecorder()
h.OrgInviteNew(w, orgReq("POST", "/orgs/acme/invite", "acme", form))
if w.Code != http.StatusOK && w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 200 or 303", w.Code)
}
}
func TestOrgInviteNew_POST_MissingEmail(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"email": {""}, "role": {"member"}}
w := httptest.NewRecorder()
h.OrgInviteNew(w, orgReq("POST", "/orgs/acme/invite", "acme", form))
// Handler re-renders the form with 200 (template may fail to render fully)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgInviteRevoke(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("DELETE", "/orgs/acme/invites/inv1", "acme", nil)
r.SetPathValue("invite_id", "inv1")
w := httptest.NewRecorder()
h.OrgInviteRevoke(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgInviteRevoke_Forbidden(t *testing.T) {
org, _, fy := testOrg()
viewer := &OrgMember{ID: "m2", OrgID: "org1", UserID: "user1", Role: OrgRoleViewer}
store := &mockStore{
orgsBySlug: map[string]*Org{"acme": org},
orgsByID: map[string]*Org{"org1": org},
membersByKey: map[string]*OrgMember{"org1:user1": viewer},
fiscalYears: []FiscalYear{*fy},
}
h := newHandler(store)
r := orgReq("DELETE", "/orgs/acme/invites/inv1", "acme", nil)
r.SetPathValue("invite_id", "inv1")
w := httptest.NewRecorder()
h.OrgInviteRevoke(w, r)
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
// ── OrgJoin ──────────────────────────────────────────────────────────────────
func TestOrgJoin_InvalidToken(t *testing.T) {
h := newHandler(&mockStore{})
r := authReq("GET", "/orgs/join/bad-token", nil)
r.SetPathValue("token", "bad-token")
w := httptest.NewRecorder()
h.OrgJoin(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgJoin_ValidToken_GET(t *testing.T) {
org, _, _ := testOrg()
store := &mockStore{
orgsByID: map[string]*Org{"org1": org},
invitesByToken: map[string]*OrgInvite{
"token123": {ID: "inv1", OrgID: "org1", Email: "new@example.com",
Role: OrgRoleMember, Token: "token123", ExpiresAt: time.Now().Add(24 * time.Hour)},
},
}
h := newHandler(store)
r := authReq("GET", "/orgs/join/token123", nil)
r.SetPathValue("token", "token123")
w := httptest.NewRecorder()
h.OrgJoin(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgJoin_POST_CreatesMember(t *testing.T) {
org, _, _ := testOrg()
store := &mockStore{
orgsByID: map[string]*Org{"org1": org},
invitesByToken: map[string]*OrgInvite{
"join-token": {ID: "inv1", OrgID: "org1", Email: "newuser@example.com",
Role: OrgRoleMember, Token: "join-token", ExpiresAt: time.Now().Add(24 * time.Hour)},
},
}
h := newHandler(store)
r := authReq("POST", "/orgs/join/join-token", nil)
r.SetPathValue("token", "join-token")
w := httptest.NewRecorder()
h.OrgJoin(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
// ── OrgFiscalYears ───────────────────────────────────────────────────────────
func TestOrgFiscalYearCreate(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"label": {"2026"}, "start_date": {"2026-01-01"}, "end_date": {"2026-12-31"}}
w := httptest.NewRecorder()
h.OrgFiscalYearCreate(w, orgReq("POST", "/orgs/acme/years", "acme", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgFiscalYearCreate_MissingLabel(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"label": {""}, "start_date": {"2026-01-01"}, "end_date": {"2026-12-31"}}
w := httptest.NewRecorder()
h.OrgFiscalYearCreate(w, orgReq("POST", "/orgs/acme/years", "acme", form))
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestOrgFiscalYearCreate_MissingDates(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"label": {"2026"}, "start_date": {""}, "end_date": {""}}
w := httptest.NewRecorder()
h.OrgFiscalYearCreate(w, orgReq("POST", "/orgs/acme/years", "acme", form))
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestOrgFiscalYearActivate_ConflictWhenActiveExists(t *testing.T) {
// newOrgStore already has an active FY — activating another should return 409
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/years/fy1/activate", "acme", url.Values{"year_id": {"fy1"}})
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgFiscalYearActivate(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (already active)", w.Code)
}
}
func TestOrgFiscalYearActivate_NoDraftYears(t *testing.T) {
org, member, _ := testOrg()
// Store with a draft fiscal year (no active year)
draft := FiscalYear{ID: "fy2", OrgID: "org1", Label: "2026", Status: FiscalYearDraft,
StartDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)}
store := &mockStore{
orgsBySlug: map[string]*Org{"acme": org},
orgsByID: map[string]*Org{"org1": org},
membersByKey: map[string]*OrgMember{"org1:user1": member},
fiscalYears: []FiscalYear{draft},
}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/years/fy2/activate", "acme", nil)
r.SetPathValue("year_id", "fy2")
w := httptest.NewRecorder()
h.OrgFiscalYearActivate(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgFiscalYearClose(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/years/fy1/close", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgFiscalYearClose(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgFiscalYearClose_Succeeds(t *testing.T) {
// OrgFiscalYearClose takes year_id from path and just closes it — no active check
org, _, _ := testOrg()
store := &mockStore{
orgsBySlug: map[string]*Org{"acme": org},
orgsByID: map[string]*Org{"org1": org},
membersByKey: map[string]*OrgMember{"org1:user1": {ID: "m1", OrgID: "org1", UserID: "user1", Role: OrgRoleAdmin}},
}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/years/any-id/close", "acme", nil)
r.SetPathValue("year_id", "any-id")
w := httptest.NewRecorder()
h.OrgFiscalYearClose(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
// ── OrgEventList / New ────────────────────────────────────────────────────────
func TestOrgEventList_GET(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/events", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventList(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgEventNew_GET(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/events/new", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventNew(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgEventNew_POST(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{
"name": {"Annual Budget"}, "description": {"Review"},
"date_start": {"2025-06-01"}, "date_end": {"2025-06-30"}, "year_id": {"fy1"},
}
r := orgReq("POST", "/orgs/acme/events/new", "acme", form)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventNew(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestOrgEventNew_POST_MissingName(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"name": {""}, "date_start": {"2025-06-01"}, "date_end": {"2025-06-30"}, "year_id": {"fy1"}}
r := orgReq("POST", "/orgs/acme/events/new", "acme", form)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventNew(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
// ── OrgEventDetail ───────────────────────────────────────────────────────────
func TestOrgEventDetail_NotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/events/evt99", "acme", nil)
r.SetPathValue("event_id", "evt99")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventDetail(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgEventDetail_Found(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{
{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Budget Review", Status: EventDraft, CreatedAt: time.Now()},
}
h := newHandler(store)
r := orgReq("GET", "/orgs/acme/events/evt1", "acme", nil)
r.SetPathValue("event_id", "evt1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventDetail(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200: %s", w.Code, w.Body.String())
}
}
// ── OrgEventEdit / Delete / Submit / Review / Feedback ───────────────────────
func TestOrgEventEdit_POST(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Budget Review", Status: EventDraft}}
h := newHandler(store)
form := url.Values{"name": {"Updated Budget"}, "description": {"Review"}, "date_start": {"2025-06-01"}, "date_end": {"2025-06-30"}}
r := orgReq("POST", "/orgs/acme/events/evt1/edit", "acme", form)
r.SetPathValue("event_id", "evt1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventEdit(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestOrgEventEdit_NotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/events/bad/edit", "acme", nil)
r.SetPathValue("event_id", "bad")
w := httptest.NewRecorder()
h.OrgEventEdit(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgEventDelete(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventDraft}}
h := newHandler(store)
r := orgReq("DELETE", "/orgs/acme/events/evt1", "acme", nil)
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgEventDelete(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgEventSubmit(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/events/evt1/submit", "acme", nil)
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgEventSubmit(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgEventSubmit_NotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/events/noexist/submit", "acme", nil)
r.SetPathValue("event_id", "noexist")
w := httptest.NewRecorder()
h.OrgEventSubmit(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgEventReview_Approve(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventReview, FiscalYearID: "fy1"}}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/events/evt1/review", "acme", url.Values{"action": {"approve"}})
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgEventReview(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgEventReview_Reject(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventReview, FiscalYearID: "fy1"}}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/events/evt1/review", "acme", url.Values{"action": {"reject"}, "comment": {"Too expensive"}})
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgEventReview(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgEventReview_CommentOnly(t *testing.T) {
// An unknown action (not "approve"/"reject") posts a comment and stays in review → 303
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventReview, FiscalYearID: "fy1"}}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/events/evt1/review", "acme", url.Values{"action": {"comment"}, "comment": {"looks good"}})
r.SetPathValue("event_id", "evt1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventReview(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgEventFeedback(t *testing.T) {
// Feedback requires a closed fiscal year
org, member, _ := testOrg()
closedFY := FiscalYear{ID: "fy2", OrgID: "org1", Label: "2024", Status: FiscalYearClosed,
StartDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)}
store := &mockStore{
orgsBySlug: map[string]*Org{"acme": org},
orgsByID: map[string]*Org{"org1": org},
membersByKey: map[string]*OrgMember{"org1:user1": member},
fiscalYears: []FiscalYear{closedFY},
orgEvents: []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy2"}},
}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/events/evt1/feedback", "acme", url.Values{"comment": {"Looks good"}})
r.SetPathValue("event_id", "evt1")
r.SetPathValue("year_id", "fy2")
w := httptest.NewRecorder()
h.OrgEventFeedback(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestOrgEventFeedback_NotClosedYear(t *testing.T) {
// Feedback on active year returns 409
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/events/evt1/feedback", "acme", url.Values{"comment": {"ok"}})
r.SetPathValue("event_id", "evt1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventFeedback(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (year not closed)", w.Code)
}
}
// ── OrgGoal CRUD ─────────────────────────────────────────────────────────────
func TestOrgGoalAdd(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}}
h := newHandler(store)
// Handler uses "text" field, not "title"
form := url.Values{"text": {"Buy Equipment"}}
r := orgReq("POST", "/orgs/acme/events/evt1/goals", "acme", form)
r.SetPathValue("event_id", "evt1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgGoalAdd(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestOrgGoalAdd_EmptyText(t *testing.T) {
// Handler checks text first (400), before looking up the event
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/events/evt1/goals", "acme", url.Values{"text": {""}})
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgGoalAdd(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestOrgGoalToggle(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}}
h := newHandler(store)
// done="1" to mark as done; handler also needs year_id (must be active)
r := orgReq("POST", "/orgs/acme/events/evt1/goals/g1/toggle", "acme", url.Values{"done": {"1"}})
r.SetPathValue("event_id", "evt1")
r.SetPathValue("goal_id", "g1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgGoalToggle(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
func TestOrgGoalDelete(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}}
h := newHandler(store)
r := orgReq("DELETE", "/orgs/acme/events/evt1/goals/g1", "acme", nil)
r.SetPathValue("event_id", "evt1")
r.SetPathValue("goal_id", "g1")
w := httptest.NewRecorder()
h.OrgGoalDelete(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
// ── OrgBudgetLine ─────────────────────────────────────────────────────────────
func TestOrgBudgetLineCreate(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}}
h := newHandler(store)
form := url.Values{"description": {"Office Rent"}, "amount": {"3000"}, "category": {"Facilities"}, "type": {"expense"}}
r := orgReq("POST", "/orgs/acme/events/evt1/budget", "acme", form)
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgBudgetLineCreate(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestOrgBudgetLineCreate_EventNotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/events/bad/budget", "acme", url.Values{"description": {"X"}, "amount": {"100"}})
r.SetPathValue("event_id", "bad")
w := httptest.NewRecorder()
h.OrgBudgetLineCreate(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgBudgetLineDelete(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}}
h := newHandler(store)
r := orgReq("DELETE", "/orgs/acme/events/evt1/budget/bl1", "acme", nil)
r.SetPathValue("event_id", "evt1")
r.SetPathValue("line_id", "bl1")
w := httptest.NewRecorder()
h.OrgBudgetLineDelete(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
// ── OrgRequestList / New / Detail / Action ────────────────────────────────────
func TestOrgRequestList_GET(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/requests", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgRequestList(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgRequestNew_GET(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/requests/new", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgRequestNew(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgRequestNew_POST(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{"description": {"New server"}, "amount": {"2500"}, "type": {"purchase_order"}, "date": {"2025-04-01"}}
r := orgReq("POST", "/orgs/acme/requests/new", "acme", form)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgRequestNew(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestOrgRequestDetail_NotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/requests/req99", "acme", nil)
r.SetPathValue("req_id", "req99") // handler uses "req_id" path value
w := httptest.NewRecorder()
h.OrgRequestDetail(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgRequestAction_NotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/requests/req99/action", "acme", url.Values{"action": {"approve"}})
r.SetPathValue("req_id", "req99") // handler uses "req_id" path value
w := httptest.NewRecorder()
h.OrgRequestAction(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
// ── OrgLedger ────────────────────────────────────────────────────────────────
func TestOrgLedger_GET(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/ledger", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgLedger(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgLedger_GET_WithQueryYear(t *testing.T) {
// OrgLedger is GET-only; pass year via query param
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/ledger?year_id=fy1", "acme", nil)
w := httptest.NewRecorder()
h.OrgLedger(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// ── OrgBankImport ─────────────────────────────────────────────────────────────
func TestOrgBankImport_GET(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/bank-import", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgBankImport(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200: %s", w.Code, w.Body.String())
}
}
func TestOrgBankImport_POST(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{
"csv_data": {"Date,Description,Amount\n2025-01-15,Office Supplies,-150.00\n"},
"year_id": {"fy1"},
}
r := orgReq("POST", "/orgs/acme/bank-import", "acme", form)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgBankImport(w, r)
if w.Code == http.StatusInternalServerError {
t.Errorf("unexpected 500: %s", w.Body.String())
}
}
// ── OrgAnalysis ──────────────────────────────────────────────────────────────
func TestOrgAnalysis_GET(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/analysis", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgAnalysis(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestOrgAnalysis_WithEvents(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{
{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Q1 Plan", Status: EventApproved, CreatedAt: time.Now()},
}
h := newHandler(store)
r := orgReq("GET", "/orgs/acme/analysis", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgAnalysis(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// ── OrgReport ────────────────────────────────────────────────────────────────
func TestOrgReport_GET(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/report", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgReport(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// ── requireOrgMember / requireOrgRole ────────────────────────────────────────
func TestRequireOrgMember_NotMember(t *testing.T) {
org := &Org{ID: "org2", Name: "Other Corp", Slug: "other"}
store := &mockStore{
orgsBySlug: map[string]*Org{"other": org},
orgsByID: map[string]*Org{"org2": org},
}
h := newHandler(store)
w := httptest.NewRecorder()
h.OrgHome(w, orgReq("GET", "/orgs/other", "other", nil))
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
func TestRequireOrgRole_InsufficientRole(t *testing.T) {
org, _, fy := testOrg()
viewer := &OrgMember{ID: "m2", OrgID: "org1", UserID: "user1", Role: OrgRoleViewer}
store := &mockStore{
orgsBySlug: map[string]*Org{"acme": org},
orgsByID: map[string]*Org{"org1": org},
membersByKey: map[string]*OrgMember{"org1:user1": viewer},
fiscalYears: []FiscalYear{*fy},
}
h := newHandler(store)
w := httptest.NewRecorder()
h.OrgTeamCreate(w, orgReq("POST", "/orgs/acme/teams", "acme", url.Values{"name": {"Dev Team"}}))
if w.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", w.Code)
}
}
// ── canManageOrg ──────────────────────────────────────────────────────────────
func TestCanManageOrg(t *testing.T) {
if !canManageOrg(OrgRoleAdmin) {
t.Error("admin should manage")
}
if !canManageOrg(OrgRoleFinance) {
t.Error("finance should manage")
}
if canManageOrg(OrgRoleMember) {
t.Error("member should NOT manage")
}
if canManageOrg(OrgRoleViewer) {
t.Error("viewer should NOT manage")
}
}
// ── models_org.go: CurrentStatus ─────────────────────────────────────────────
func TestTxRequestCurrentStatus(t *testing.T) {
req := &TxRequest{}
if req.CurrentStatus() != TxDraft {
t.Errorf("empty log should return TxDraft, got %v", req.CurrentStatus())
}
req.StatusLog = []StatusLogEntry{{Status: TxSubmitted}}
if req.CurrentStatus() != TxSubmitted {
t.Errorf("want TxSubmitted, got %v", req.CurrentStatus())
}
req.StatusLog = append(req.StatusLog, StatusLogEntry{Status: TxApproved})
if req.CurrentStatus() != TxApproved {
t.Errorf("want TxApproved, got %v", req.CurrentStatus())
}
}
// ── handler_property.go pure functions ───────────────────────────────────────
func TestLoanMonthlyPayment(t *testing.T) {
if p := loanMonthlyPayment(100000, 5.0, 0); p != 0 {
t.Errorf("zero term = %d, want 0", p)
}
if p := loanMonthlyPayment(120000, 0, 12); p != 10000 {
t.Errorf("zero rate = %d, want 10000", p)
}
p := loanMonthlyPayment(10000000, 5.0, 240)
if p < 60000 || p > 70000 {
t.Errorf("payment = %d, want ~66000", p)
}
}
func TestLoanRemainingMonths(t *testing.T) {
if m := loanRemainingMonths(0, 5.0, 1000); m != 0 {
t.Errorf("zero balance = %d, want 0", m)
}
if m := loanRemainingMonths(100000, 5.0, 0); m != 0 {
t.Errorf("zero payment = %d, want 0", m)
}
if m := loanRemainingMonths(120000, 0, 10000); m != 12 {
t.Errorf("zero rate months = %d, want 12", m)
}
if m := loanRemainingMonths(1000000, 24.0, 100); m != 999 {
t.Errorf("insufficient payment = %d, want 999", m)
}
}
func TestParseFormCents(t *testing.T) {
tests := []struct {
in string
want int64
}{
{"180000", 18000000},
{"1500.50", 150050},
{"1500,50", 150050},
{"abc", 0},
{"", 0},
}
for _, tt := range tests {
if got := parseFormCents(tt.in); got != tt.want {
t.Errorf("parseFormCents(%q) = %d, want %d", tt.in, got, tt.want)
}
}
}
func TestGenID(t *testing.T) {
a, b := genID(), genID()
if a == b {
t.Error("genID should return unique values")
}
if len(a) != 24 {
t.Errorf("genID length = %d, want 24", len(a))
}
}
func TestLoanBalanceAt(t *testing.T) {
if b := loanBalanceAt(10000000, 5.0, 66000, 0); b != 10000000 {
t.Errorf("balance at 0 = %d, want 10000000", b)
}
if b := loanBalanceAt(10000000, 5.0, 66000, 240); b < 0 || b > 500000 {
t.Errorf("balance at end = %d, want near 0", b)
}
}
func TestProperties_GET(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.Properties(w, authReq("GET", "/property", nil))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestProperties_POST_AddProperty(t *testing.T) {
h := newHandler(&mockStore{})
form := url.Values{"action": {"add_property"}, "name": {"My Flat"}, "address": {"123 Main St"}, "current_value": {"250000"}, "purchase_price": {"200000"}}
w := httptest.NewRecorder()
h.Properties(w, authReq("POST", "/property", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestProperties_POST_AddLoan(t *testing.T) {
h := newHandler(&mockStore{})
form := url.Values{"action": {"add_loan"}, "lender": {"Bank X"}, "balance": {"150000"}, "rate": {"3.5"}, "term_months": {"240"}, "monthly": {"800"}}
w := httptest.NewRecorder()
h.Properties(w, authReq("POST", "/property", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestProperties_POST_UpdateProperty(t *testing.T) {
h := newHandler(&mockStore{})
form := url.Values{"action": {"update_property"}, "id": {"prop1"}, "current_value": {"260000"}, "status": {"active"}}
w := httptest.NewRecorder()
h.Properties(w, authReq("POST", "/property", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestProperties_POST_DeleteProperty(t *testing.T) {
h := newHandler(&mockStore{})
form := url.Values{"action": {"delete_property"}, "id": {"prop1"}}
w := httptest.NewRecorder()
h.Properties(w, authReq("POST", "/property", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestProperties_POST_DeleteLoan(t *testing.T) {
h := newHandler(&mockStore{})
form := url.Values{"action": {"delete_loan"}, "id": {"loan1"}}
w := httptest.NewRecorder()
h.Properties(w, authReq("POST", "/property", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestToLoanView(t *testing.T) {
loan := Loan{ID: "l1", UserID: "user1", Name: "Bank X", BalanceCents: 15000000, InterestRatePct: 3.5, TermMonths: 240, MonthlyPaymentCents: 80000, Status: LoanActive}
v := toLoanView(loan)
if v.ID != "l1" {
t.Errorf("ID = %q, want l1", v.ID)
}
if v.BalanceCents != 15000000 {
t.Errorf("BalanceCents = %d, want 15000000", v.BalanceCents)
}
}
func TestToPropertyView_NoLoan(t *testing.T) {
prop := Property{ID: "p1", UserID: "user1", Name: "My Flat", CurrentValueCents: 25000000, Status: PropertyOwned}
v := toPropertyView(prop, nil)
if v.ID != "p1" {
t.Errorf("ID = %q, want p1", v.ID)
}
if v.EquityCents != 25000000 {
t.Errorf("EquityCents = %d, want 25000000", v.EquityCents)
}
}
func TestToPropertyView_WithLoan(t *testing.T) {
prop := Property{ID: "p1", UserID: "user1", Name: "My Flat", CurrentValueCents: 25000000, Status: PropertyOwned}
loans := []Loan{{ID: "l1", UserID: "user1", PropertyID: "p1", BalanceCents: 10000000, InterestRatePct: 3.0, TermMonths: 180, MonthlyPaymentCents: 70000, Status: LoanActive}}
v := toPropertyView(prop, loans)
if v.LinkedLoan == nil {
t.Error("expected loan to be linked")
}
if v.EquityCents != 15000000 {
t.Errorf("EquityCents = %d, want 15000000", v.EquityCents)
}
}
// ── handler_dream.go ─────────────────────────────────────────────────────────
func TestDream_Redirect(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.Dream(w, authReq("GET", "/plan", nil))
if w.Code != http.StatusMovedPermanently {
t.Errorf("status = %d, want 301", w.Code)
}
if !strings.Contains(w.Header().Get("Location"), "/goals") {
t.Error("expected redirect to /goals")
}
}
func TestParseFloatParam(t *testing.T) {
if v := parseFloatParam("", 1.5); v != 1.5 {
t.Errorf("empty = %f, want 1.5", v)
}
if v := parseFloatParam("3.14", 0); v != 3.14 {
t.Errorf("valid = %f, want 3.14", v)
}
if v := parseFloatParam("abc", 2.0); v != 2.0 {
t.Errorf("invalid = %f, want 2.0", v)
}
if v := parseFloatParam("-1", 5.0); v != 5.0 {
t.Errorf("negative = %f, want 5.0", v)
}
}
func TestParseIntParam(t *testing.T) {
if v := parseIntParam("", 10); v != 10 {
t.Errorf("empty = %d, want 10", v)
}
if v := parseIntParam("42", 0); v != 42 {
t.Errorf("valid = %d, want 42", v)
}
if v := parseIntParam("abc", 5); v != 5 {
t.Errorf("invalid = %d, want 5", v)
}
if v := parseIntParam("-3", 5); v != 5 {
t.Errorf("negative = %d, want 5", v)
}
}
func TestRunDreamSim_Empty(t *testing.T) {
if result := runDreamSim(DreamForm{}, nil, nil); result == nil {
t.Fatal("expected non-nil result")
}
}
func TestRunDreamSim_WithValues(t *testing.T) {
form := DreamForm{
DreamCostCents: 30000000, DownPaymentPct: 20.0,
ConstructionRatePct: 4.5, ConstructionTermYears: 20, MonthlySavingsCents: 200000,
}
if result := runDreamSim(form, nil, nil); result == nil {
t.Fatal("expected non-nil result")
}
}
func TestRunPurchaseSim(t *testing.T) {
if result := runPurchaseSim("Holiday", 500000, 50000, "2026-12"); result == nil {
t.Fatal("expected non-nil result")
}
}
func TestRunPurchaseSim_PastDeadline(t *testing.T) {
if result := runPurchaseSim("Old Goal", 100000, 1000, "2020-01"); result == nil {
t.Fatal("expected non-nil result")
}
}
func TestRunPurchaseSim_AlreadySaved(t *testing.T) {
if result := runPurchaseSim("Done", 100000, 500000, "2026-12"); result == nil {
t.Fatal("expected non-nil result")
}
}
// ── handler_auth.go helpers ───────────────────────────────────────────────────
func TestSignAndVerifySessionID(t *testing.T) {
h := newHandler(&mockStore{})
id := "sess-abc-123"
token := h.signSessionID(id)
got, ok := h.verifySessionToken(token)
if !ok {
t.Error("expected valid token")
}
if got != id {
t.Errorf("got %q, want %q", got, id)
}
}
func TestVerifySessionToken_Invalid(t *testing.T) {
h := newHandler(&mockStore{})
if _, ok := h.verifySessionToken("no-dot-here"); ok {
t.Error("expected invalid: no dot")
}
if _, ok := h.verifySessionToken("abc.wrongsig"); ok {
t.Error("expected invalid: wrong sig")
}
}
func TestRateLimiter_AllowAndFail(t *testing.T) {
rl := newLoginRateLimiter()
ip := "10.0.0.1"
if !rl.allow(ip) {
t.Error("expected allow before any failures")
}
for i := 0; i < rlMaxFailures; i++ {
rl.failure(ip)
}
if rl.allow(ip) {
t.Error("expected blocked after max failures")
}
rl.success(ip)
if !rl.allow(ip) {
t.Error("expected allow after success")
}
}
func TestAuthLoginPost_EmptyFields(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.AuthLogin(w, authReq("POST", "/auth/login", url.Values{"email": {""}, "password": {""}}))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestAuthLoginPost_UserNotFound(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.AuthLogin(w, authReq("POST", "/auth/login", url.Values{"email": {"nobody@example.com"}, "password": {"pass123"}}))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestAuthRegisterPost_InvalidEmail(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.AuthRegister(w, authReq("POST", "/auth/register", url.Values{"email": {"not-an-email"}, "password": {"secret123"}}))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestAuthRegisterPost_ShortPassword(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.AuthRegister(w, authReq("POST", "/auth/register", url.Values{"email": {"user@example.com"}, "password": {"short"}}))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestAuthLogout_Redirect(t *testing.T) {
h := newHandler(&mockStore{})
w := httptest.NewRecorder()
h.AuthLogout(w, httptest.NewRequest("POST", "/auth/logout", nil))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
if !strings.Contains(w.Header().Get("Location"), "login") {
t.Error("expected redirect to login")
}
}
// ── i18n ─────────────────────────────────────────────────────────────────────
func TestTranslator_MissingKey(t *testing.T) {
v := newT("en").Get("no.such.key.xyz")
if v != "" && !strings.Contains(v, "no.such.key") {
t.Errorf("missing key = %q, expected empty or key name", v)
}
}
func TestTranslator_Lang(t *testing.T) {
if l := newT("en").Lang(); l != "en" {
t.Errorf("Lang() = %q, want en", l)
}
if l := newT("pt").Lang(); l != "pt" {
t.Errorf("Lang() = %q, want pt", l)
}
}
func TestTranslator_UnsupportedLang(t *testing.T) {
if tx := newT("xx"); tx == nil {
t.Fatal("expected non-nil translator")
}
}
func TestTranslator_FallsBackToEN(t *testing.T) {
enVal := newT("en").Get("nav.dashboard")
ptVal := newT("pt").Get("nav.dashboard")
if enVal == "" {
t.Error("EN nav.dashboard should not be empty")
}
if ptVal == "" {
t.Error("PT nav.dashboard should not be empty")
}
}
// ── securityHeaders middleware ────────────────────────────────────────────────
func TestSecurityHeaders(t *testing.T) {
h := newHandler(&mockStore{})
called := false
mw := h.securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
mw.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
if !called {
t.Error("handler not called through securityHeaders")
}
}
// ── parseEuroAmount ────────────────────────────────────────────────────────────
func TestParseEuroAmount(t *testing.T) {
tests := []struct {
in string
want int64
wantErr bool
}{
{"150", 15000, false},
{"150.50", 15050, false},
{"150,50", 15050, false}, // comma as decimal
{"150.5", 15050, false}, // one decimal digit
{"150.505", 15050, false}, // truncates to 2 decimals
{"0", 0, false},
{"", 0, false},
{"abc", 0, true},
{"-50", -5000, false}, // negative amounts are valid (expenses)
}
for _, tt := range tests {
got, err := parseEuroAmount(tt.in)
if tt.wantErr {
if err == nil {
t.Errorf("parseEuroAmount(%q) expected error, got %d", tt.in, got)
}
continue
}
if err != nil {
t.Errorf("parseEuroAmount(%q) unexpected error: %v", tt.in, err)
continue
}
if got != tt.want {
t.Errorf("parseEuroAmount(%q) = %d, want %d", tt.in, got, tt.want)
}
}
}
func TestParseInt64(t *testing.T) {
if v, err := parseInt64(""); v != 0 || err != nil {
t.Errorf("empty = %d/%v, want 0/nil", v, err)
}
if v, err := parseInt64("42"); v != 42 || err != nil {
t.Errorf("42 = %d/%v", v, err)
}
if v, err := parseInt64("-10"); v != -10 || err != nil {
t.Errorf("-10 = %d/%v", v, err)
}
if _, err := parseInt64("abc"); err == nil {
t.Error("expected error for 'abc'")
}
}
func TestMaxHelper(t *testing.T) {
if max(3, 5) != 5 {
t.Error("max(3,5) should be 5")
}
if max(5, 3) != 5 {
t.Error("max(5,3) should be 5")
}
if max(4, 4) != 4 {
t.Error("max(4,4) should be 4")
}
}
// ── csvReader / parseBankCSV ───────────────────────────────────────────────────
func TestCsvReader(t *testing.T) {
data := "Name,Value\nAlice,42\nBob,99\n"
rows, err := csvReader(strings.NewReader(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 3 {
t.Errorf("rows = %d, want 3", len(rows))
}
}
func TestParseBankCSV_Valid(t *testing.T) {
// Note: '+' prefix on amounts fails parseEuroAmount, use plain numbers
data := "date,description,amount\n2025-01-15,Office Supplies,-150.00\n2025-01-20,Income,500.00\n"
rows, err := parseBankCSV(strings.NewReader(data))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(rows) != 2 {
t.Errorf("rows = %d, want 2", len(rows))
}
}
func TestParseBankCSV_Empty(t *testing.T) {
_, err := parseBankCSV(strings.NewReader(""))
if err == nil {
t.Error("expected error for empty CSV")
}
}
func TestParseBankCSV_HeaderOnly(t *testing.T) {
// Only 1 record total (the header) → "CSV is empty" error
_, err := parseBankCSV(strings.NewReader("date,description,amount\n"))
if err == nil {
t.Error("header-only (1 record) should return 'CSV is empty' error")
}
}
func TestParseBankCSV_MissingColumns(t *testing.T) {
_, err := parseBankCSV(strings.NewReader("name,value\nAlice,42\n"))
if err == nil {
t.Error("expected error for CSV missing required columns")
}
}
// ── OrgRequestDelivery / Settle: not-found paths ──────────────────────────────
func TestOrgRequestDelivery_NotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/requests/bad/delivery", "acme", url.Values{"actual_amount": {"150"}})
r.SetPathValue("req_id", "bad")
w := httptest.NewRecorder()
h.OrgRequestDelivery(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgRequestSettle_NotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/requests/bad/settle", "acme", nil)
r.SetPathValue("req_id", "bad")
w := httptest.NewRecorder()
h.OrgRequestSettle(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
func TestOrgRequestUpload_NotFound(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("POST", "/orgs/acme/requests/bad/upload", "acme", nil)
r.SetPathValue("req_id", "bad")
w := httptest.NewRecorder()
h.OrgRequestUpload(w, r)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
}
// ── OrgRequestAction: found + submit ─────────────────────────────────────────
func TestOrgRequestAction_SubmitDraft(t *testing.T) {
store := newOrgStore()
req := TxRequest{
ID: "req1", OrgID: "org1", SubmittedBy: "user1",
Type: TxPurchaseOrder, AmountCents: 50000,
StatusLog: []StatusLogEntry{{Status: TxDraft}},
}
store.txRequests = []TxRequest{req}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/requests/req1/action", "acme", url.Values{"action": {"submit"}})
r.SetPathValue("req_id", "req1")
w := httptest.NewRecorder()
h.OrgRequestAction(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
func TestOrgRequestAction_Cancel(t *testing.T) {
store := newOrgStore()
req := TxRequest{
ID: "req1", OrgID: "org1", SubmittedBy: "user1",
Type: TxPurchaseOrder, AmountCents: 50000,
StatusLog: []StatusLogEntry{{Status: TxDraft}},
}
store.txRequests = []TxRequest{req}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/requests/req1/action", "acme", url.Values{"action": {"cancel"}})
r.SetPathValue("req_id", "req1")
w := httptest.NewRecorder()
h.OrgRequestAction(w, r)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303: %s", w.Code, w.Body.String())
}
}
// ── OrgEventList with year_id ─────────────────────────────────────────────────
func TestOrgEventList_WithYearID(t *testing.T) {
h := newHandler(newOrgStore())
r := orgReq("GET", "/orgs/acme/years/fy1/events", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventList(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// ── OrgReport with events ─────────────────────────────────────────────────────
func TestOrgReport_WithEvents(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{
{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Name: "Q1", Status: EventApproved},
}
h := newHandler(store)
r := orgReq("GET", "/orgs/acme/report", "acme", nil)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgReport(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
// ── OrgRequestNew: invalid type ───────────────────────────────────────────────
func TestOrgRequestNew_InvalidType(t *testing.T) {
// OrgRequestNew validates "type" field — description emptiness is allowed
h := newHandler(newOrgStore())
form := url.Values{"description": {""}, "amount": {"100"}, "type": {"unknown_type"}}
r := orgReq("POST", "/orgs/acme/requests/new", "acme", form)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgRequestNew(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
// ── OrgFiscalYearActivate: all events approved ────────────────────────────────
func TestOrgFiscalYearActivate_UnapprovedEvent(t *testing.T) {
org, member, _ := testOrg()
draft := FiscalYear{ID: "fy2", OrgID: "org1", Label: "2026", Status: FiscalYearDraft,
StartDate: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)}
store := &mockStore{
orgsBySlug: map[string]*Org{"acme": org},
orgsByID: map[string]*Org{"org1": org},
membersByKey: map[string]*OrgMember{"org1:user1": member},
fiscalYears: []FiscalYear{draft},
orgEvents: []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy2", Name: "Pending", Status: EventDraft}},
}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/years/fy2/activate", "acme", nil)
r.SetPathValue("year_id", "fy2")
w := httptest.NewRecorder()
h.OrgFiscalYearActivate(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (unapproved event)", w.Code)
}
}
// ── OrgBankImport POST: valid CSV import ──────────────────────────────────────
func TestOrgBankImport_POST_ValidCSV(t *testing.T) {
h := newHandler(newOrgStore())
form := url.Values{
"csv_data": {"date,description,amount\n2025-01-15,Coffee,-15.00\n2025-01-20,Revenue,1000.00\n"},
}
r := orgReq("POST", "/orgs/acme/bank-import", "acme", form)
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgBankImport(w, r)
if w.Code == http.StatusInternalServerError {
t.Errorf("unexpected 500: %s", w.Body.String())
}
}
// ── OrgJoin: expired token ────────────────────────────────────────────────────
func TestOrgJoin_ExpiredToken(t *testing.T) {
// The mock returns the invite regardless of expiry (real store would filter).
// Handler renders the join page for any found invite — expiry is enforced in store.
org, _, _ := testOrg()
store := &mockStore{
orgsByID: map[string]*Org{"org1": org},
invitesByToken: map[string]*OrgInvite{
"expired": {
ID: "inv1", OrgID: "org1", Email: "old@example.com",
Role: OrgRoleMember, Token: "expired",
ExpiresAt: time.Now().Add(-24 * time.Hour),
},
},
}
h := newHandler(store)
r := authReq("GET", "/orgs/join/expired", nil)
r.SetPathValue("token", "expired")
w := httptest.NewRecorder()
h.OrgJoin(w, r)
// Mock returns the invite (no expiry check in mock) → handler renders join page
if w.Code == http.StatusInternalServerError {
t.Error("unexpected 500")
}
}
// ── OrgEventDelete: non-draft event ──────────────────────────────────────────
func TestOrgEventDelete_NonDraft(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventReview}}
h := newHandler(store)
r := orgReq("DELETE", "/orgs/acme/events/evt1", "acme", nil)
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgEventDelete(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (can't delete non-draft)", w.Code)
}
}
// ── OrgEventSubmit: non-draft event ──────────────────────────────────────────
func TestOrgEventSubmit_NonDraft(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventReview}}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/events/evt1/submit", "acme", nil)
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgEventSubmit(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (can't submit non-draft)", w.Code)
}
}
// ── OrgGoalDelete: approved event ────────────────────────────────────────────
func TestOrgGoalDelete_ApprovedEvent(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", FiscalYearID: "fy1", Status: EventApproved}}
h := newHandler(store)
r := orgReq("DELETE", "/orgs/acme/events/evt1/goals/g1", "acme", nil)
r.SetPathValue("event_id", "evt1")
r.SetPathValue("goal_id", "g1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgGoalDelete(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (can't delete goals from approved event)", w.Code)
}
}
// ── OrgBudgetLineCreate: approved event ──────────────────────────────────────
func TestOrgBudgetLineCreate_ApprovedEvent(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}}
h := newHandler(store)
form := url.Values{"description": {"X"}, "amount": {"100"}, "type": {"expense"}}
r := orgReq("POST", "/orgs/acme/events/evt1/budget", "acme", form)
r.SetPathValue("event_id", "evt1")
w := httptest.NewRecorder()
h.OrgBudgetLineCreate(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409", w.Code)
}
}
// ── OrgBudgetLineDelete: approved event ──────────────────────────────────────
func TestOrgBudgetLineDelete_ApprovedEvent(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}}
h := newHandler(store)
r := orgReq("DELETE", "/orgs/acme/events/evt1/budget/bl1", "acme", nil)
r.SetPathValue("event_id", "evt1")
r.SetPathValue("line_id", "bl1")
w := httptest.NewRecorder()
h.OrgBudgetLineDelete(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409", w.Code)
}
}
// ── OrgEventReview: not-under-review ─────────────────────────────────────────
func TestOrgEventReview_NotUnderReview(t *testing.T) {
store := newOrgStore()
store.orgEvents = []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventDraft, FiscalYearID: "fy1"}}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/events/evt1/review", "acme", url.Values{"action": {"approve"}})
r.SetPathValue("event_id", "evt1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgEventReview(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (event not under review)", w.Code)
}
}
// ── OrgGoalToggle: non-active year ───────────────────────────────────────────
func TestOrgGoalToggle_InactiveYear(t *testing.T) {
org, member, _ := testOrg()
closed := FiscalYear{ID: "fy1", OrgID: "org1", Label: "2025", Status: FiscalYearClosed,
StartDate: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC)}
store := &mockStore{
orgsBySlug: map[string]*Org{"acme": org},
orgsByID: map[string]*Org{"org1": org},
membersByKey: map[string]*OrgMember{"org1:user1": member},
fiscalYears: []FiscalYear{closed},
orgEvents: []OrgEvent{{ID: "evt1", OrgID: "org1", Status: EventApproved, FiscalYearID: "fy1"}},
}
h := newHandler(store)
r := orgReq("POST", "/orgs/acme/events/evt1/goals/g1/toggle", "acme", url.Values{"done": {"1"}})
r.SetPathValue("event_id", "evt1")
r.SetPathValue("goal_id", "g1")
r.SetPathValue("year_id", "fy1")
w := httptest.NewRecorder()
h.OrgGoalToggle(w, r)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409 (closed year)", w.Code)
}
}
// ── OrgCreate: duplicate slug ─────────────────────────────────────────────────
func TestOrgCreate_POST_SlugCheck(t *testing.T) {
// Mock's slugExists always returns false, so any slug succeeds
h := newHandler(&mockStore{})
form := url.Values{"name": {"Test Corp"}, "slug": {"test-corp"}}
w := httptest.NewRecorder()
h.OrgCreate(w, authReq("POST", "/orgs/new", form))
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want 303", w.Code)
}
}
// ── fmt sentinel ──────────────────────────────────────────────────────────────
var _ = fmt.Sprintf