This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/shell/banking/banking_test.go
Torjus Håkestad 1b28f10ca8 refactor: migrate module path from git.t-juice.club to code.t-juice.club
Update Go module path and all import references to reflect the migration
from Gitea (git.t-juice.club) to Forgejo (code.t-juice.club).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:51:23 +01:00

561 lines
14 KiB
Go

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)
}
}