[test] Added unit tests

- Moved the TransactionNotFound exception to a more appropriate place.
- Changed the serialization for AccountingExceptions
- Override the Exception.__init__ method in AccountingException
- Added __eq__ methods to accounting.models.*
- Catch the TransactionNotFound exception in transaction_get and return a
  404 instead. This could be improved, perhaps in the jsonify_exceptions
  decorator so that all endpoints that raise a TransactionNotFound
  exception automatically return a 404.
This commit is contained in:
Joar Wandborg 2013-12-21 00:24:37 +01:00
parent 36d91dd0b3
commit 281d6fed47
7 changed files with 70 additions and 41 deletions

View file

@ -7,4 +7,11 @@ class AccountingException(Exception):
Used as a base for exceptions that are returned to the caller via the Used as a base for exceptions that are returned to the caller via the
jsonify_exceptions decorator jsonify_exceptions decorator
''' '''
def __init__(self, message, **kw):
self.message = message
for key, value in kw.items():
setattr(self, key, value)
class TransactionNotFound(AccountingException):
pass pass

View file

@ -1,14 +1,18 @@
# Part of accounting-api project: # Part of accounting-api project:
# https://gitorious.org/conservancy/accounting-api # https://gitorious.org/conservancy/accounting-api
# License: AGPLv3-or-later # License: AGPLv3-or-later
import datetime
import uuid import uuid
from decimal import Decimal from decimal import Decimal
class Transaction: class Transaction:
def __init__(self, id=None, date=None, payee=None, postings=None, def __init__(self, id=None, date=None, payee=None, postings=None,
metadata=None, _generate_id=False): metadata=None, _generate_id=False):
if type(date) == datetime.datetime:
date = date.date()
self.id = id self.id = id
self.date = date self.date = date
self.payee = payee self.payee = payee
@ -21,6 +25,9 @@ class Transaction:
def generate_id(self): def generate_id(self):
self.id = str(uuid.uuid4()) self.id = str(uuid.uuid4())
def __eq__(self, other):
return self.__dict__ == other.__dict__
def __repr__(self): def __repr__(self):
return ('<{self.__class__.__name__} {self.id} {date}' + return ('<{self.__class__.__name__} {self.id} {date}' +
' {self.payee} {self.postings}').format( ' {self.payee} {self.postings}').format(
@ -34,6 +41,9 @@ class Posting:
self.amount = amount self.amount = amount
self.metadata = metadata if metadata is not None else {} self.metadata = metadata if metadata is not None else {}
def __eq__(self, other):
return self.__dict__ == other.__dict__
def __repr__(self): def __repr__(self):
return ('<{self.__class__.__name__} "{self.account}"' + return ('<{self.__class__.__name__} "{self.account}"' +
' {self.amount}>').format(self=self) ' {self.amount}>').format(self=self)
@ -44,6 +54,9 @@ class Amount:
self.amount = Decimal(amount) self.amount = Decimal(amount)
self.symbol = symbol self.symbol = symbol
def __eq__(self, other):
return self.__dict__ == other.__dict__
def __repr__(self): def __repr__(self):
return ('<{self.__class__.__name__} {self.symbol}' + return ('<{self.__class__.__name__} {self.symbol}' +
' {self.amount}>').format(self=self) ' {self.amount}>').format(self=self)
@ -55,6 +68,9 @@ class Account:
self.amounts = amounts self.amounts = amounts
self.accounts = accounts self.accounts = accounts
def __eq__(self, other):
return self.__dict__ == other.__dict__
def __repr__(self): def __repr__(self):
return ('<{self.__class__.__name__} "{self.name}" {self.amounts}' + return ('<{self.__class__.__name__} "{self.name}" {self.amounts}' +
' {self.accounts}>').format(self=self) ' {self.accounts}>').format(self=self)

View file

@ -4,8 +4,6 @@
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
from accounting.exceptions import AccountingException
class Storage: class Storage:
''' '''
@ -47,7 +45,3 @@ class Storage:
@abstractmethod @abstractmethod
def reverse_transaction(self, transaction_id): def reverse_transaction(self, transaction_id):
raise NotImplementedError raise NotImplementedError
class TransactionNotFound(AccountingException):
pass

View file

@ -12,9 +12,9 @@ from datetime import datetime
from xml.etree import ElementTree from xml.etree import ElementTree
from contextlib import contextmanager from contextlib import contextmanager
from accounting.exceptions import AccountingException from accounting.exceptions import AccountingException, TransactionNotFound
from accounting.models import Account, Transaction, Posting, Amount from accounting.models import Account, Transaction, Posting, Amount
from accounting.storage import Storage, TransactionNotFound from accounting.storage import Storage
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
@ -129,6 +129,10 @@ class Ledger(Storage):
while True: while True:
line = process.stdout.read(1) # XXX: This is a hack line = process.stdout.read(1) # XXX: This is a hack
if len(line) > 0:
pass
#_log.debug('line: %s', line)
output += line output += line
if b'\n] ' in output: if b'\n] ' in output:
@ -148,6 +152,8 @@ class Ledger(Storage):
if isinstance(command, str): if isinstance(command, str):
command = command.encode('utf8') command = command.encode('utf8')
_log.debug('Sending command: %r', command)
p.stdin.write(command + b'\n') p.stdin.write(command + b'\n')
p.stdin.flush() p.stdin.flush()
@ -263,8 +269,8 @@ class Ledger(Storage):
if transaction.id == transaction_id: if transaction.id == transaction_id:
return transaction return transaction
raise TransactionNotFound('No transaction with id %s found', raise TransactionNotFound(
transaction_id) 'No transaction with id {0} found'.format(transaction_id))
def reg(self): def reg(self):
output = self.send_command('xml') output = self.send_command('xml')
@ -321,7 +327,13 @@ class Ledger(Storage):
metadata.update({key: value}) metadata.update({key: value})
# Add a Transaction instance to the list # Add a Transaction instance to the list
id = metadata.pop('Id') try:
id = metadata.pop('Id')
except KeyError:
_log.warning('Transaction on %s with payee %s does not have an'
' Id attribute. A temporary ID will be used.',
date, payee)
id = 'NO-ID'
entries.append( entries.append(
Transaction(id=id, date=date, payee=payee, postings=postings, Transaction(id=id, date=date, payee=payee, postings=postings,
metadata=metadata)) metadata=metadata))

View file

@ -6,6 +6,7 @@ from datetime import datetime
from flask import json from flask import json
from accounting.exceptions import AccountingException
from accounting.models import Amount, Transaction, Posting, Account from accounting.models import Amount, Transaction, Posting, Account
@ -40,10 +41,10 @@ class AccountingEncoder(json.JSONEncoder):
amount=str(o.amount), amount=str(o.amount),
symbol=o.symbol symbol=o.symbol
) )
elif isinstance(o, Exception): elif isinstance(o, AccountingException):
return dict( return dict(
__type__=o.__class__.__name__, type=o.__class__.__name__,
args=o.args message=o.message
) )
return json.JSONEncoder.default(self, o) return json.JSONEncoder.default(self, o)
@ -58,7 +59,7 @@ class AccountingDecoder(json.JSONDecoder):
return d return d
types = {c.__name__: c for c in [Amount, Transaction, Posting, types = {c.__name__: c for c in [Amount, Transaction, Posting,
Account]} Account, AccountingException]}
_type = d.pop('__type__') _type = d.pop('__type__')

