class Account: def __init__(self, db: str = "Data/atmdb.db") -> Account: self.accountId = None self.balance = None self.isAuthorized = False self.sql = SQLHelper(db) # updates account details def addAccountDetails(self, accountId: str, balance: int, isAuthorized: bool = True) -> None: self.accountId = accountId self.balance = balance self.isAuthorized = isAuthorized # update account balance in db and in memory # if withdrawal, change amount to negative # amount is added to current amount (doesn't just set the amount) # also update account history in db def updateAccountBalance(self, amount: int) -> None: newBalance = self.balance + amount self.sql.updateBalanceCmd(self.accountId, amount, newBalance) self.balance = newBalance # Clears all account details # This method is mainly to be used for inactive logout # As we can't create a new sqllite object in a different thread def clearAccountDetails(self) -> None: self.accountId = None self.balance = None self.isAuthorized = False self.sql = None
def __init__(self, inactiveTime: int = 120, db: str = "Data/atmdb.db") -> ATM: # commands that require and account to be authorized before running self.preauthCmds = ["withdraw", "deposit", "balance", "history"] self.sql = SQLHelper(db) self.atmBalance = self.sql.getATMBalanceCmd() self.inactiveTime = inactiveTime # timer that after inactiveTime runs inactiveLogout method self.timer = Timer(self.inactiveTime, self.inactiveLogout) self.activeTimer = False self.db = db self.account = Account(self.db)
def authorize(self, accountId: str, pin: str) -> ControllerResponse: response = ControllerResponse() # check if an account is already authorized, if one is then return if self.account.isAuthorized: message = "An account is already authorized. Logout before authorizing another account." response.addResponseMsg(message) return response # validate that account sql object is valid # it's possible that it could have been cleared # due to inactivity if self.account.sql is None: self.account.sql = SQLHelper(self.db) # search for account data actData = self.sql.accountSelectCmd(accountId) # if no account exists with the provided account id then return # failed authorization if not actData: message = "Authorization failed." response.addResponseMsg(message) return response # get pin number actPin = actData[2] # if the pin on file matches the provided pin then authorize account if actPin == pin: balance = actData[3] self.account.addAccountDetails(accountId, balance, True) message = "{} successfully authorized.".format(accountId) # otherwise fail authorization else: message = "Authorization failed." response.addResponseMsg(message) return response
class ATMTests(unittest.TestCase): @classmethod def setUp(self): # create atm object with 3 second logout timer and point towards test db # this is mostly to be used with tests that don't require auth # create sql object that points towards test db self.atm = ATM(3, "Data/atmdb_test.db") self.sql = SQLHelper("Data/atmdb_test.db") # authorizes the first available account def AuthorizeAct(self, inactive: int = 3) -> ATM: atm = ATM(inactive, "Data/atmdb_test.db") data = self.sql.getSingleAccountCmd() accountId = data[0] pin = data[1] atm.controller("authorize {} {}".format(accountId, pin)) return atm # tests authorizing a valid account def test_AuthorizeSuccess(self) -> None: print("Testing authorization of a valid account id and pin") # get first account id and pin in db data = self.sql.getSingleAccountCmd() accountId = data[0] pin = data[1] response = self.atm.controller("authorize {} {}".format( accountId, pin)) # assert response is correct self.assertEqual("{} successfully authorized.".format(accountId), response.message) # assert account is authorized self.assertTrue(self.atm.account.isAuthorized) self.atm.controller self.atm.controller("end") # tests authorizing an invalid account id def test_AuthorizeBadAccountId(self) -> None: print("Testing authorization of an invalid account id") response = self.atm.controller("authorize 55555 1234") # assert response is correct self.assertEqual("Authorization failed.", response.message) # assert account is not authorized self.assertFalse(self.atm.account.isAuthorized) self.atm.controller("end") # tests authorizing a valid account with an invalid pin def test_AuthorizationBadPin(self) -> None: print( "Testing authorization of a valid account id with an invalid pin") data = self.sql.getSingleAccountCmd() accountId = data[0] response = self.atm.controller("authorize {} 0000".format(accountId)) # assert response is correct self.assertEqual("Authorization failed.", response.message) # assert account is not authorized self.assertFalse(self.atm.account.isAuthorized) self.atm.controller("end") # tests running through all commands, except logout & end # that require authorizatio with no authorization def test_CommandsWithNoAuthorization(self) -> None: print( "Testing running all commands (except logout & end) that require authorization with no authorization" ) responseLst = list() # run through commads and append response to list responseLst.append(self.atm.controller("balance").message) responseLst.append(self.atm.controller("history").message) responseLst.append(self.atm.controller("withdraw 20").message) responseLst.append(self.atm.controller("deposit 20").message) # validate that all responses were correct for response in responseLst: self.assertTrue(response == "Authorization required.") self.atm.controller("end") # Tests a valid withdrawal # Validates balances are correct at the end # and that response is correct def test_ValidWithdrawal(self) -> None: print("Testing a valid withdrawal of $20") atm = self.AuthorizeAct() # get current balances atmBal = atm.atmBalance actBal = atm.account.balance # validate atm has enough money, add 100 if it doesn't if atmBal < 20: atm.updateATMBalance(100) atmBal = 100 # validate account has enough money # add difference between 20 and account val + 1 if actBal < 20: diff = 20 - atm.account.balance atm.account.updateAccountBalance(diff + 1) actBal += diff + 1 # run withdraw command response = atm.controller("withdraw 20") # assert balances have been updated self.assertEqual(atmBal - 20, atm.atmBalance) self.assertEqual(actBal - 20, atm.account.balance) respMsg = "Amount dispensed: $20\nCurrent balance: {}".format( round(atm.account.balance, 2)) # validate response is correct self.assertEqual(response.message, respMsg) atm.controller("end") # tests a withdrawal that results in an overdraft fee # Validates balances are correct and response message is correct def test_OverdraftWithdrawal(self) -> None: print("Testing an overdraft withdrawal") atm = self.AuthorizeAct() actBal = atm.account.balance atmBal = atm.atmBalance # if account balance is less than 15 update if actBal < 15: diff = 15 - actBal atm.account.updateAccountBalance(diff) actBal += diff # if balance is greater than or equal to 20 update elif actBal >= 20: diff = actBal - 19 atm.account.updateAccountBalance(diff * -1) actBal -= diff # if atm bal if under 20 update if atmBal < 20: atm.updateATMBalance(100) atmBal = 100 response = atm.controller("withdraw 20") # assert balances have been updated and that overdraft fee was taken out of account self.assertEqual(atmBal - 20, atm.atmBalance) self.assertEqual(actBal - 25, atm.account.balance) message = ( "Amount dispensed: $20\nYou have been charged an overdraft fee of " "$5. Current balance: {}").format(round(atm.account.balance, 2)) # validate response is correct self.assertEqual(response.message, message) atm.controller("end") # Tests scenario where atm doesn't have enough to complete full withdrawal # Validates balances are correct and response message is correct def test_InvalidAtmAmtWithdrawl(self) -> None: print( "Testing a scenario where the atm doesn't have enough to dispense full amount" ) atm = self.AuthorizeAct() atmBal = atm.atmBalance actBal = atm.account.balance # adjust atm balance if necessary if atmBal != 15: atm.updateATMBalance(15) atmBal = 15 # adjust act bal if necessary if actBal < 20: diff = 20 - actBal atm.account.updateAccountBalance(diff) actBal += diff response = atm.controller("withdraw 20") self.assertEqual(0, atm.atmBalance) self.assertEqual(actBal - 15, atm.account.balance) message = "Unable to dispense full amount requested at this time. Amount dispensed: $15\nCurrent balance: {}".format( round(atm.account.balance, 2)) self.assertEqual(message, response.message) atm.controller("end") # tests scenario where atm has no money to dispense # validates atm balance and response def test_AtmEmptyWithdrawal(self) -> None: print("Testing scenario where atm has no money to dispense") atm = self.AuthorizeAct() atmBal = atm.atmBalance actBal = atm.account.balance # adjust atm balance if necessary if atmBal != 0: atm.updateATMBalance(0) atmBal = 0 if actBal < 0: atm.account.updateAccountBalance(abs(actBal) + 1) actBal = abs(actBal) + 1 response = atm.controller("withdraw 20") self.assertEqual(0, atm.atmBalance) message = "Unable to process your withdrawal at this time." self.assertEqual(response.message, message) atm.controller("end") # tests scenario where account is overdrawn def test_AccountOverdrawnWithdrawal(self) -> None: print("Testing scenario where account is overdrawn") atm = self.AuthorizeAct() actBal = atm.account.balance if actBal >= 0: diff = -1 - actBal atm.account.updateAccountBalance(diff) actBal += diff response = atm.controller("withdraw 20") self.assertEqual(actBal, atm.account.balance) message = "Your account is overdrawn! You may not make withdrawals at this time." self.assertEqual(message, response.message) atm.controller("end") # tests scenario where negative withdrawal amount given def test_NegativeWitdrawal(self) -> None: print("Testing scenarion where negative withdrawal amount is given") atm = self.AuthorizeAct() response = atm.controller("withdraw -20") message = "Withdrawal amount must be greater than 0 and in increments of 20." self.assertEqual(response.message, message) atm.controller("end") # tests scenario where an non multiple of 20 withdrawal amount given def test_NotMultOf20Witdrawal(self) -> None: print( "Testing scenarion where an amount that isn't a multiple of 20 is provided for withdrawal" ) atm = self.AuthorizeAct() response = atm.controller("withdraw 15") message = "Withdrawal amount must be greater than 0 and in increments of 20." self.assertEqual(response.message, message) atm.controller("end") # tests a valid deposit of $20 def test_ValidDeposit(self) -> None: print("Testing valid deposit of $20") atm = self.AuthorizeAct() atmBal = atm.atmBalance actBal = atm.account.balance response = atm.controller("deposit 20") self.assertEqual(atmBal + 20, atm.atmBalance) self.assertEqual(actBal + 20, atm.account.balance) message = "Current balance: {}".format(atm.account.balance) self.assertEqual(message, response.message) atm.controller("end") # tests an invalid deposit of 0 def test_ZeroDeposit(self) -> None: print("Testing scenario where $0 is deposited") atm = self.AuthorizeAct() response = atm.controller("deposit 0") message = "Deposit amount must be greater than 0." self.assertEqual(response.message, message) atm.controller("end") # tests an invalid deposit of -5 def test_NegativeDeposit(self) -> None: print("Testing scenario where $-5 is deposited") atm = self.AuthorizeAct() response = atm.controller("deposit -5") message = "Deposit amount must be greater than 0." self.assertEqual(response.message, message) atm.controller("end") # tests balance command def test_Balance(self) -> None: print("Testing balance command") atm = self.AuthorizeAct() response = atm.controller("balance") message = "Current balance: {}".format(atm.account.balance) self.assertEqual(message, response.message) atm.controller("end") # tests balance by depositing and then checking history is not null def test_History(self) -> None: print("Testing history command") atm = self.AuthorizeAct() atm.controller("deposit 20") response = atm.controller("history") self.assertIsNotNone(response.message) atm.controller("end") # tests scenario where account has no history # validates response is correct def test_NoHistory(self) -> None: print("Testing scenario where there is no account history") atm = self.AuthorizeAct() self.sql.clearAccountHistoryCmd(atm.account.accountId) response = atm.controller("history") self.assertEqual(response.message, "No history found") atm.controller("end") # tests logging out of valid account # validate authorized flag is false # and response message is correct def test_ValidLogout(self) -> None: print("Testing normal logout") atm = self.AuthorizeAct() accountId = atm.account.accountId response = atm.controller("logout") message = "Account {} logged out.".format(accountId) self.assertFalse(atm.account.isAuthorized) self.assertEqual(message, response.message) atm.controller("end") # tests logging out of account when one isn't logged in # validate authorized flag is false # and response message is correct def test_InvalidLogout(self) -> None: print("Testing logging out when no account is logged in") atm = ATM() response = atm.controller("logout") message = "No account is currently authorized." self.assertEqual(message, response.message) self.assertFalse(atm.account.isAuthorized) atm.controller("end") # tests that user is logged out after 4 seconds # when inactive logout is set to 3 seconds def test_InactiveLogout(self) -> None: print("Testing scenario where user is inactive and is logged out") atm = self.AuthorizeAct() sleep = Event() sleep.wait(4) self.assertFalse(atm.account.isAuthorized) # tests inputs that should trigger error when given while user logged in # asserts error flag is raised and def test_BadInputLoggedIn(self) -> None: print( "Testing a few scenarios where user is logged in and gives bad input" ) atm = self.AuthorizeAct() badInput = [ "deposit 1 2 3 4", "withdraw 5634 095376", "26235742", "2438g346" ] for bad in badInput: response = atm.controller(bad) self.assertEqual("Invalid command detected.", response.message) self.assertTrue(response.error) atm.controller("end") # tests inputs that should trigger error when given while user not logged in # asserts error flag is raised and def test_BadInputNotLoggedIn(self) -> None: print( "Testing a few scenarios where user is not logged in and gives bad input" ) atm = ATM(3, "Data/atmdb_test.db") badInput = [ "deposit 1 2 3 4", "withdraw 5634 095376", "26235742", "2438g346" ] for bad in badInput: response = atm.controller(bad) self.assertEqual("Invalid command detected.", response.message) self.assertTrue(response.error) atm.controller("end") # test having a user login, be logged out due to inactivity # login again and then withdraw def test_InactiveLogoutThenLoginAndWitdraw(self) -> None: print( "Testing a scenario when a user is logged out due to inactivity then is logged back and withdraws" ) atm = self.AuthorizeAct() sleep = Event() sleep.wait(4) atm = self.AuthorizeAct() ogBal = atm.account.balance # make sure the account has enough money if ogBal < 20: diff = 20 - ogBal atm.account.updateAccountBalance(diff) # make sure atm has enough money if atm.atmBalance < 20: atm.updateATMBalance(100) # withdraw money and recheck balance response = atm.controller("withdraw 20") newBal = atm.account.balance self.assertEqual(newBal, ogBal - 20) message = "Amount dispensed: $20\nCurrent balance: {}".format(newBal) self.assertEqual(response.message, message) atm.controller("end")
def setUp(self): # create atm object with 3 second logout timer and point towards test db # this is mostly to be used with tests that don't require auth # create sql object that points towards test db self.atm = ATM(3, "Data/atmdb_test.db") self.sql = SQLHelper("Data/atmdb_test.db")
class ATM: # constructor, inactive logout time by default is 2 minutes # db by default is atmdb def __init__(self, inactiveTime: int = 120, db: str = "Data/atmdb.db") -> ATM: # commands that require and account to be authorized before running self.preauthCmds = ["withdraw", "deposit", "balance", "history"] self.sql = SQLHelper(db) self.atmBalance = self.sql.getATMBalanceCmd() self.inactiveTime = inactiveTime # timer that after inactiveTime runs inactiveLogout method self.timer = Timer(self.inactiveTime, self.inactiveLogout) self.activeTimer = False self.db = db self.account = Account(self.db) # updates atm balance in memory and in db # to specified amount def updateATMBalance(self, amount: int): self.sql.updateATMBalance(amount) self.atmBalance = amount # updates atm and account balances def updateBalances(self, amount: int, withdrawal: bool, overdraft: bool = False) -> None: actAmt = amount atmAmt = amount # if this transaction overdrafts the account subtract an additional 5 if overdraft: actAmt += 5 # if the is is a withdrawal turn amount negative if withdrawal: actAmt *= -1 atmAmt *= -1 # run methods to update atm and account balances newAtmBal = self.atmBalance + atmAmt self.updateATMBalance(newAtmBal) self.account.updateAccountBalance(actAmt) # uses accountSelectCmd from sqlhelper to get account using account id # checks that the provided pin matches the pin on file # if pin matches account is authorized, otherwise account is not authorized def authorize(self, accountId: str, pin: str) -> ControllerResponse: response = ControllerResponse() # check if an account is already authorized, if one is then return if self.account.isAuthorized: message = "An account is already authorized. Logout before authorizing another account." response.addResponseMsg(message) return response # validate that account sql object is valid # it's possible that it could have been cleared # due to inactivity if self.account.sql is None: self.account.sql = SQLHelper(self.db) # search for account data actData = self.sql.accountSelectCmd(accountId) # if no account exists with the provided account id then return # failed authorization if not actData: message = "Authorization failed." response.addResponseMsg(message) return response # get pin number actPin = actData[2] # if the pin on file matches the provided pin then authorize account if actPin == pin: balance = actData[3] self.account.addAccountDetails(accountId, balance, True) message = "{} successfully authorized.".format(accountId) # otherwise fail authorization else: message = "Authorization failed." response.addResponseMsg(message) return response # validate that account is authorized the withdraw amount # validate that atm and account have enough money def withdraw(self, amount: int) -> ControllerResponse: response = ControllerResponse() # if the amount is not greater than 0 and not an increment of 20 update response message # exit immediately if amount <= 0 or amount % 20 != 0: message = "Withdrawal amount must be greater than 0 and in increments of 20." response.addResponseMsg(message) return response # if the account is overdrawn update the response message # and exit immediately elif self.account.balance < 0: message = "Your account is overdrawn! You may not make withdrawals at this time." response.addResponseMsg(message) return response # if the atm has no money update response message and return elif self.atmBalance == 0: message = "Unable to process your withdrawal at this time." response.addResponseMsg(message) return response message = "" # if there isn't enough in the atm, update amount requested to atm balance # prepend unable to dispense full amount message to return message if amount > self.atmBalance: message = "Unable to dispense full amount requested at this time. " amount = self.atmBalance # if there is enough in the account # update account and atm balance if amount <= self.account.balance: self.updateBalances(amount, True) message += "Amount dispensed: ${}\nCurrent balance: {}".format( amount, round(self.account.balance, 2)) # if there is money in the account but not enough # add an extra 5 to withdrawal amount # update account and atm balance elif 0 <= self.account.balance < amount: self.updateBalances(amount, True, True) message += ( "Amount dispensed: ${}\nYou have been charged an overdraft fee of " "$5. Current balance: {}").format( amount, round(self.account.balance, 2)) # update response message response.addResponseMsg(message) return response # deposits provided amount into bank account def deposit(self, amount: int) -> ControllerResponse: if amount > 0: self.updateBalances(amount, False) message = "Current balance: {}".format( round(self.account.balance, 2)) else: message = "Deposit amount must be greater than 0." # update response and return response = ControllerResponse() response.addResponseMsg(message) return response # Finds balance of authorized account def getBalance(self) -> ControllerResponse: response = ControllerResponse() response.addResponseMsg("Current balance: {}".format( round(self.account.balance, 2))) return response # get history from db then format into string def getHistory(self) -> ControllerResponse: message = "" sqlData = self.sql.getHistoryCmd(self.account.accountId) # if no history found update message # otherwise grab history if len(sqlData) == 0: message = "No history found" else: for row in sqlData: message += "{} {} {} {}\n".format(row[0], row[1], round(row[2], 2), round(row[3], 2)) response = ControllerResponse() response.addResponseMsg(message) return response # if an account is logged in then log out and update response message # if no account is logged in then just update response message def logout(self) -> ControllerResponse: if self.account.isAuthorized: message = "Account {} logged out.".format(self.account.accountId) self.account = Account(self.db) else: message = "No account is currently authorized." response = ControllerResponse() response.addResponseMsg(message) return response # method that is called by timer when time is up # logs out user and sets active timer flag to false def inactiveLogout(self) -> None: self.account.clearAccountDetails() self.activeTimer = False self.timer = Timer(self.inactiveTime, self.inactiveLogout) # starts the inactive timer if one isn't running and account is authorized def startInactiveTimer(self) -> None: if self.account.isAuthorized and not self.activeTimer: self.timer.start() self.activeTimer = True # stops the inactive timer if one is running def stopInactiveTimer(self) -> None: # if a timer is running, cancel timer, reset flag # and create new timer object if self.activeTimer: self.timer.cancel() self.activeTimer = False self.timer = Timer(self.inactiveTime, self.inactiveLogout) # end program def endProgram(self) -> ControllerResponse: response = ControllerResponse() response.setEndFlag() response.addResponseMsg("Goodbye!") return response # logs error to db def logError(self, error: Exception) -> None: errorMsg = str(error) # escape single quotes errorMsg = errorMsg.replace("'", "''") self.sql.logErrorCmd(self.account.accountId, errorMsg) # validates user input # checks length and data types def validateInput(self, usrInput: str) -> List[object]: # strip and split user input cmdParts = usrInput.strip().split() # no command should have over 3 arguments, raise exception if len(cmdParts) > 3: raise Exception( "Too many arguments detected in command: {}".format(usrInput)) firstPart = cmdParts[0].lower() # commands that should only have 2 parts twoPartCmds = ["withdraw", "deposit"] onePartCmds = ["balance", "history", "logout", "end"] # if the command has three parts it should be authorize if len(cmdParts) == 3 and firstPart != "authorize": raise Exception( "Too many arguments detected in command: {}".format(usrInput)) # if it has two pasts it should just be withdraw or deposit elif len(cmdParts) == 2 and firstPart not in twoPartCmds: raise Exception( "Too many arguments detected in command: {}".format(usrInput)) # if in one part commands, should only be one command elif firstPart in onePartCmds and len(cmdParts) != 1: raise Exception( "Too many arguments detected in command: {}".format(usrInput)) validCmds = list() validCmds.append(firstPart) # is this is an authorize command then list should have strings if firstPart == "authorize": for i in range(1, len(cmdParts)): argVal = cmdParts[i].strip() validCmds.append(argVal) # otherwise first command should be strings # all others should be ints if len > 1 elif len(cmdParts) > 1: for i in range(1, len(cmdParts)): argVal = int(cmdParts[i].strip()) validCmds.append(argVal) return validCmds # controls logic flow of class # routes to different methods based on valid user input # starts and stops inactivity timer def controller(self, userInput: str) -> ControllerResponse: self.stopInactiveTimer() # minimal error handling for simplicity # one try block, if error occurs log and return generic response try: # raise exception if len(userInput.strip()) == 0: raise Exception("No input detected.") # parse commands if valid, exception raised if not valid cmds = self.validateInput(userInput) initArg = cmds[0] # if the command is in the list of commands that need authorization # validate that the account is authorized if initArg in self.preauthCmds: # if the account is authorized then route and run command if self.account.isAuthorized: if initArg == "withdraw": response = self.withdraw(cmds[1]) elif initArg == "deposit": response = self.deposit(cmds[1]) elif initArg == "balance": response = self.getBalance() elif initArg == "history": response = self.getHistory() # if the account isn't authorized, update response else: response = ControllerResponse() response.addResponseMsg("Authorization required.") # if command doesn't need preauth then run them elif initArg == "authorize": response = self.authorize(cmds[1], cmds[2]) elif initArg == "logout": response = self.logout() elif initArg == "end": response = self.endProgram() # otherwise command isn't recognized, throw error else: raise Exception( "Invalid command detected: {}".format(userInput)) # if there is an error, log to db and update controller message except Exception as e: self.logError(e) message = "Invalid command detected." response = ControllerResponse() response.addResponseMsg(message) response.updateErrorFlag(True) # start inactive timer unless if program is ending if not response.end: self.startInactiveTimer() return response
def __init__(self, db: str = "Data/atmdb.db") -> Account: self.accountId = None self.balance = None self.isAuthorized = False self.sql = SQLHelper(db)