package banking import ( "context" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "code.t-juice.club/torjus/oubliette/internal/shell" "code.t-juice.club/torjus/oubliette/internal/storage" ) // newTestModel creates a model with a test session context. func newTestModel(t *testing.T) (*model, *storage.MemoryStore) { t.Helper() store := storage.NewMemoryStore() sessID, _ := store.CreateSession(context.Background(), "127.0.0.1", "banker", "banking", "") sess := &shell.SessionContext{ SessionID: sessID, Username: "banker", Store: store, } m := newModel(sess, "SECUREBANK", "SB-0001", "NORTHEAST") return m, store } // sendKeys sends a string of characters as individual key messages to the model. func sendKeys(m *model, s string) { for _, ch := range s { var msg tea.KeyMsg switch ch { case '\r': msg = tea.KeyMsg{Type: tea.KeyEnter} case '\x1b': msg = tea.KeyMsg{Type: tea.KeyEscape} case '\x03': msg = tea.KeyMsg{Type: tea.KeyCtrlC} default: msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{ch}} } m.Update(msg) } } func TestBankingShellName(t *testing.T) { sh := NewBankingShell() if sh.Name() != "banking" { t.Errorf("Name() = %q, want %q", sh.Name(), "banking") } if sh.Description() == "" { t.Error("Description() should not be empty") } } func TestFormatCurrency(t *testing.T) { tests := []struct { cents int64 want string }{ {0, "$0.00"}, {100, "$1.00"}, {4738291, "$47,382.91"}, {18254100, "$182,541.00"}, {52387450, "$523,874.50"}, {25000000, "$250,000.00"}, {-125000, "-$1,250.00"}, {99, "$0.99"}, } for _, tt := range tests { got := formatCurrency(tt.cents) if got != tt.want { t.Errorf("formatCurrency(%d) = %q, want %q", tt.cents, got, tt.want) } } } func TestNewBankState(t *testing.T) { state := newBankState() if len(state.Accounts) != 4 { t.Errorf("expected 4 accounts, got %d", len(state.Accounts)) } for _, acct := range state.Accounts { txns, ok := state.Transactions[acct.Number] if !ok { t.Errorf("no transactions for account %s", acct.Number) continue } if len(txns) == 0 { t.Errorf("account %s has no transactions", acct.Number) } } if len(state.Messages) != 4 { t.Errorf("expected 4 messages, got %d", len(state.Messages)) } } func TestLoginScreenRenders(t *testing.T) { m, _ := newTestModel(t) view := m.View() if !strings.Contains(view, "SECUREBANK") { t.Error("login should show bank name") } if !strings.Contains(view, "AUTHORIZED ACCESS ONLY") { t.Error("login should show authorization warning") } if !strings.Contains(view, "ACCOUNT NUMBER") { t.Error("login should prompt for account number") } } func TestLoginFlow(t *testing.T) { m, _ := newTestModel(t) // Type account number. sendKeys(m, "12345678") view := m.View() if !strings.Contains(view, "12345678") { t.Error("should show typed account number") } // Press enter. sendKeys(m, "\r") view = m.View() if !strings.Contains(view, "PIN") { t.Error("should show PIN prompt after entering account number") } // Type PIN and enter. sendKeys(m, "1234\r") // Should be on menu now. if m.screen != screenMenu { t.Errorf("expected screenMenu, got %d", m.screen) } } func TestMainMenuRenders(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") view := m.View() if !strings.Contains(view, "MAIN MENU") { t.Error("should show MAIN MENU after login") } if !strings.Contains(view, "WIRE TRANSFER") { t.Error("menu should contain WIRE TRANSFER option") } if !strings.Contains(view, "SECURE MESSAGES") { t.Error("menu should contain SECURE MESSAGES option") } if !strings.Contains(view, "ACCOUNT SUMMARY") { t.Error("menu should contain ACCOUNT SUMMARY option") } } func TestAccountSummary(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "1\r") // account summary if m.screen != screenAccountSummary { t.Fatalf("expected screenAccountSummary, got %d", m.screen) } view := m.View() if !strings.Contains(view, "ACCOUNT SUMMARY") { t.Error("should show ACCOUNT SUMMARY") } if !strings.Contains(view, "CHECKING") { t.Error("should show CHECKING account") } if !strings.Contains(view, "SAVINGS") { t.Error("should show SAVINGS account") } if !strings.Contains(view, "TOTAL") { t.Error("should show TOTAL") } // Press any key to return. sendKeys(m, " ") if m.screen != screenMenu { t.Errorf("should return to menu, got screen %d", m.screen) } } func TestWireTransferFlow(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "3\r") // wire transfer if m.screen != screenTransfer { t.Fatalf("expected screenTransfer, got %d", m.screen) } view := m.View() if !strings.Contains(view, "WIRE TRANSFER") { t.Error("should show WIRE TRANSFER header") } // Fill all fields. sendKeys(m, "021000021\r") // routing sendKeys(m, "9876543210\r") // dest account sendKeys(m, "JOHN DOE\r") // beneficiary sendKeys(m, "FIRST NATIONAL BANK\r") // bank name sendKeys(m, "50000\r") // amount sendKeys(m, "INVOICE 12345\r") // memo // Should be on confirm step. view = m.View() if !strings.Contains(view, "TRANSFER SUMMARY") { t.Error("should show TRANSFER SUMMARY for confirmation") } if !strings.Contains(view, "021000021") { t.Error("summary should show routing number") } // Confirm. sendKeys(m, "Y\r") // Auth code. sendKeys(m, "AUTH99\r") view = m.View() if !strings.Contains(view, "TRANSFER QUEUED") { t.Error("should show TRANSFER QUEUED confirmation") } // Check wire transfer was stored. if len(m.state.Transfers) != 1 { t.Fatalf("expected 1 transfer, got %d", len(m.state.Transfers)) } wt := m.state.Transfers[0] if wt.RoutingNumber != "021000021" { t.Errorf("routing = %q, want %q", wt.RoutingNumber, "021000021") } if wt.DestAccount != "9876543210" { t.Errorf("dest = %q, want %q", wt.DestAccount, "9876543210") } if wt.Beneficiary != "JOHN DOE" { t.Errorf("beneficiary = %q, want %q", wt.Beneficiary, "JOHN DOE") } // Press key to return to menu. sendKeys(m, " ") if m.screen != screenMenu { t.Errorf("should return to menu after transfer, got screen %d", m.screen) } } func TestWireTransferCancel(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "3\r") // wire transfer sendKeys(m, "021000021\r") sendKeys(m, "9876543210\r") sendKeys(m, "JOHN DOE\r") sendKeys(m, "FIRST NATIONAL BANK\r") sendKeys(m, "50000\r") sendKeys(m, "INVOICE 12345\r") // Cancel at confirm step. sendKeys(m, "N\r") if m.screen != screenMenu { t.Errorf("should return to menu after cancel, got screen %d", m.screen) } } func TestTransactionHistory(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "4\r") // transaction history if m.screen != screenHistory { t.Fatalf("expected screenHistory, got %d", m.screen) } view := m.View() if !strings.Contains(view, "TRANSACTION HISTORY") { t.Error("should show TRANSACTION HISTORY header") } // Select first account. sendKeys(m, "1\r") view = m.View() if !strings.Contains(view, "DATE") { t.Error("should show transaction list with DATE column") } if !strings.Contains(view, "PAGE") { t.Error("should show page indicator") } // Press B to go back to account list. sendKeys(m, "B") view = m.View() if !strings.Contains(view, "SELECT ACCOUNT") { t.Error("should return to account selection") } // Press 0 to return to menu. sendKeys(m, "0\r") if m.screen != screenMenu { t.Errorf("should return to menu, got screen %d", m.screen) } } func TestSecureMessages(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "5\r") // secure messages if m.screen != screenMessages { t.Fatalf("expected screenMessages, got %d", m.screen) } view := m.View() if !strings.Contains(view, "SECURE MESSAGES") { t.Error("should show SECURE MESSAGES header") } if !strings.Contains(view, "SCHEDULED MAINTENANCE") { t.Error("should show first message subject") } // View first message. sendKeys(m, "1\r") view = m.View() if !strings.Contains(view, "10.48.2.100") { t.Error("message body should contain breadcrumb IP") } // Press key to return to list. sendKeys(m, " ") // Return to menu. sendKeys(m, "0\r") if m.screen != screenMenu { t.Errorf("should return to menu, got screen %d", m.screen) } } func TestAdminAccessDenied(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "99\r") // admin if m.screen != screenAdmin { t.Fatalf("expected screenAdmin, got %d", m.screen) } view := m.View() if !strings.Contains(view, "SYSTEM ADMINISTRATION") { t.Error("should show SYSTEM ADMINISTRATION header") } // Three failed PIN attempts. sendKeys(m, "secret1\r") view = m.View() if !strings.Contains(view, "INVALID CREDENTIALS") { t.Error("should show INVALID CREDENTIALS after first attempt") } sendKeys(m, "secret2\r") sendKeys(m, "secret3\r") view = m.View() if !strings.Contains(view, "ACCESS DENIED") { t.Error("should show ACCESS DENIED after 3 attempts") } if !strings.Contains(view, "ABEND S0C4") { t.Error("should show COBOL-style error") } // Press key to return to menu. sendKeys(m, " ") if m.screen != screenMenu { t.Errorf("should return to menu after lockout, got screen %d", m.screen) } } func TestAdminEscapeReturns(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "99\r") // admin sendKeys(m, "\x1b") // ESC to return if m.screen != screenMenu { t.Errorf("ESC should return to menu from admin, got screen %d", m.screen) } } func TestChangePin(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "6\r") // change PIN if m.screen != screenChangePin { t.Fatalf("expected screenChangePin, got %d", m.screen) } view := m.View() if !strings.Contains(view, "CHANGE PIN") { t.Error("should show CHANGE PIN header") } // Old PIN. sendKeys(m, "1234\r") // New PIN. sendKeys(m, "5678\r") // Confirm PIN. sendKeys(m, "5678\r") view = m.View() if !strings.Contains(view, "PIN CHANGED SUCCESSFULLY") { t.Error("should show PIN CHANGED SUCCESSFULLY") } // Press key to return. sendKeys(m, " ") if m.screen != screenMenu { t.Errorf("should return to menu after PIN change, got screen %d", m.screen) } } func TestLogoutExits(t *testing.T) { m, _ := newTestModel(t) sendKeys(m, "12345678\r1234\r") // login sendKeys(m, "7\r") // logout if !m.quitting { t.Error("should be quitting after logout") } } func TestSessionLogs(t *testing.T) { m, store := newTestModel(t) // Send login keys and manually run returned commands. // Type account number. sendKeys(m, "12345678") // Enter to advance to PIN. sendKeys(m, "\r") // Type PIN. sendKeys(m, "1234") // Enter to login — this returns a logAction cmd. _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) if cmd != nil { // Execute the batch of commands (login log). execCmds(cmd) } // Give async store writes a moment. time.Sleep(50 * time.Millisecond) if len(store.SessionLogs) == 0 { t.Error("expected session logs to be recorded after login") } found := false for _, log := range store.SessionLogs { if strings.Contains(log.Input, "LOGIN") { found = true break } } if !found { t.Error("expected a LOGIN entry in session logs") } // Navigate to account summary — also returns a logAction cmd. sendKeys(m, "1") _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) if cmd != nil { execCmds(cmd) } time.Sleep(50 * time.Millisecond) foundMenu := false for _, log := range store.SessionLogs { if strings.Contains(log.Input, "MENU") { foundMenu = true break } } if !foundMenu { t.Error("expected a MENU entry in session logs for account summary") } } // execCmds recursively executes tea.Cmd functions (including batches). func execCmds(cmd tea.Cmd) { if cmd == nil { return } msg := cmd() // tea.BatchMsg is a slice of Cmds returned by tea.Batch. if batch, ok := msg.(tea.BatchMsg); ok { for _, c := range batch { execCmds(c) } } } func TestConfigString(t *testing.T) { cfg := map[string]any{ "bank_name": "TESTBANK", "region": "SOUTHWEST", } if got := configString(cfg, "bank_name", "DEFAULT"); got != "TESTBANK" { t.Errorf("configString() = %q, want %q", got, "TESTBANK") } if got := configString(cfg, "missing", "DEFAULT"); got != "DEFAULT" { t.Errorf("configString() = %q, want %q", got, "DEFAULT") } if got := configString(nil, "bank_name", "DEFAULT"); got != "DEFAULT" { t.Errorf("configString(nil) = %q, want %q", got, "DEFAULT") } } func TestScreenFrame(t *testing.T) { frame := screenFrame("TESTBANK", "TB-0001", "NORTHEAST", "content here", 0) if !strings.Contains(frame, "TESTBANK FEDERAL RESERVE SYSTEM") { t.Error("frame should contain bank name in header") } if !strings.Contains(frame, "TB-0001") { t.Error("frame should contain terminal ID in footer") } if !strings.Contains(frame, "content here") { t.Error("frame should contain the content") } } func TestScreenFramePadsLines(t *testing.T) { frame := screenFrame("TESTBANK", "TB-0001", "NE", "short\n", 0) for i, line := range strings.Split(frame, "\n") { w := lipgloss.Width(line) if w > 0 && w < termWidth { t.Errorf("line %d has visual width %d, want at least %d: %q", i, w, termWidth, line) } } } func TestScreenFramePadsToHeight(t *testing.T) { short := screenFrame("TESTBANK", "TB-0001", "NE", "line1\nline2\n", 30) lines := strings.Count(short, "\n") // Total newlines should be at least height-1 (since the last line has no trailing newline). if lines < 29 { t.Errorf("padded frame has %d newlines, want at least 29 for height=30", lines) } // Without height, no padding. noPad := screenFrame("TESTBANK", "TB-0001", "NE", "line1\nline2\n", 0) noPadLines := strings.Count(noPad, "\n") if noPadLines >= 29 { t.Errorf("unpadded frame has %d newlines, should be much less than 29", noPadLines) } }