Fix rendering issues where content from previous screens bled through when switching between views of different heights/widths: - Pad every line to full terminal width (ANSI-aware) so shorter lines overwrite leftover content from previous renders - Track terminal height via WindowSizeMsg and pad between content and footer to fill the screen - Send tea.ClearScreen on all screen transitions for height changes - Fix panic in transfer completion when routing number is < 4 chars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
561 lines
14 KiB
Go
561 lines
14 KiB
Go
package banking
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"git.t-juice.club/torjus/oubliette/internal/shell"
|
|
"git.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)
|
|
}
|
|
}
|