View file

@ -10,7 +10,7 @@ import sys
import logging import logging
import argparse import argparse
from flask import Flask, jsonify, request, render_template from flask import Flask, jsonify, request, render_template, abort
from flask.ext.script import Manager from flask.ext.script import Manager
from flask.ext.migrate import Migrate, MigrateCommand from flask.ext.migrate import Migrate, MigrateCommand
@ -19,32 +19,18 @@ from accounting.storage import Storage
from accounting.storage.ledgercli import Ledger from accounting.storage.ledgercli import Ledger
from accounting.storage.sql import SQLStorage from accounting.storage.sql import SQLStorage
from accounting.transport import AccountingEncoder, AccountingDecoder from accounting.transport import AccountingEncoder, AccountingDecoder
from accounting.exceptions import AccountingException from accounting.exceptions import AccountingException, TransactionNotFound
from accounting.decorators import jsonify_exceptions, cors from accounting.decorators import jsonify_exceptions, cors
app = Flask('accounting') app = Flask('accounting')
app.config.from_pyfile('config.py') app.config.from_pyfile('config.py')
storage = Storage() app.ledger = Storage()
if isinstance(storage, SQLStorage):
# TODO: Move migration stuff into SQLStorage
db = storage.db
migrate = Migrate(app, db)
manager = Manager(app)
manager.add_command('db', MigrateCommand)
@app.before_request
def init_ledger(): def init_ledger():
''' app.ledger = Ledger(app)
:py:meth:`flask.Flask.before_request`-decorated method that initializes an
:py:class:`accounting.Ledger` object.
'''
global ledger
#ledger = Ledger(ledger_file=app.config['LEDGER_FILE'])
# These will convert output from our internal classes to JSON and back # These will convert output from our internal classes to JSON and back
@ -85,9 +71,12 @@ def transaction_get(transaction_id=None):
Returns the JSON-serialized output of :meth:`accounting.Ledger.reg` Returns the JSON-serialized output of :meth:`accounting.Ledger.reg`
''' '''
if transaction_id is None: if transaction_id is None:
return jsonify(transactions=storage.get_transactions()) return jsonify(transactions=app.ledger.get_transactions())
return jsonify(transaction=storage.get_transaction(transaction_id)) try:
return jsonify(transaction=app.ledger.get_transaction(transaction_id))
except TransactionNotFound:
abort(404)
@app.route('/transaction/<string:transaction_id>', methods=['POST']) @app.route('/transaction/<string:transaction_id>', methods=['POST'])
@ -105,7 +94,7 @@ def transaction_update(transaction_id=None):
elif transaction.id is None: elif transaction.id is None:
transaction.id = transaction_id transaction.id = transaction_id
storage.update_transaction(transaction) app.ledger.update_transaction(transaction)
return jsonify(status='OK') return jsonify(status='OK')
@ -117,7 +106,7 @@ def transaction_delete(transaction_id=None):
if transaction_id is None: if transaction_id is None:
raise AccountingException('Transaction ID cannot be None') raise AccountingException('Transaction ID cannot be None')
storage.delete_transaction(transaction_id) app.ledger.delete_transaction(transaction_id)
return jsonify(status='OK') return jsonify(status='OK')
@ -184,7 +173,7 @@ def transaction_post():
transaction_ids = [] transaction_ids = []
for transaction in transactions: for transaction in transactions:
transaction_ids.append(storage.add_transaction(transaction)) transaction_ids.append(app.ledger.add_transaction(transaction))
return jsonify(status='OK', transaction_ids=transaction_ids) return jsonify(status='OK', transaction_ids=transaction_ids)
@ -201,8 +190,7 @@ def main(argv=None):
help=('Filter logging output. Possible values:' + help=('Filter logging output. Possible values:' +
' CRITICAL, ERROR, WARNING, INFO, DEBUG')) ' CRITICAL, ERROR, WARNING, INFO, DEBUG'))
global storage init_ledger()
storage = Ledger(app=app)
args = parser.parse_args(argv) args = parser.parse_args(argv)

