Move test directory, add TxRequest
* ./tests is moved to ./trading/tests * Remove trading/tests.py * Add TxRequest model * Add tests for TxRequests Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from trading.models import User, Commodity, Tx, BalanceError
|
||||
|
||||
|
||||
class TestTransactions(TestCase):
|
||||
def test_user_balances(self):
|
||||
# Create users
|
||||
user1 = User.objects.create_user(username="test1", email="test1@test.test")
|
||||
user2 = User.objects.create_user(username="test2", email="test2@test.test")
|
||||
|
||||
commodity = Commodity(created_by=user1, in_circulation=1000, name="commodity")
|
||||
commodity.save()
|
||||
|
||||
self.assertEqual(user1.balances(), {commodity: 1000})
|
||||
self.assertEqual(user2.balances(), {})
|
||||
|
||||
Tx(source=user1, dest=user2, amount=100, commodity=commodity).save()
|
||||
|
||||
self.assertEqual(user1.balances(), {commodity: 900})
|
||||
self.assertEqual(user2.balances(), {commodity: 100})
|
||||
|
||||
with self.assertRaises(BalanceError):
|
||||
Tx(source=user2, dest=user1, amount=1000, commodity=commodity).save()
|
||||
|
||||
self.assertEqual(user1.balances(), {commodity: 900})
|
||||
self.assertEqual(user2.balances(), {commodity: 100})
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 2.2 on 2020-01-16 01:11
|
||||
# Generated by Django 2.2 on 2020-01-17 01:24
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
@@ -45,7 +45,7 @@ class Migration(migrations.Migration):
|
||||
('id', hashid_field.field.HashidAutoField(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', min_length=7, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='commodity name')),
|
||||
('in_circulation', models.PositiveIntegerField()),
|
||||
('symbol', models.CharField(blank=True, max_length=6, unique=True, verbose_name='symbol')),
|
||||
('symbol', models.CharField(blank=True, max_length=6, verbose_name='symbol')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
@@ -54,16 +54,15 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TxRequests',
|
||||
name='TxRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('WAIT', 'Waiting'), ('DECL', 'Declined'), ('REV', 'Revised')], default='WAIT', max_length=2)),
|
||||
('status', models.CharField(choices=[('OPEN', 'Open'), ('DECL', 'Declined'), ('ACC', 'Accepted')], default='OPEN', max_length=2)),
|
||||
('source_amount', models.PositiveIntegerField()),
|
||||
('dest_amount', models.PositiveIntegerField()),
|
||||
('dest', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_received', to=settings.AUTH_USER_MODEL)),
|
||||
('dest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_received', to=settings.AUTH_USER_MODEL)),
|
||||
('dest_sends', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='trading.Commodity')),
|
||||
('previous_request', models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revised_request', to='trading.TxRequests')),
|
||||
('source', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_sent', to=settings.AUTH_USER_MODEL)),
|
||||
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_sent', to=settings.AUTH_USER_MODEL)),
|
||||
('source_sends', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='trading.Commodity')),
|
||||
],
|
||||
),
|
||||
@@ -75,6 +74,7 @@ class Migration(migrations.Migration):
|
||||
('amount', models.PositiveIntegerField(editable=False, verbose_name='amount')),
|
||||
('commodity', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to='trading.Commodity')),
|
||||
('dest', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='credits', related_query_name='credit_tx', to=settings.AUTH_USER_MODEL, verbose_name='destination')),
|
||||
('request', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='trading.TxRequest')),
|
||||
('source', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='debits', related_query_name='debit_tx', to=settings.AUTH_USER_MODEL, verbose_name='source')),
|
||||
],
|
||||
options={
|
||||
|
||||
@@ -87,12 +87,26 @@ class User(PermissionsMixin, AbstractBaseUser):
|
||||
row["pk"]: row["in_circulation"]
|
||||
for row in self.commodity_set.values("pk", "in_circulation")
|
||||
}
|
||||
keys = set(ipos.keys()) | set(credits.keys()) | set(debits.keys())
|
||||
tx_requests = {
|
||||
row["source_sends"]: row["source_amount__sum"]
|
||||
for row in self.transaction_requests_sent.filter(
|
||||
status=TxRequest.OPEN
|
||||
).values("source_sends").annotate(Sum("source_amount"))
|
||||
}
|
||||
keys = (
|
||||
set(ipos.keys())
|
||||
| set(credits.keys())
|
||||
| set(debits.keys())
|
||||
| set(tx_requests.keys())
|
||||
)
|
||||
return frozendict(
|
||||
[
|
||||
(
|
||||
Commodity.objects.get(pk=pk),
|
||||
credits.get(pk, 0) + ipos.get(pk, 0) - debits.get(pk, 0),
|
||||
credits.get(pk, 0)
|
||||
+ ipos.get(pk, 0)
|
||||
- debits.get(pk, 0)
|
||||
- tx_requests.get(pk, 0),
|
||||
)
|
||||
for pk in keys
|
||||
]
|
||||
@@ -142,9 +156,7 @@ class Commodity(models.Model):
|
||||
)
|
||||
in_circulation = models.PositiveIntegerField()
|
||||
created_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
|
||||
symbol = models.CharField(
|
||||
blank=True, unique=True, max_length=6, verbose_name=_("symbol")
|
||||
)
|
||||
symbol = models.CharField(blank=True, max_length=6, verbose_name=_("symbol"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("commodity")
|
||||
@@ -157,7 +169,7 @@ class Commodity(models.Model):
|
||||
if self.symbol:
|
||||
return f"<span>{self.symbol}</span>"
|
||||
else:
|
||||
return f"<span>units of {self.name}"
|
||||
return f"<span>units of {self.name}</span>"
|
||||
|
||||
|
||||
class MaxCommodityError(Exception):
|
||||
@@ -191,51 +203,51 @@ class Tx(models.Model):
|
||||
)
|
||||
amount = models.PositiveIntegerField(verbose_name=_("amount"), editable=False)
|
||||
commodity = models.ForeignKey(Commodity, on_delete=models.PROTECT, editable=False)
|
||||
request = models.ForeignKey(
|
||||
"TxRequest", null=True, default=None, on_delete=models.SET_NULL
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("transaction")
|
||||
verbose_name_plural = _("transactions")
|
||||
|
||||
|
||||
class TxRequests(models.Model):
|
||||
class TxRequest(models.Model):
|
||||
"""
|
||||
A transaction request between two users.
|
||||
"""
|
||||
|
||||
WAITING = "WAIT"
|
||||
OPEN = "OPEN"
|
||||
DECLINED = "DECL"
|
||||
REVISED = "REV"
|
||||
# REVISED = "REV"
|
||||
ACCEPTED = "ACC"
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(WAITING, "Waiting"),
|
||||
(OPEN, "Open"),
|
||||
(DECLINED, "Declined"),
|
||||
(REVISED, "Revised"),
|
||||
# (REVISED, "Revised"),
|
||||
(ACCEPTED, "Accepted"),
|
||||
]
|
||||
|
||||
status = models.CharField(
|
||||
max_length=max([len(c) for c in STATUS_CHOICES]),
|
||||
choices=STATUS_CHOICES,
|
||||
default=WAITING,
|
||||
)
|
||||
previous_request = models.OneToOneField(
|
||||
"TxRequests",
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="revised_request",
|
||||
default=OPEN,
|
||||
)
|
||||
|
||||
# previous_request = models.OneToOneField(
|
||||
# "TxRequest",
|
||||
# null=True,
|
||||
# default=None,
|
||||
# on_delete=models.SET_NULL,
|
||||
# related_name="revised_request",
|
||||
# )
|
||||
|
||||
source = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="transaction_requests_sent",
|
||||
User, on_delete=models.CASCADE, related_name="transaction_requests_sent",
|
||||
)
|
||||
dest = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="transaction_requests_received",
|
||||
User, on_delete=models.CASCADE, related_name="transaction_requests_received",
|
||||
)
|
||||
|
||||
source_sends = models.ForeignKey(
|
||||
@@ -248,6 +260,81 @@ class TxRequests(models.Model):
|
||||
source_amount = models.PositiveIntegerField()
|
||||
dest_amount = models.PositiveIntegerField()
|
||||
|
||||
@staticmethod
|
||||
def open(
|
||||
source: User,
|
||||
dest: User,
|
||||
source_sends: Commodity,
|
||||
dest_sends: Commodity,
|
||||
source_amount: int,
|
||||
dest_amount: int,
|
||||
) -> "TxRequest":
|
||||
"""
|
||||
Opens a new transaction request.
|
||||
"""
|
||||
# Check balance
|
||||
source_balance = source.balance_of(source_sends)
|
||||
if source_balance < source_amount:
|
||||
raise BalanceError(source_balance, source, dest, source_amount, source_sends)
|
||||
req = TxRequest.objects.create(
|
||||
status=TxRequest.OPEN,
|
||||
source=source,
|
||||
dest=dest,
|
||||
source_sends=source_sends,
|
||||
dest_sends=dest_sends,
|
||||
source_amount=source_amount,
|
||||
dest_amount=dest_amount,
|
||||
)
|
||||
req.save()
|
||||
return req
|
||||
|
||||
def accept(self):
|
||||
"""
|
||||
Accepts an open transaction request.
|
||||
"""
|
||||
assert self.status == TxRequest.OPEN
|
||||
|
||||
# ensure destination balance before continuing
|
||||
dest_balance = self.dest.balance_of(self.dest_sends)
|
||||
if dest_balance < self.dest_amount:
|
||||
raise BalanceError(
|
||||
dest_balance, self.dest, self.source, self.dest_amount, self.dest_sends
|
||||
)
|
||||
|
||||
# update status
|
||||
self.status = TxRequest.ACCEPTED
|
||||
|
||||
# Create source transaction
|
||||
source_tx = Tx.objects.create(
|
||||
source=self.source,
|
||||
dest=self.dest,
|
||||
amount=self.source_amount,
|
||||
commodity=self.source_sends,
|
||||
request=self,
|
||||
)
|
||||
|
||||
# Create dest transaction
|
||||
dest_tx = Tx.objects.create(
|
||||
source=self.dest,
|
||||
dest=self.source,
|
||||
amount=self.dest_amount,
|
||||
commodity=self.dest_sends,
|
||||
request=self,
|
||||
)
|
||||
|
||||
# Save everything
|
||||
source_tx.save()
|
||||
dest_tx.save()
|
||||
self.save()
|
||||
|
||||
def decline(self):
|
||||
"""
|
||||
Declines an open transaction request.
|
||||
"""
|
||||
assert self.status == TxRequest.OPEN
|
||||
self.status = TxRequest.DECLINED
|
||||
self.save()
|
||||
|
||||
|
||||
class BalanceError(Exception):
|
||||
def __init__(
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
107
trading/tests/test_models.py
Normal file
107
trading/tests/test_models.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from django.test import TestCase
|
||||
from trading.models import User, Commodity, Tx, TxRequest, BalanceError
|
||||
|
||||
|
||||
class TestTransactions(TestCase):
|
||||
def test_user_balances(self):
|
||||
# Create users
|
||||
user1 = User.objects.create_user(username="test1", email="test1@test.test")
|
||||
user2 = User.objects.create_user(username="test2", email="test2@test.test")
|
||||
|
||||
# Create test commodity
|
||||
commodity = Commodity(created_by=user1, in_circulation=1000, name="commodity")
|
||||
commodity.save()
|
||||
|
||||
# Confirm balances
|
||||
self.assertEqual(user1.balances(), {commodity: 1000})
|
||||
self.assertEqual(user1.balance_of(commodity), 1000)
|
||||
self.assertEqual(user2.balances(), {})
|
||||
self.assertEqual(user2.balance_of(commodity), 0)
|
||||
|
||||
# Create transaction
|
||||
Tx(source=user1, dest=user2, amount=100, commodity=commodity).save()
|
||||
|
||||
# Confirm balances
|
||||
self.assertEqual(user1.balances(), {commodity: 900})
|
||||
self.assertEqual(user2.balances(), {commodity: 100})
|
||||
|
||||
# Ensure we can't trade when there's missing balance
|
||||
with self.assertRaises(BalanceError):
|
||||
Tx(source=user2, dest=user1, amount=1000, commodity=commodity).save()
|
||||
|
||||
# Confirm balances
|
||||
self.assertEqual(user1.balances(), {commodity: 900})
|
||||
self.assertEqual(user2.balances(), {commodity: 100})
|
||||
|
||||
def test_tx_requests(self):
|
||||
# Create users
|
||||
user1 = User.objects.create_user(username="test1", email="test1@test.test")
|
||||
user2 = User.objects.create_user(username="test2", email="test2@test.test")
|
||||
|
||||
# Create test commodity for user1
|
||||
com1 = Commodity(created_by=user1, in_circulation=1000, name="test1")
|
||||
com1.save()
|
||||
|
||||
# Create test commodity for user2
|
||||
com2 = Commodity(created_by=user2, in_circulation=1000, name="test2")
|
||||
com2.save()
|
||||
|
||||
# Create TX request
|
||||
req1 = TxRequest.open(user1, user2, com1, com2, 10, 10)
|
||||
|
||||
# Confirm balances
|
||||
self.assertEqual(user1.balance_of(com1), 990)
|
||||
self.assertEqual(user1.balance_of(com2), 0)
|
||||
self.assertEqual(user2.balance_of(com1), 0)
|
||||
self.assertEqual(user2.balance_of(com2), 1000)
|
||||
|
||||
# Accept TX request
|
||||
req1.accept()
|
||||
|
||||
# Confirm balances
|
||||
self.assertEqual(user1.balance_of(com1), 990)
|
||||
self.assertEqual(user1.balance_of(com2), 10)
|
||||
self.assertEqual(user2.balance_of(com1), 10)
|
||||
self.assertEqual(user2.balance_of(com2), 990)
|
||||
|
||||
# Create TX request
|
||||
req2 = TxRequest.open(user2, user1, com2, com1, 10, 10)
|
||||
|
||||
# Confirm balances
|
||||
self.assertEqual(user1.balance_of(com1), 990)
|
||||
self.assertEqual(user1.balance_of(com2), 10)
|
||||
self.assertEqual(user2.balance_of(com1), 10)
|
||||
self.assertEqual(user2.balance_of(com2), 980)
|
||||
|
||||
# Decline TX request
|
||||
req2.decline()
|
||||
|
||||
# Confirm balances
|
||||
self.assertEqual(user1.balance_of(com1), 990)
|
||||
self.assertEqual(user1.balance_of(com2), 10)
|
||||
self.assertEqual(user2.balance_of(com1), 10)
|
||||
self.assertEqual(user2.balance_of(com2), 990)
|
||||
|
||||
# Ensure assertions on already created TXs
|
||||
with self.assertRaises(AssertionError):
|
||||
req1.accept()
|
||||
with self.assertRaises(AssertionError):
|
||||
req1.decline()
|
||||
with self.assertRaises(AssertionError):
|
||||
req2.accept()
|
||||
with self.assertRaises(AssertionError):
|
||||
req2.decline()
|
||||
|
||||
# Balance checking
|
||||
# Ensure whoever opens the request has the balance available
|
||||
with self.assertRaises(BalanceError):
|
||||
TxRequest.open(user1, user2, com2, com1, 100, 100)
|
||||
|
||||
# Ensure whoever tries to accept the request has the balance available
|
||||
req3 = TxRequest.open(user1, user2, com2, com1, 10, 100)
|
||||
with self.assertRaises(BalanceError):
|
||||
req3.accept()
|
||||
|
||||
# And make sure you can still decline it after failing to accept it.
|
||||
req3.decline()
|
||||
|
||||
Reference in New Issue
Block a user