View file

@ -23,6 +23,7 @@
Assets:Checking $50.00 Assets:Checking $50.00
2011/04/20 (2) Baz Hosting Services, LLC 2011/04/20 (2) Baz Hosting Services, LLC
;Party: Baz Hosting Services, LLC
;Id: this is probably unique ;Id: this is probably unique
Expenses:Blah:Hosting $250.00 Expenses:Blah:Hosting $250.00
;Receipt: Projects/Blah/Expenses/hosting/AprilHostingReceipt.pdf ;Receipt: Projects/Blah/Expenses/hosting/AprilHostingReceipt.pdf
@ -44,3 +45,13 @@
;Id: 31048b9d-a5b6-41d7-951a-e7128e7c53c0 ;Id: 31048b9d-a5b6-41d7-951a-e7128e7c53c0
Income:Donations:PayPal $ -20.18 Income:Donations:PayPal $ -20.18
Assets:Checking $ 20.18 Assets:Checking $ 20.18
2013-12-19 AngularJS Donation
;Id: 593d921c-07f9-496d-a1b7-47bb68ff00d4
Assets:Checking USD -10.00
Expenses:Donations USD 10.00
2013-12-20 PageKite donation
;Id: b3576a94-2a53-4d40-bd92-06b2532937f5
Assets:Checking USD -10.00
Expenses:Donations USD 10.00