eventsourcing 9.2.21__py3-none-any.whl → 9.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of eventsourcing might be problematic. Click here for more details.
- eventsourcing/__init__.py +1 -1
- eventsourcing/application.py +137 -132
- eventsourcing/cipher.py +17 -12
- eventsourcing/compressor.py +2 -0
- eventsourcing/dispatch.py +30 -56
- eventsourcing/domain.py +221 -227
- eventsourcing/examples/__init__.py +0 -0
- eventsourcing/examples/aggregate1/__init__.py +0 -0
- eventsourcing/examples/aggregate1/application.py +27 -0
- eventsourcing/examples/aggregate1/domainmodel.py +16 -0
- eventsourcing/examples/aggregate1/test_application.py +37 -0
- eventsourcing/examples/aggregate2/__init__.py +0 -0
- eventsourcing/examples/aggregate2/application.py +27 -0
- eventsourcing/examples/aggregate2/domainmodel.py +22 -0
- eventsourcing/examples/aggregate2/test_application.py +37 -0
- eventsourcing/examples/aggregate3/__init__.py +0 -0
- eventsourcing/examples/aggregate3/application.py +27 -0
- eventsourcing/examples/aggregate3/domainmodel.py +38 -0
- eventsourcing/examples/aggregate3/test_application.py +37 -0
- eventsourcing/examples/aggregate4/__init__.py +0 -0
- eventsourcing/examples/aggregate4/application.py +27 -0
- eventsourcing/examples/aggregate4/domainmodel.py +114 -0
- eventsourcing/examples/aggregate4/test_application.py +38 -0
- eventsourcing/examples/aggregate5/__init__.py +0 -0
- eventsourcing/examples/aggregate5/application.py +27 -0
- eventsourcing/examples/aggregate5/domainmodel.py +131 -0
- eventsourcing/examples/aggregate5/test_application.py +38 -0
- eventsourcing/examples/aggregate6/__init__.py +0 -0
- eventsourcing/examples/aggregate6/application.py +30 -0
- eventsourcing/examples/aggregate6/domainmodel.py +123 -0
- eventsourcing/examples/aggregate6/test_application.py +38 -0
- eventsourcing/examples/aggregate6a/__init__.py +0 -0
- eventsourcing/examples/aggregate6a/application.py +40 -0
- eventsourcing/examples/aggregate6a/domainmodel.py +149 -0
- eventsourcing/examples/aggregate6a/test_application.py +45 -0
- eventsourcing/examples/aggregate7/__init__.py +0 -0
- eventsourcing/examples/aggregate7/application.py +48 -0
- eventsourcing/examples/aggregate7/domainmodel.py +144 -0
- eventsourcing/examples/aggregate7/persistence.py +57 -0
- eventsourcing/examples/aggregate7/test_application.py +38 -0
- eventsourcing/examples/aggregate7/test_compression_and_encryption.py +45 -0
- eventsourcing/examples/aggregate7/test_snapshotting_intervals.py +67 -0
- eventsourcing/examples/aggregate7a/__init__.py +0 -0
- eventsourcing/examples/aggregate7a/application.py +56 -0
- eventsourcing/examples/aggregate7a/domainmodel.py +170 -0
- eventsourcing/examples/aggregate7a/test_application.py +46 -0
- eventsourcing/examples/aggregate7a/test_compression_and_encryption.py +45 -0
- eventsourcing/examples/aggregate8/__init__.py +0 -0
- eventsourcing/examples/aggregate8/application.py +47 -0
- eventsourcing/examples/aggregate8/domainmodel.py +65 -0
- eventsourcing/examples/aggregate8/persistence.py +57 -0
- eventsourcing/examples/aggregate8/test_application.py +37 -0
- eventsourcing/examples/aggregate8/test_compression_and_encryption.py +44 -0
- eventsourcing/examples/aggregate8/test_snapshotting_intervals.py +38 -0
- eventsourcing/examples/bankaccounts/__init__.py +0 -0
- eventsourcing/examples/bankaccounts/application.py +70 -0
- eventsourcing/examples/bankaccounts/domainmodel.py +56 -0
- eventsourcing/examples/bankaccounts/test.py +173 -0
- eventsourcing/examples/cargoshipping/__init__.py +0 -0
- eventsourcing/examples/cargoshipping/application.py +126 -0
- eventsourcing/examples/cargoshipping/domainmodel.py +330 -0
- eventsourcing/examples/cargoshipping/interface.py +143 -0
- eventsourcing/examples/cargoshipping/test.py +231 -0
- eventsourcing/examples/contentmanagement/__init__.py +0 -0
- eventsourcing/examples/contentmanagement/application.py +118 -0
- eventsourcing/examples/contentmanagement/domainmodel.py +69 -0
- eventsourcing/examples/contentmanagement/test.py +180 -0
- eventsourcing/examples/contentmanagement/utils.py +26 -0
- eventsourcing/examples/contentmanagementsystem/__init__.py +0 -0
- eventsourcing/examples/contentmanagementsystem/application.py +54 -0
- eventsourcing/examples/contentmanagementsystem/postgres.py +17 -0
- eventsourcing/examples/contentmanagementsystem/sqlite.py +17 -0
- eventsourcing/examples/contentmanagementsystem/system.py +14 -0
- eventsourcing/examples/contentmanagementsystem/test_system.py +180 -0
- eventsourcing/examples/searchablecontent/__init__.py +0 -0
- eventsourcing/examples/searchablecontent/application.py +45 -0
- eventsourcing/examples/searchablecontent/persistence.py +23 -0
- eventsourcing/examples/searchablecontent/postgres.py +118 -0
- eventsourcing/examples/searchablecontent/sqlite.py +136 -0
- eventsourcing/examples/searchablecontent/test_application.py +110 -0
- eventsourcing/examples/searchablecontent/test_recorder.py +68 -0
- eventsourcing/examples/searchabletimestamps/__init__.py +0 -0
- eventsourcing/examples/searchabletimestamps/application.py +32 -0
- eventsourcing/examples/searchabletimestamps/persistence.py +20 -0
- eventsourcing/examples/searchabletimestamps/postgres.py +110 -0
- eventsourcing/examples/searchabletimestamps/sqlite.py +99 -0
- eventsourcing/examples/searchabletimestamps/test_searchabletimestamps.py +94 -0
- eventsourcing/examples/test_invoice.py +176 -0
- eventsourcing/examples/test_parking_lot.py +206 -0
- eventsourcing/interface.py +4 -2
- eventsourcing/persistence.py +88 -82
- eventsourcing/popo.py +32 -31
- eventsourcing/postgres.py +388 -593
- eventsourcing/sqlite.py +100 -102
- eventsourcing/system.py +66 -71
- eventsourcing/tests/application.py +20 -32
- eventsourcing/tests/application_tests/__init__.py +0 -0
- eventsourcing/tests/application_tests/test_application_with_automatic_snapshotting.py +55 -0
- eventsourcing/tests/application_tests/test_application_with_popo.py +22 -0
- eventsourcing/tests/application_tests/test_application_with_postgres.py +75 -0
- eventsourcing/tests/application_tests/test_application_with_sqlite.py +72 -0
- eventsourcing/tests/application_tests/test_cache.py +134 -0
- eventsourcing/tests/application_tests/test_event_sourced_log.py +162 -0
- eventsourcing/tests/application_tests/test_notificationlog.py +232 -0
- eventsourcing/tests/application_tests/test_notificationlogreader.py +126 -0
- eventsourcing/tests/application_tests/test_processapplication.py +110 -0
- eventsourcing/tests/application_tests/test_processingpolicy.py +109 -0
- eventsourcing/tests/application_tests/test_repository.py +504 -0
- eventsourcing/tests/application_tests/test_snapshotting.py +68 -0
- eventsourcing/tests/application_tests/test_upcasting.py +459 -0
- eventsourcing/tests/docs_tests/__init__.py +0 -0
- eventsourcing/tests/docs_tests/test_docs.py +293 -0
- eventsourcing/tests/domain.py +1 -1
- eventsourcing/tests/domain_tests/__init__.py +0 -0
- eventsourcing/tests/domain_tests/test_aggregate.py +1180 -0
- eventsourcing/tests/domain_tests/test_aggregate_decorators.py +1604 -0
- eventsourcing/tests/domain_tests/test_domainevent.py +80 -0
- eventsourcing/tests/interface_tests/__init__.py +0 -0
- eventsourcing/tests/interface_tests/test_remotenotificationlog.py +258 -0
- eventsourcing/tests/persistence.py +52 -50
- eventsourcing/tests/persistence_tests/__init__.py +0 -0
- eventsourcing/tests/persistence_tests/test_aes.py +93 -0
- eventsourcing/tests/persistence_tests/test_connection_pool.py +722 -0
- eventsourcing/tests/persistence_tests/test_eventstore.py +72 -0
- eventsourcing/tests/persistence_tests/test_infrastructure_factory.py +21 -0
- eventsourcing/tests/persistence_tests/test_mapper.py +113 -0
- eventsourcing/tests/persistence_tests/test_noninterleaving_notification_ids.py +69 -0
- eventsourcing/tests/persistence_tests/test_popo.py +124 -0
- eventsourcing/tests/persistence_tests/test_postgres.py +1119 -0
- eventsourcing/tests/persistence_tests/test_sqlite.py +348 -0
- eventsourcing/tests/persistence_tests/test_transcoder.py +44 -0
- eventsourcing/tests/postgres_utils.py +7 -7
- eventsourcing/tests/system_tests/__init__.py +0 -0
- eventsourcing/tests/system_tests/test_runner.py +935 -0
- eventsourcing/tests/system_tests/test_system.py +284 -0
- eventsourcing/tests/utils_tests/__init__.py +0 -0
- eventsourcing/tests/utils_tests/test_utils.py +226 -0
- eventsourcing/utils.py +49 -50
- {eventsourcing-9.2.21.dist-info → eventsourcing-9.3.0.dist-info}/METADATA +30 -33
- eventsourcing-9.3.0.dist-info/RECORD +145 -0
- {eventsourcing-9.2.21.dist-info → eventsourcing-9.3.0.dist-info}/WHEEL +1 -2
- eventsourcing-9.2.21.dist-info/RECORD +0 -25
- eventsourcing-9.2.21.dist-info/top_level.txt +0 -1
- {eventsourcing-9.2.21.dist-info → eventsourcing-9.3.0.dist-info}/AUTHORS +0 -0
- {eventsourcing-9.2.21.dist-info → eventsourcing-9.3.0.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from dataclasses import _DataclassParams, dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from unittest.case import TestCase
|
|
6
|
+
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
|
7
|
+
|
|
8
|
+
from eventsourcing.application import AggregateNotFound, AggregateNotFoundError
|
|
9
|
+
from eventsourcing.domain import (
|
|
10
|
+
Aggregate,
|
|
11
|
+
AggregateCreated,
|
|
12
|
+
AggregateEvent,
|
|
13
|
+
OriginatorIDError,
|
|
14
|
+
OriginatorVersionError,
|
|
15
|
+
TAggregate,
|
|
16
|
+
)
|
|
17
|
+
from eventsourcing.tests.domain import (
|
|
18
|
+
AccountClosedError,
|
|
19
|
+
BankAccount,
|
|
20
|
+
InsufficientFundsError,
|
|
21
|
+
)
|
|
22
|
+
from eventsourcing.utils import get_method_name
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestMetaAggregate(TestCase):
|
|
26
|
+
def test_aggregate_class_has_a_created_event_class(self):
|
|
27
|
+
self.assertTrue(hasattr(Aggregate, "_created_event_class"))
|
|
28
|
+
self.assertTrue(issubclass(Aggregate._created_event_class, AggregateCreated))
|
|
29
|
+
self.assertEqual(Aggregate._created_event_class, Aggregate.Created)
|
|
30
|
+
|
|
31
|
+
def test_aggregate_subclass_is_a_dataclass_iff_decorated_or_has_annotations(self):
|
|
32
|
+
# No dataclass decorator, no annotations.
|
|
33
|
+
class MyAggregate(Aggregate):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
self.assertFalse("__dataclass_params__" in MyAggregate.__dict__)
|
|
37
|
+
|
|
38
|
+
# Has a dataclass decorator (helps IDE know what's going on with annotations).
|
|
39
|
+
@dataclass
|
|
40
|
+
class MyAggregate(Aggregate):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
self.assertTrue("__dataclass_params__" in MyAggregate.__dict__)
|
|
44
|
+
self.assertIsInstance(MyAggregate.__dataclass_params__, _DataclassParams)
|
|
45
|
+
self.assertFalse(MyAggregate.__dataclass_params__.frozen)
|
|
46
|
+
|
|
47
|
+
# Has annotations but no decorator.
|
|
48
|
+
@dataclass
|
|
49
|
+
class MyAggregate(Aggregate):
|
|
50
|
+
a: int
|
|
51
|
+
|
|
52
|
+
self.assertTrue("__dataclass_params__" in MyAggregate.__dict__)
|
|
53
|
+
self.assertIsInstance(MyAggregate.__dataclass_params__, _DataclassParams)
|
|
54
|
+
self.assertFalse(MyAggregate.__dataclass_params__.frozen)
|
|
55
|
+
|
|
56
|
+
def test_aggregate_subclass_gets_a_default_created_event_class(self):
|
|
57
|
+
class MyAggregate(Aggregate):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
self.assertTrue(hasattr(MyAggregate, "_created_event_class"))
|
|
61
|
+
self.assertTrue(issubclass(MyAggregate._created_event_class, AggregateCreated))
|
|
62
|
+
self.assertEqual(MyAggregate._created_event_class, MyAggregate.Created)
|
|
63
|
+
|
|
64
|
+
def test_aggregate_subclass_has_a_custom_created_event_class(self):
|
|
65
|
+
class MyAggregate(Aggregate):
|
|
66
|
+
class Started(AggregateCreated):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
self.assertTrue(hasattr(MyAggregate, "_created_event_class"))
|
|
70
|
+
self.assertTrue(issubclass(MyAggregate._created_event_class, AggregateCreated))
|
|
71
|
+
self.assertEqual(MyAggregate._created_event_class, MyAggregate.Started)
|
|
72
|
+
|
|
73
|
+
def test_aggregate_subclass_has_a_custom_created_event_class_name(self):
|
|
74
|
+
@dataclass
|
|
75
|
+
class MyAggregate(Aggregate, created_event_name="Started"):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
a = MyAggregate()
|
|
79
|
+
self.assertEqual(type(a.pending_events[0]).__name__, "Started")
|
|
80
|
+
|
|
81
|
+
self.assertTrue(hasattr(MyAggregate, "_created_event_class"))
|
|
82
|
+
created_event_cls = MyAggregate._created_event_class
|
|
83
|
+
self.assertEqual(created_event_cls.__name__, "Started")
|
|
84
|
+
self.assertTrue(created_event_cls.__qualname__.endswith("MyAggregate.Started"))
|
|
85
|
+
self.assertTrue(issubclass(created_event_cls, AggregateCreated))
|
|
86
|
+
self.assertEqual(created_event_cls, MyAggregate.Started)
|
|
87
|
+
|
|
88
|
+
def test_can_define_initial_version_number(self):
|
|
89
|
+
class MyAggregate1(Aggregate):
|
|
90
|
+
INITIAL_VERSION = 0
|
|
91
|
+
|
|
92
|
+
a = MyAggregate1()
|
|
93
|
+
self.assertEqual(a.version, 0)
|
|
94
|
+
|
|
95
|
+
class MyAggregate2(Aggregate):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
a = MyAggregate2()
|
|
99
|
+
self.assertEqual(a.version, 1)
|
|
100
|
+
|
|
101
|
+
class MyAggregate3(Aggregate):
|
|
102
|
+
INITIAL_VERSION = 2
|
|
103
|
+
|
|
104
|
+
a = MyAggregate3()
|
|
105
|
+
self.assertEqual(a.version, 2)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestAggregateCreation(TestCase):
|
|
109
|
+
def test_call_class_method_create(self):
|
|
110
|
+
# Check the _create() method creates a new aggregate.
|
|
111
|
+
before_created = Aggregate.Event.create_timestamp()
|
|
112
|
+
uuid = uuid4()
|
|
113
|
+
a = Aggregate._create(
|
|
114
|
+
event_class=AggregateCreated,
|
|
115
|
+
id=uuid,
|
|
116
|
+
)
|
|
117
|
+
after_created = Aggregate.Event.create_timestamp()
|
|
118
|
+
self.assertIsInstance(a, Aggregate)
|
|
119
|
+
self.assertEqual(a.id, uuid)
|
|
120
|
+
self.assertEqual(a.version, 1)
|
|
121
|
+
self.assertEqual(a.created_on, a.modified_on)
|
|
122
|
+
self.assertGreater(a.created_on, before_created)
|
|
123
|
+
self.assertGreater(after_created, a.created_on)
|
|
124
|
+
|
|
125
|
+
def test_raises_when_create_args_mismatch_created_event(self):
|
|
126
|
+
class BrokenAggregate(Aggregate):
|
|
127
|
+
@classmethod
|
|
128
|
+
def create(cls, name):
|
|
129
|
+
return cls._create(event_class=cls.Created, id=uuid4(), name=name)
|
|
130
|
+
|
|
131
|
+
with self.assertRaises(TypeError) as cm:
|
|
132
|
+
BrokenAggregate.create("name")
|
|
133
|
+
|
|
134
|
+
method_name = get_method_name(BrokenAggregate.Created.__init__)
|
|
135
|
+
|
|
136
|
+
self.assertEqual(
|
|
137
|
+
"Unable to construct 'Created' event: "
|
|
138
|
+
f"{method_name}() got an unexpected keyword argument 'name'",
|
|
139
|
+
cm.exception.args[0],
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def test_call_base_class(self):
|
|
143
|
+
before_created = Aggregate.Event.create_timestamp()
|
|
144
|
+
a = Aggregate()
|
|
145
|
+
after_created = Aggregate.Event.create_timestamp()
|
|
146
|
+
self.assertIsInstance(a, Aggregate)
|
|
147
|
+
self.assertIsInstance(a.id, UUID)
|
|
148
|
+
self.assertIsInstance(a.version, int)
|
|
149
|
+
self.assertEqual(a.version, 1)
|
|
150
|
+
self.assertIsInstance(a.created_on, datetime)
|
|
151
|
+
self.assertIsInstance(a.modified_on, datetime)
|
|
152
|
+
self.assertEqual(a.created_on, a.modified_on)
|
|
153
|
+
self.assertGreater(a.created_on, before_created)
|
|
154
|
+
self.assertGreater(after_created, a.created_on)
|
|
155
|
+
|
|
156
|
+
events = a.collect_events()
|
|
157
|
+
self.assertIsInstance(events[0], AggregateCreated)
|
|
158
|
+
self.assertEqual("Aggregate.Created", type(events[0]).__qualname__)
|
|
159
|
+
|
|
160
|
+
def test_call_subclass_with_no_init(self):
|
|
161
|
+
qualname = type(self).__qualname__
|
|
162
|
+
prefix = f"{qualname}.test_call_subclass_with_no_init.<locals>."
|
|
163
|
+
|
|
164
|
+
class MyAggregate1(Aggregate):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
a = MyAggregate1()
|
|
168
|
+
self.assertIsInstance(a.id, UUID)
|
|
169
|
+
self.assertIsInstance(a.version, int)
|
|
170
|
+
self.assertEqual(a.version, 1)
|
|
171
|
+
self.assertIsInstance(a.created_on, datetime)
|
|
172
|
+
self.assertIsInstance(a.modified_on, datetime)
|
|
173
|
+
|
|
174
|
+
events = a.collect_events()
|
|
175
|
+
self.assertEqual(len(events), 1)
|
|
176
|
+
self.assertIsInstance(events[0], AggregateCreated)
|
|
177
|
+
self.assertEqual(f"{prefix}MyAggregate1.Created", type(events[0]).__qualname__)
|
|
178
|
+
|
|
179
|
+
# Do it again using @dataclass
|
|
180
|
+
@dataclass # ...this just makes the code completion work in the IDE.
|
|
181
|
+
class MyAggregate2(Aggregate):
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# Check the init method takes no args (except "self").
|
|
185
|
+
init_params = inspect.signature(MyAggregate2.__init__).parameters
|
|
186
|
+
self.assertEqual(len(init_params), 1)
|
|
187
|
+
self.assertEqual(next(iter(init_params)), "self")
|
|
188
|
+
|
|
189
|
+
#
|
|
190
|
+
# Do it again with custom "created" event.
|
|
191
|
+
@dataclass
|
|
192
|
+
class MyAggregate3(Aggregate):
|
|
193
|
+
class Started(AggregateCreated):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
a = MyAggregate3()
|
|
197
|
+
self.assertIsInstance(a.id, UUID)
|
|
198
|
+
self.assertIsInstance(a.version, int)
|
|
199
|
+
self.assertIsInstance(a.created_on, datetime)
|
|
200
|
+
self.assertIsInstance(a.modified_on, datetime)
|
|
201
|
+
|
|
202
|
+
events = a.collect_events()
|
|
203
|
+
self.assertEqual(len(events), 1)
|
|
204
|
+
self.assertIsInstance(events[0], AggregateCreated)
|
|
205
|
+
self.assertEqual(f"{prefix}MyAggregate3.Started", type(events[0]).__qualname__)
|
|
206
|
+
|
|
207
|
+
def test_init_no_args(self):
|
|
208
|
+
qualname = type(self).__qualname__
|
|
209
|
+
prefix = f"{qualname}.test_init_no_args.<locals>."
|
|
210
|
+
|
|
211
|
+
class MyAggregate1(Aggregate):
|
|
212
|
+
def __init__(self):
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
a = MyAggregate1()
|
|
216
|
+
self.assertIsInstance(a.id, UUID)
|
|
217
|
+
self.assertIsInstance(a.version, int)
|
|
218
|
+
self.assertIsInstance(a.created_on, datetime)
|
|
219
|
+
self.assertIsInstance(a.modified_on, datetime)
|
|
220
|
+
|
|
221
|
+
events = a.collect_events()
|
|
222
|
+
self.assertEqual(len(events), 1)
|
|
223
|
+
self.assertIsInstance(events[0], AggregateCreated)
|
|
224
|
+
self.assertEqual(f"{prefix}MyAggregate1.Created", type(events[0]).__qualname__)
|
|
225
|
+
|
|
226
|
+
#
|
|
227
|
+
# Do it again using @dataclass (makes no difference)...
|
|
228
|
+
@dataclass # ...this just makes the code completion work in the IDE.
|
|
229
|
+
class MyAggregate2(Aggregate):
|
|
230
|
+
def __init__(self):
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
# Check the init method takes no args (except "self").
|
|
234
|
+
init_params = inspect.signature(MyAggregate2.__init__).parameters
|
|
235
|
+
self.assertEqual(len(init_params), 1)
|
|
236
|
+
self.assertEqual(next(iter(init_params)), "self")
|
|
237
|
+
|
|
238
|
+
#
|
|
239
|
+
# Do it again with custom "created" event.
|
|
240
|
+
@dataclass
|
|
241
|
+
class MyAggregate3(Aggregate):
|
|
242
|
+
class Started(AggregateCreated):
|
|
243
|
+
pass
|
|
244
|
+
|
|
245
|
+
a = MyAggregate3()
|
|
246
|
+
self.assertIsInstance(a.id, UUID)
|
|
247
|
+
self.assertIsInstance(a.version, int)
|
|
248
|
+
self.assertIsInstance(a.created_on, datetime)
|
|
249
|
+
self.assertIsInstance(a.modified_on, datetime)
|
|
250
|
+
|
|
251
|
+
events = a.collect_events()
|
|
252
|
+
self.assertEqual(len(events), 1)
|
|
253
|
+
self.assertIsInstance(events[0], AggregateCreated)
|
|
254
|
+
self.assertEqual(f"{prefix}MyAggregate3.Started", type(events[0]).__qualname__)
|
|
255
|
+
|
|
256
|
+
def test_raises_when_init_with_no_args_called_with_args(self):
|
|
257
|
+
# First, with a normal dataclass, to document the errors.
|
|
258
|
+
@dataclass
|
|
259
|
+
class Data(Aggregate):
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
# Second, with an aggregate class, to replicate same errors.
|
|
263
|
+
@dataclass
|
|
264
|
+
class MyAgg(Aggregate):
|
|
265
|
+
pass
|
|
266
|
+
|
|
267
|
+
def assert_raises(cls):
|
|
268
|
+
method_name = get_method_name(cls.__init__)
|
|
269
|
+
|
|
270
|
+
with self.assertRaises(TypeError) as cm:
|
|
271
|
+
cls(0)
|
|
272
|
+
|
|
273
|
+
self.assertEqual(
|
|
274
|
+
cm.exception.args[0],
|
|
275
|
+
f"{method_name}() takes 1 positional argument but 2 were given",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
with self.assertRaises(TypeError) as cm:
|
|
279
|
+
cls(value=0)
|
|
280
|
+
|
|
281
|
+
self.assertEqual(
|
|
282
|
+
cm.exception.args[0],
|
|
283
|
+
f"{method_name}() got an unexpected keyword argument 'value'",
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
assert_raises(Data)
|
|
287
|
+
assert_raises(MyAgg)
|
|
288
|
+
|
|
289
|
+
def test_init_defined_with_positional_or_keyword_arg(self):
|
|
290
|
+
class MyAgg(Aggregate):
|
|
291
|
+
def __init__(self, value):
|
|
292
|
+
self.value = value
|
|
293
|
+
|
|
294
|
+
a = MyAgg(1)
|
|
295
|
+
self.assertIsInstance(a, MyAgg)
|
|
296
|
+
self.assertEqual(a.value, 1)
|
|
297
|
+
self.assertIsInstance(a, Aggregate)
|
|
298
|
+
self.assertEqual(len(a.pending_events), 1)
|
|
299
|
+
|
|
300
|
+
a = MyAgg(value=1)
|
|
301
|
+
self.assertIsInstance(a, MyAgg)
|
|
302
|
+
self.assertEqual(a.value, 1)
|
|
303
|
+
self.assertIsInstance(a, Aggregate)
|
|
304
|
+
self.assertEqual(len(a.pending_events), 1)
|
|
305
|
+
|
|
306
|
+
def test_init_defined_with_default_keyword_arg(self):
|
|
307
|
+
class MyAgg(Aggregate):
|
|
308
|
+
def __init__(self, value=0):
|
|
309
|
+
self.value = value
|
|
310
|
+
|
|
311
|
+
a = MyAgg()
|
|
312
|
+
self.assertIsInstance(a, MyAgg)
|
|
313
|
+
self.assertEqual(a.value, 0)
|
|
314
|
+
self.assertIsInstance(a, Aggregate)
|
|
315
|
+
self.assertEqual(len(a.pending_events), 1)
|
|
316
|
+
|
|
317
|
+
def test_init_with_default_keyword_arg_required_positional_and_keyword_only(self):
|
|
318
|
+
class MyAgg(Aggregate):
|
|
319
|
+
def __init__(self, a, b=0, *, c):
|
|
320
|
+
self.a = a
|
|
321
|
+
self.b = b
|
|
322
|
+
self.c = c
|
|
323
|
+
|
|
324
|
+
x = MyAgg(1, c=2)
|
|
325
|
+
self.assertEqual(x.a, 1)
|
|
326
|
+
self.assertEqual(x.b, 0)
|
|
327
|
+
self.assertEqual(x.c, 2)
|
|
328
|
+
|
|
329
|
+
def test_raises_when_init_missing_1_required_positional_arg(self):
|
|
330
|
+
class MyAgg(Aggregate):
|
|
331
|
+
def __init__(self, value):
|
|
332
|
+
self.value = value
|
|
333
|
+
|
|
334
|
+
with self.assertRaises(TypeError) as cm:
|
|
335
|
+
MyAgg()
|
|
336
|
+
|
|
337
|
+
self.assertEqual(
|
|
338
|
+
cm.exception.args[0],
|
|
339
|
+
f"{get_method_name(MyAgg.__init__)}() missing 1 required "
|
|
340
|
+
"positional argument: 'value'",
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def test_raises_when_init_missing_1_required_keyword_only_arg(self):
|
|
344
|
+
class MyAgg(Aggregate):
|
|
345
|
+
def __init__(self, *, value):
|
|
346
|
+
self.value = value
|
|
347
|
+
|
|
348
|
+
with self.assertRaises(TypeError) as cm:
|
|
349
|
+
MyAgg()
|
|
350
|
+
|
|
351
|
+
self.assertEqual(
|
|
352
|
+
cm.exception.args[0],
|
|
353
|
+
f"{get_method_name(MyAgg.__init__)}() missing 1 required "
|
|
354
|
+
"keyword-only argument: 'value'",
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def test_raises_when_init_missing_required_positional_and_keyword_only_arg(self):
|
|
358
|
+
class MyAgg(Aggregate):
|
|
359
|
+
def __init__(self, a, *, b):
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
with self.assertRaises(TypeError) as cm:
|
|
363
|
+
MyAgg()
|
|
364
|
+
|
|
365
|
+
method_name = get_method_name(MyAgg.__init__)
|
|
366
|
+
|
|
367
|
+
self.assertEqual(
|
|
368
|
+
cm.exception.args[0],
|
|
369
|
+
f"{method_name}() missing 1 required positional argument: 'a'",
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
class MyAgg(Aggregate):
|
|
373
|
+
def __init__(self, a, b=0, *, c):
|
|
374
|
+
self.a = a
|
|
375
|
+
self.b = b
|
|
376
|
+
self.c = c
|
|
377
|
+
|
|
378
|
+
with self.assertRaises(TypeError) as cm:
|
|
379
|
+
MyAgg(c=2)
|
|
380
|
+
|
|
381
|
+
self.assertEqual(
|
|
382
|
+
cm.exception.args[0],
|
|
383
|
+
f"{method_name}() missing 1 required positional argument: 'a'",
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def test_raises_when_init_missing_2_required_positional_args(self):
|
|
387
|
+
class MyAgg(Aggregate):
|
|
388
|
+
def __init__(self, a, b, *, c):
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
with self.assertRaises(TypeError) as cm:
|
|
392
|
+
MyAgg()
|
|
393
|
+
|
|
394
|
+
method_name = get_method_name(MyAgg.__init__)
|
|
395
|
+
|
|
396
|
+
self.assertEqual(
|
|
397
|
+
cm.exception.args[0],
|
|
398
|
+
f"{method_name}() missing 2 required positional arguments: 'a' and 'b'",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def test_raises_when_init_gets_unexpected_keyword_argument(self):
|
|
402
|
+
class MyAgg(Aggregate):
|
|
403
|
+
def __init__(self, a=1):
|
|
404
|
+
pass
|
|
405
|
+
|
|
406
|
+
with self.assertRaises(TypeError) as cm:
|
|
407
|
+
MyAgg(b=1)
|
|
408
|
+
|
|
409
|
+
method_name = get_method_name(MyAgg.__init__)
|
|
410
|
+
|
|
411
|
+
self.assertEqual(
|
|
412
|
+
cm.exception.args[0],
|
|
413
|
+
f"{method_name}() got an unexpected keyword argument 'b'",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
with self.assertRaises(TypeError) as cm:
|
|
417
|
+
MyAgg(c=1)
|
|
418
|
+
|
|
419
|
+
self.assertEqual(
|
|
420
|
+
cm.exception.args[0],
|
|
421
|
+
f"{method_name}() got an unexpected keyword argument 'c'",
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
with self.assertRaises(TypeError) as cm:
|
|
425
|
+
MyAgg(b=1, c=1)
|
|
426
|
+
|
|
427
|
+
self.assertEqual(
|
|
428
|
+
cm.exception.args[0],
|
|
429
|
+
f"{method_name}() got an unexpected keyword argument 'b'",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def test_init_defined_as_dataclass_no_default(self):
|
|
433
|
+
class MyAgg(Aggregate):
|
|
434
|
+
value: int
|
|
435
|
+
|
|
436
|
+
a = MyAgg(1)
|
|
437
|
+
self.assertIsInstance(a, MyAgg)
|
|
438
|
+
self.assertEqual(a.value, 1)
|
|
439
|
+
self.assertIsInstance(a, Aggregate)
|
|
440
|
+
self.assertEqual(len(a.pending_events), 1)
|
|
441
|
+
|
|
442
|
+
a = MyAgg(value=1)
|
|
443
|
+
self.assertIsInstance(a, MyAgg)
|
|
444
|
+
self.assertEqual(a.value, 1)
|
|
445
|
+
self.assertIsInstance(a, Aggregate)
|
|
446
|
+
self.assertEqual(len(a.pending_events), 1)
|
|
447
|
+
|
|
448
|
+
def test_init_defined_as_dataclass_with_default(self):
|
|
449
|
+
class MyAgg(Aggregate):
|
|
450
|
+
value: int = 0
|
|
451
|
+
|
|
452
|
+
a = MyAgg(1)
|
|
453
|
+
self.assertIsInstance(a, MyAgg)
|
|
454
|
+
self.assertEqual(a.value, 1)
|
|
455
|
+
self.assertIsInstance(a, Aggregate)
|
|
456
|
+
self.assertEqual(len(a.pending_events), 1)
|
|
457
|
+
|
|
458
|
+
a = MyAgg(value=1)
|
|
459
|
+
self.assertIsInstance(a, MyAgg)
|
|
460
|
+
self.assertEqual(a.value, 1)
|
|
461
|
+
self.assertIsInstance(a, Aggregate)
|
|
462
|
+
self.assertEqual(len(a.pending_events), 1)
|
|
463
|
+
|
|
464
|
+
a = MyAgg()
|
|
465
|
+
self.assertIsInstance(a, MyAgg)
|
|
466
|
+
self.assertEqual(a.value, 0)
|
|
467
|
+
self.assertIsInstance(a, Aggregate)
|
|
468
|
+
self.assertEqual(len(a.pending_events), 1)
|
|
469
|
+
|
|
470
|
+
with self.assertRaises(TypeError) as cm:
|
|
471
|
+
MyAgg(wrong=1)
|
|
472
|
+
|
|
473
|
+
method_name = get_method_name(MyAgg.__init__)
|
|
474
|
+
|
|
475
|
+
self.assertEqual(
|
|
476
|
+
f"{method_name}() got an unexpected keyword argument 'wrong'",
|
|
477
|
+
cm.exception.args[0],
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
def test_init_defined_as_dataclass_mixture_of_nondefault_and_default_values(self):
|
|
481
|
+
@dataclass
|
|
482
|
+
class MyAgg(Aggregate):
|
|
483
|
+
a: int
|
|
484
|
+
b: int
|
|
485
|
+
c: int = 1
|
|
486
|
+
d: int = 2
|
|
487
|
+
|
|
488
|
+
# This to check aggregate performs the same behaviour.
|
|
489
|
+
@dataclass
|
|
490
|
+
class Data:
|
|
491
|
+
a: int
|
|
492
|
+
b: int
|
|
493
|
+
c: int = 1
|
|
494
|
+
d: int = 2
|
|
495
|
+
|
|
496
|
+
def test_init(cls):
|
|
497
|
+
obj = cls(b=1, a=2)
|
|
498
|
+
self.assertEqual(obj.a, 2)
|
|
499
|
+
self.assertEqual(obj.b, 1)
|
|
500
|
+
self.assertEqual(obj.c, 1)
|
|
501
|
+
self.assertEqual(obj.d, 2)
|
|
502
|
+
|
|
503
|
+
obj = cls(1, 2, 3, 4)
|
|
504
|
+
self.assertEqual(obj.a, 1)
|
|
505
|
+
self.assertEqual(obj.b, 2)
|
|
506
|
+
self.assertEqual(obj.c, 3)
|
|
507
|
+
self.assertEqual(obj.d, 4)
|
|
508
|
+
|
|
509
|
+
with self.assertRaises(TypeError) as cm:
|
|
510
|
+
obj = cls(1, 2, 3, c=4)
|
|
511
|
+
self.assertEqual(obj.a, 1)
|
|
512
|
+
self.assertEqual(obj.b, 2)
|
|
513
|
+
self.assertEqual(obj.c, 4)
|
|
514
|
+
self.assertEqual(obj.d, 3)
|
|
515
|
+
|
|
516
|
+
method_name = get_method_name(cls.__init__)
|
|
517
|
+
|
|
518
|
+
self.assertEqual(
|
|
519
|
+
f"{method_name}() got multiple values for argument 'c'",
|
|
520
|
+
cm.exception.args[0],
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
with self.assertRaises(TypeError) as cm:
|
|
524
|
+
obj = cls(1, a=2, d=3, c=4)
|
|
525
|
+
self.assertEqual(obj.a, 2)
|
|
526
|
+
self.assertEqual(obj.b, 1)
|
|
527
|
+
self.assertEqual(obj.c, 4)
|
|
528
|
+
self.assertEqual(obj.d, 3)
|
|
529
|
+
|
|
530
|
+
self.assertEqual(
|
|
531
|
+
f"{method_name}() got multiple values for argument 'a'",
|
|
532
|
+
cm.exception.args[0],
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
test_init(Data)
|
|
536
|
+
test_init(MyAgg)
|
|
537
|
+
|
|
538
|
+
def test_raises_when_init_has_variable_positional_params(self):
|
|
539
|
+
with self.assertRaises(TypeError) as cm:
|
|
540
|
+
|
|
541
|
+
class _(Aggregate): # noqa: N801
|
|
542
|
+
def __init__(self, *values):
|
|
543
|
+
pass
|
|
544
|
+
|
|
545
|
+
self.assertEqual(
|
|
546
|
+
cm.exception.args[0], "*values not supported by decorator on __init__()"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def test_raises_when_init_has_variable_keyword_params(self):
|
|
550
|
+
with self.assertRaises(TypeError) as cm:
|
|
551
|
+
|
|
552
|
+
class _(Aggregate): # noqa: N801
|
|
553
|
+
def __init__(self, **values):
|
|
554
|
+
pass
|
|
555
|
+
|
|
556
|
+
self.assertEqual(
|
|
557
|
+
cm.exception.args[0], "**values not supported by decorator on __init__()"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def test_define_custom_create_id_as_uuid5(self):
|
|
561
|
+
class MyAggregate1(Aggregate):
|
|
562
|
+
def __init__(self, name):
|
|
563
|
+
self.name = name
|
|
564
|
+
|
|
565
|
+
@classmethod
|
|
566
|
+
def create_id(cls, name):
|
|
567
|
+
return uuid5(NAMESPACE_URL, f"/names/{name}")
|
|
568
|
+
|
|
569
|
+
a = MyAggregate1("name")
|
|
570
|
+
self.assertEqual(a.name, "name")
|
|
571
|
+
self.assertEqual(a.id, MyAggregate1.create_id("name"))
|
|
572
|
+
|
|
573
|
+
# Do it again with method defined as staticmethod.
|
|
574
|
+
@dataclass
|
|
575
|
+
class MyAggregate2(Aggregate):
|
|
576
|
+
name: str
|
|
577
|
+
|
|
578
|
+
@staticmethod
|
|
579
|
+
def create_id(name):
|
|
580
|
+
return uuid5(NAMESPACE_URL, f"/names/{name}")
|
|
581
|
+
|
|
582
|
+
a = MyAggregate2("name")
|
|
583
|
+
self.assertEqual(a.name, "name")
|
|
584
|
+
self.assertEqual(a.id, MyAggregate2.create_id("name"))
|
|
585
|
+
|
|
586
|
+
def test_raises_type_error_if_create_id_not_staticmethod_or_classmethod(self):
|
|
587
|
+
with self.assertRaises(TypeError):
|
|
588
|
+
|
|
589
|
+
class MyAggregate(Aggregate):
|
|
590
|
+
def create_id(self, myarg):
|
|
591
|
+
pass
|
|
592
|
+
|
|
593
|
+
def test_raises_type_error_if_created_event_class_not_aggregate_created(self):
|
|
594
|
+
with self.assertRaises(TypeError):
|
|
595
|
+
|
|
596
|
+
class MyAggregate(Aggregate):
|
|
597
|
+
_created_event_class = Aggregate.Event
|
|
598
|
+
|
|
599
|
+
def test_refuse_implicit_choice_of_alternative_created_events(self):
|
|
600
|
+
# In case aggregates were created with old Created event,
|
|
601
|
+
# there may need to be several defined. Then, when calling
|
|
602
|
+
# aggregate class, require explicit statement of which to use.
|
|
603
|
+
|
|
604
|
+
# Don't specify created event class.
|
|
605
|
+
class MyAggregate1(Aggregate):
|
|
606
|
+
class Started(AggregateCreated):
|
|
607
|
+
pass
|
|
608
|
+
|
|
609
|
+
class Opened(AggregateCreated):
|
|
610
|
+
pass
|
|
611
|
+
|
|
612
|
+
# This is okay.
|
|
613
|
+
MyAggregate1._create(event_class=MyAggregate1.Started)
|
|
614
|
+
MyAggregate1._create(event_class=MyAggregate1.Opened)
|
|
615
|
+
|
|
616
|
+
with self.assertRaises(TypeError) as cm:
|
|
617
|
+
# This is not okay.
|
|
618
|
+
MyAggregate1()
|
|
619
|
+
|
|
620
|
+
self.assertTrue(
|
|
621
|
+
cm.exception.args[0].startswith(
|
|
622
|
+
"Can't decide which of many "
|
|
623
|
+
'"created" event classes to '
|
|
624
|
+
"use: 'Started', 'Opened'"
|
|
625
|
+
)
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
# Specify created event class using _created_event_class.
|
|
629
|
+
class MyAggregate2(Aggregate):
|
|
630
|
+
class Started(AggregateCreated):
|
|
631
|
+
pass
|
|
632
|
+
|
|
633
|
+
class Opened(AggregateCreated):
|
|
634
|
+
pass
|
|
635
|
+
|
|
636
|
+
_created_event_class = Started
|
|
637
|
+
|
|
638
|
+
# Call class, and expect Started event will be used.
|
|
639
|
+
a = MyAggregate2()
|
|
640
|
+
events = a.collect_events()
|
|
641
|
+
self.assertIsInstance(events[0], MyAggregate2.Started, type(events[0]))
|
|
642
|
+
|
|
643
|
+
# Specify created event class using created_event_name.
|
|
644
|
+
class MyAggregate3(Aggregate, created_event_name="Started"):
|
|
645
|
+
class Started(AggregateCreated):
|
|
646
|
+
pass
|
|
647
|
+
|
|
648
|
+
class Opened(AggregateCreated):
|
|
649
|
+
pass
|
|
650
|
+
|
|
651
|
+
# Call class, and expect Started event will be used.
|
|
652
|
+
a = MyAggregate3()
|
|
653
|
+
events = a.collect_events()
|
|
654
|
+
self.assertIsInstance(events[0], MyAggregate3.Started)
|
|
655
|
+
|
|
656
|
+
def test_refuse_implicit_choice_of_alternative_created_events_on_subclass(self):
|
|
657
|
+
# In case aggregates were created with old Created event,
|
|
658
|
+
# there may need to be several defined. Then, when calling
|
|
659
|
+
# aggregate class, require explicit statement of which to use.
|
|
660
|
+
class MyBaseAggregate(Aggregate, created_event_name="Opened"):
|
|
661
|
+
class Started(AggregateCreated):
|
|
662
|
+
pass
|
|
663
|
+
|
|
664
|
+
class Opened(AggregateCreated):
|
|
665
|
+
pass
|
|
666
|
+
|
|
667
|
+
class MyAggregate1(MyBaseAggregate):
|
|
668
|
+
class Started(AggregateCreated):
|
|
669
|
+
pass
|
|
670
|
+
|
|
671
|
+
class Opened(AggregateCreated):
|
|
672
|
+
pass
|
|
673
|
+
|
|
674
|
+
# This is okay.
|
|
675
|
+
MyAggregate1._create(event_class=MyAggregate1.Started)
|
|
676
|
+
MyAggregate1._create(event_class=MyAggregate1.Opened)
|
|
677
|
+
|
|
678
|
+
with self.assertRaises(TypeError) as cm:
|
|
679
|
+
MyAggregate1() # This is not okay.
|
|
680
|
+
|
|
681
|
+
self.assertTrue(
|
|
682
|
+
cm.exception.args[0].startswith(
|
|
683
|
+
"Can't decide which of many "
|
|
684
|
+
'"created" event classes to '
|
|
685
|
+
"use: 'Started', 'Opened'"
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# Specify created event class using _created_event_class.
|
|
690
|
+
class MyAggregate2(MyBaseAggregate):
|
|
691
|
+
class Started(AggregateCreated):
|
|
692
|
+
pass
|
|
693
|
+
|
|
694
|
+
class Opened(AggregateCreated):
|
|
695
|
+
pass
|
|
696
|
+
|
|
697
|
+
_created_event_class = Started
|
|
698
|
+
|
|
699
|
+
# Call class, and expect Started event will be used.
|
|
700
|
+
a = MyAggregate2()
|
|
701
|
+
events = a.collect_events()
|
|
702
|
+
self.assertIsInstance(events[0], MyAggregate2.Started)
|
|
703
|
+
|
|
704
|
+
def test_uses_defined_created_event_when_given_name_matches(self):
|
|
705
|
+
class Order(Aggregate, created_event_name="Started"):
|
|
706
|
+
def __init__(self, name):
|
|
707
|
+
self.name = name
|
|
708
|
+
self.confirmed_at = None
|
|
709
|
+
self.pickedup_at = None
|
|
710
|
+
|
|
711
|
+
class Created(AggregateCreated):
|
|
712
|
+
name: str
|
|
713
|
+
|
|
714
|
+
class Started(AggregateCreated):
|
|
715
|
+
name: str
|
|
716
|
+
|
|
717
|
+
order = Order("name")
|
|
718
|
+
pending = order.collect_events()
|
|
719
|
+
self.assertEqual(type(pending[0]).__name__, "Started")
|
|
720
|
+
|
|
721
|
+
def test_defines_created_event_when_given_name_does_not_match(self):
|
|
722
|
+
class Order(Aggregate, created_event_name="Started"):
|
|
723
|
+
def __init__(self, name):
|
|
724
|
+
self.name = name
|
|
725
|
+
self.confirmed_at = None
|
|
726
|
+
self.pickedup_at = None
|
|
727
|
+
|
|
728
|
+
class Created(AggregateCreated):
|
|
729
|
+
name: str
|
|
730
|
+
|
|
731
|
+
order = Order("name")
|
|
732
|
+
pending = order.collect_events()
|
|
733
|
+
self.assertEqual(type(pending[0]).__name__, "Started")
|
|
734
|
+
self.assertTrue(isinstance(pending[0], Order.Created))
|
|
735
|
+
|
|
736
|
+
def test_raises_when_given_created_event_name_conflicts_with_created_event_class(
|
|
737
|
+
self,
|
|
738
|
+
):
|
|
739
|
+
with self.assertRaises(TypeError) as cm:
|
|
740
|
+
|
|
741
|
+
class Order(Aggregate, created_event_name="Started"):
|
|
742
|
+
def __init__(self, name):
|
|
743
|
+
self.name = name
|
|
744
|
+
self.confirmed_at = None
|
|
745
|
+
self.pickedup_at = None
|
|
746
|
+
|
|
747
|
+
class Created(AggregateCreated):
|
|
748
|
+
name: str
|
|
749
|
+
|
|
750
|
+
class Started(AggregateCreated):
|
|
751
|
+
name: str
|
|
752
|
+
|
|
753
|
+
_created_event_class = Created
|
|
754
|
+
|
|
755
|
+
self.assertEqual(
|
|
756
|
+
cm.exception.args[0],
|
|
757
|
+
"Can't use both '_created_event_class' and 'created_event_name'",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
def test_define_create_id(self):
|
|
761
|
+
@dataclass
|
|
762
|
+
class Index(Aggregate):
|
|
763
|
+
name: str
|
|
764
|
+
|
|
765
|
+
@staticmethod
|
|
766
|
+
def create_id(name: str):
|
|
767
|
+
return uuid5(NAMESPACE_URL, f"/pages/{name}")
|
|
768
|
+
|
|
769
|
+
index = Index(name="name")
|
|
770
|
+
self.assertEqual(index.name, "name")
|
|
771
|
+
self.assertEqual(index.id, Index.create_id("name"))
|
|
772
|
+
|
|
773
|
+
def test_id_dataclass_style(self):
|
|
774
|
+
@dataclass
|
|
775
|
+
class MyDataclass:
|
|
776
|
+
id: UUID
|
|
777
|
+
name: str
|
|
778
|
+
|
|
779
|
+
@dataclass
|
|
780
|
+
class Index(Aggregate):
|
|
781
|
+
id: UUID
|
|
782
|
+
name: str
|
|
783
|
+
|
|
784
|
+
@staticmethod
|
|
785
|
+
def create_id(name: str):
|
|
786
|
+
return uuid5(NAMESPACE_URL, f"/pages/{name}")
|
|
787
|
+
|
|
788
|
+
def assert_id_dataclass_style(cls):
|
|
789
|
+
with self.assertRaises(TypeError) as cm:
|
|
790
|
+
cls()
|
|
791
|
+
self.assertEqual(
|
|
792
|
+
cm.exception.args[0],
|
|
793
|
+
f"{get_method_name(cls.__init__)}() missing 2 "
|
|
794
|
+
"required positional arguments: 'id' and 'name'",
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
# Just check it works if used properly.
|
|
798
|
+
name = "name"
|
|
799
|
+
index_id = Index.create_id(name)
|
|
800
|
+
obj = cls(name=name, id=index_id)
|
|
801
|
+
self.assertEqual(obj.id, index_id)
|
|
802
|
+
self.assertEqual(obj.id, index_id)
|
|
803
|
+
|
|
804
|
+
assert_id_dataclass_style(MyDataclass)
|
|
805
|
+
assert_id_dataclass_style(Index)
|
|
806
|
+
|
|
807
|
+
def test_init_has_id_explicitly(self):
|
|
808
|
+
class Index(Aggregate):
|
|
809
|
+
def __init__(self, id: UUID, name: str): # noqa: A002
|
|
810
|
+
self._id = id
|
|
811
|
+
self.name = name
|
|
812
|
+
|
|
813
|
+
@staticmethod
|
|
814
|
+
def create_id(name: str):
|
|
815
|
+
return uuid5(NAMESPACE_URL, f"/pages/{name}")
|
|
816
|
+
|
|
817
|
+
name = "name"
|
|
818
|
+
index_id = Index.create_id(name)
|
|
819
|
+
index = Index(name=name, id=index_id)
|
|
820
|
+
self.assertEqual(index.id, index_id)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class TestSubsequentEvents(TestCase):
|
|
824
|
+
def test_trigger_event(self):
|
|
825
|
+
a = Aggregate()
|
|
826
|
+
|
|
827
|
+
# Check the aggregate can trigger further events.
|
|
828
|
+
a.trigger_event(AggregateEvent)
|
|
829
|
+
self.assertLess(a.created_on, a.modified_on)
|
|
830
|
+
|
|
831
|
+
pending = a.collect_events()
|
|
832
|
+
self.assertEqual(len(pending), 2)
|
|
833
|
+
self.assertIsInstance(pending[0], AggregateCreated)
|
|
834
|
+
self.assertEqual(pending[0].originator_version, 1)
|
|
835
|
+
self.assertIsInstance(pending[1], AggregateEvent)
|
|
836
|
+
self.assertEqual(pending[1].originator_version, 2)
|
|
837
|
+
|
|
838
|
+
def test_event_mutate_raises_originator_version_error(self):
|
|
839
|
+
a = Aggregate()
|
|
840
|
+
|
|
841
|
+
# Try to mutate aggregate with an invalid domain event.
|
|
842
|
+
event = AggregateEvent(
|
|
843
|
+
originator_id=a.id,
|
|
844
|
+
originator_version=a.version, # NB not +1.
|
|
845
|
+
timestamp=AggregateEvent.create_timestamp(),
|
|
846
|
+
)
|
|
847
|
+
# Check raises "VersionError".
|
|
848
|
+
with self.assertRaises(OriginatorVersionError):
|
|
849
|
+
event.mutate(a)
|
|
850
|
+
|
|
851
|
+
def test_event_mutate_raises_originator_id_error(self):
|
|
852
|
+
a = Aggregate()
|
|
853
|
+
|
|
854
|
+
# Try to mutate aggregate with an invalid domain event.
|
|
855
|
+
event = AggregateEvent(
|
|
856
|
+
originator_id=uuid4(),
|
|
857
|
+
originator_version=a.version + 1,
|
|
858
|
+
timestamp=AggregateEvent.create_timestamp(),
|
|
859
|
+
)
|
|
860
|
+
# Check raises "VersionError".
|
|
861
|
+
with self.assertRaises(OriginatorIDError):
|
|
862
|
+
event.mutate(a)
|
|
863
|
+
|
|
864
|
+
def test_raises_when_triggering_event_with_mismatched_args(self):
|
|
865
|
+
class MyAgg(Aggregate):
|
|
866
|
+
@classmethod
|
|
867
|
+
def create(cls):
|
|
868
|
+
return cls._create(event_class=cls.Created, id=uuid4())
|
|
869
|
+
|
|
870
|
+
class ValueUpdated(AggregateEvent):
|
|
871
|
+
a: int
|
|
872
|
+
|
|
873
|
+
a = MyAgg.create()
|
|
874
|
+
|
|
875
|
+
with self.assertRaises(TypeError) as cm:
|
|
876
|
+
a.trigger_event(MyAgg.ValueUpdated)
|
|
877
|
+
self.assertTrue(
|
|
878
|
+
cm.exception.args[0].startswith("Can't construct event"),
|
|
879
|
+
cm.exception.args[0],
|
|
880
|
+
)
|
|
881
|
+
self.assertTrue(
|
|
882
|
+
cm.exception.args[0].endswith(
|
|
883
|
+
"__init__() missing 1 required positional argument: 'a'"
|
|
884
|
+
),
|
|
885
|
+
cm.exception.args[0],
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
# def test_raises_when_apply_method_returns_value(self):
|
|
889
|
+
# class MyAgg(Aggregate):
|
|
890
|
+
# class ValueUpdated(AggregateEvent):
|
|
891
|
+
# a: int
|
|
892
|
+
#
|
|
893
|
+
# def apply(self, aggregate: TAggregate) -> None:
|
|
894
|
+
# return 1
|
|
895
|
+
#
|
|
896
|
+
# a = MyAgg()
|
|
897
|
+
# with self.assertRaises(TypeError) as cm:
|
|
898
|
+
# a.trigger_event(MyAgg.ValueUpdated, a=1)
|
|
899
|
+
# msg = str(cm.exception.args[0])
|
|
900
|
+
#
|
|
901
|
+
# self.assertTrue(msg.startswith("Unexpected value returned from "), msg)
|
|
902
|
+
# self.assertTrue(
|
|
903
|
+
# msg.endswith(
|
|
904
|
+
# "MyAgg.ValueUpdated.apply(). Values returned from 'apply' methods are"
|
|
905
|
+
# " discarded."
|
|
906
|
+
# ),
|
|
907
|
+
# msg,
|
|
908
|
+
# )
|
|
909
|
+
|
|
910
|
+
def test_eq(self):
|
|
911
|
+
class MyAggregate1(Aggregate):
|
|
912
|
+
id: UUID
|
|
913
|
+
|
|
914
|
+
id_a = uuid4()
|
|
915
|
+
id_b = uuid4()
|
|
916
|
+
a = MyAggregate1(id=id_a)
|
|
917
|
+
self.assertEqual(a, a)
|
|
918
|
+
|
|
919
|
+
b = MyAggregate1(id=id_b)
|
|
920
|
+
self.assertNotEqual(a, b)
|
|
921
|
+
|
|
922
|
+
c = MyAggregate1(id=id_a)
|
|
923
|
+
self.assertNotEqual(a, c)
|
|
924
|
+
|
|
925
|
+
a_copy = a.collect_events()[0].mutate(None)
|
|
926
|
+
self.assertEqual(a, a_copy)
|
|
927
|
+
|
|
928
|
+
# Check the aggregate can trigger further events.
|
|
929
|
+
a.trigger_event(AggregateEvent)
|
|
930
|
+
self.assertNotEqual(a, a_copy)
|
|
931
|
+
a.collect_events()
|
|
932
|
+
self.assertNotEqual(a, a_copy)
|
|
933
|
+
|
|
934
|
+
@dataclass(eq=False)
|
|
935
|
+
class MyAggregate2(Aggregate):
|
|
936
|
+
id: UUID
|
|
937
|
+
|
|
938
|
+
id_a = uuid4()
|
|
939
|
+
id_b = uuid4()
|
|
940
|
+
a = MyAggregate2(id=id_a)
|
|
941
|
+
self.assertEqual(a, a)
|
|
942
|
+
|
|
943
|
+
b = MyAggregate2(id=id_b)
|
|
944
|
+
self.assertNotEqual(a, b)
|
|
945
|
+
|
|
946
|
+
c = MyAggregate2(id=id_a)
|
|
947
|
+
self.assertNotEqual(a, c)
|
|
948
|
+
|
|
949
|
+
a_copy = a.collect_events()[0].mutate(None)
|
|
950
|
+
self.assertEqual(a, a_copy)
|
|
951
|
+
|
|
952
|
+
# Check the aggregate can trigger further events.
|
|
953
|
+
a.trigger_event(AggregateEvent)
|
|
954
|
+
self.assertNotEqual(a, a_copy)
|
|
955
|
+
a.collect_events()
|
|
956
|
+
self.assertNotEqual(a, a_copy)
|
|
957
|
+
|
|
958
|
+
def test_repr_baseclass(self):
|
|
959
|
+
a = Aggregate()
|
|
960
|
+
|
|
961
|
+
expect = (
|
|
962
|
+
f"Aggregate(id={a.id!r}, "
|
|
963
|
+
"version=1, "
|
|
964
|
+
f"created_on={a.created_on!r}, "
|
|
965
|
+
f"modified_on={a.modified_on!r}"
|
|
966
|
+
")"
|
|
967
|
+
)
|
|
968
|
+
self.assertEqual(expect, repr(a))
|
|
969
|
+
|
|
970
|
+
a.trigger_event(AggregateEvent)
|
|
971
|
+
|
|
972
|
+
expect = (
|
|
973
|
+
f"Aggregate(id={a.id!r}, "
|
|
974
|
+
"version=2, "
|
|
975
|
+
f"created_on={a.created_on!r}, "
|
|
976
|
+
f"modified_on={a.modified_on!r}"
|
|
977
|
+
")"
|
|
978
|
+
)
|
|
979
|
+
self.assertEqual(expect, repr(a))
|
|
980
|
+
|
|
981
|
+
def test_repr_subclass(self):
|
|
982
|
+
class MyAggregate1(Aggregate):
|
|
983
|
+
a: int
|
|
984
|
+
|
|
985
|
+
class ValueAssigned(AggregateEvent):
|
|
986
|
+
b: int
|
|
987
|
+
|
|
988
|
+
def apply(self, aggregate: TAggregate) -> None:
|
|
989
|
+
aggregate.b = self.b
|
|
990
|
+
|
|
991
|
+
a = MyAggregate1(a=1)
|
|
992
|
+
expect = (
|
|
993
|
+
f"MyAggregate1(id={a.id!r}, "
|
|
994
|
+
"version=1, "
|
|
995
|
+
f"created_on={a.created_on!r}, "
|
|
996
|
+
f"modified_on={a.modified_on!r}, "
|
|
997
|
+
"a=1"
|
|
998
|
+
")"
|
|
999
|
+
)
|
|
1000
|
+
self.assertEqual(expect, repr(a))
|
|
1001
|
+
|
|
1002
|
+
a.trigger_event(MyAggregate1.ValueAssigned, b=2)
|
|
1003
|
+
|
|
1004
|
+
expect = (
|
|
1005
|
+
f"MyAggregate1(id={a.id!r}, "
|
|
1006
|
+
"version=2, "
|
|
1007
|
+
f"created_on={a.created_on!r}, "
|
|
1008
|
+
f"modified_on={a.modified_on!r}, "
|
|
1009
|
+
"a=1, "
|
|
1010
|
+
"b=2"
|
|
1011
|
+
")"
|
|
1012
|
+
)
|
|
1013
|
+
self.assertEqual(expect, repr(a))
|
|
1014
|
+
|
|
1015
|
+
@dataclass(repr=False)
|
|
1016
|
+
class MyAggregate2(Aggregate):
|
|
1017
|
+
a: int
|
|
1018
|
+
|
|
1019
|
+
class ValueAssigned(AggregateEvent):
|
|
1020
|
+
b: int
|
|
1021
|
+
|
|
1022
|
+
def apply(self, aggregate: TAggregate) -> None:
|
|
1023
|
+
aggregate.b = self.b
|
|
1024
|
+
|
|
1025
|
+
a = MyAggregate2(a=1)
|
|
1026
|
+
expect = (
|
|
1027
|
+
f"MyAggregate2(id={a.id!r}, "
|
|
1028
|
+
"version=1, "
|
|
1029
|
+
f"created_on={a.created_on!r}, "
|
|
1030
|
+
f"modified_on={a.modified_on!r}, "
|
|
1031
|
+
"a=1"
|
|
1032
|
+
")"
|
|
1033
|
+
)
|
|
1034
|
+
self.assertEqual(expect, repr(a))
|
|
1035
|
+
|
|
1036
|
+
a.trigger_event(MyAggregate2.ValueAssigned, b=2)
|
|
1037
|
+
|
|
1038
|
+
expect = (
|
|
1039
|
+
f"MyAggregate2(id={a.id!r}, "
|
|
1040
|
+
"version=2, "
|
|
1041
|
+
f"created_on={a.created_on!r}, "
|
|
1042
|
+
f"modified_on={a.modified_on!r}, "
|
|
1043
|
+
"a=1, "
|
|
1044
|
+
"b=2"
|
|
1045
|
+
")"
|
|
1046
|
+
)
|
|
1047
|
+
self.assertEqual(expect, repr(a))
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
class TestAggregateEventsAreSubclassed(TestCase):
|
|
1051
|
+
def test_base_event_class_is_defined_if_missing(self):
|
|
1052
|
+
class MyAggregate(Aggregate):
|
|
1053
|
+
pass
|
|
1054
|
+
|
|
1055
|
+
self.assertTrue(MyAggregate.Event.__qualname__.endswith("MyAggregate.Event"))
|
|
1056
|
+
self.assertTrue(issubclass(MyAggregate.Event, Aggregate.Event))
|
|
1057
|
+
self.assertNotEqual(MyAggregate.Event, Aggregate.Event)
|
|
1058
|
+
|
|
1059
|
+
def test_base_event_class_is_not_redefined_if_exists(self):
|
|
1060
|
+
class MyAggregate(Aggregate):
|
|
1061
|
+
class Event(Aggregate.Event):
|
|
1062
|
+
pass
|
|
1063
|
+
|
|
1064
|
+
my_event_cls = Event
|
|
1065
|
+
|
|
1066
|
+
self.assertTrue(MyAggregate.Event.__qualname__.endswith("MyAggregate.Event"))
|
|
1067
|
+
self.assertEqual(MyAggregate.my_event_cls, MyAggregate.Event)
|
|
1068
|
+
|
|
1069
|
+
def test_aggregate_events_are_subclassed(self):
|
|
1070
|
+
class MyAggregate(Aggregate):
|
|
1071
|
+
class Created(Aggregate.Created):
|
|
1072
|
+
pass
|
|
1073
|
+
|
|
1074
|
+
class Started(Aggregate.Created):
|
|
1075
|
+
pass
|
|
1076
|
+
|
|
1077
|
+
class Ended(Aggregate.Event):
|
|
1078
|
+
pass
|
|
1079
|
+
|
|
1080
|
+
_created_event_class = Started
|
|
1081
|
+
|
|
1082
|
+
self.assertTrue(MyAggregate.Event.__qualname__.endswith("MyAggregate.Event"))
|
|
1083
|
+
self.assertTrue(issubclass(MyAggregate.Created, MyAggregate.Event))
|
|
1084
|
+
self.assertTrue(issubclass(MyAggregate.Started, MyAggregate.Event))
|
|
1085
|
+
self.assertTrue(issubclass(MyAggregate.Ended, MyAggregate.Event))
|
|
1086
|
+
self.assertEqual(MyAggregate._created_event_class, MyAggregate.Started)
|
|
1087
|
+
|
|
1088
|
+
class MySubclass(MyAggregate):
|
|
1089
|
+
class Opened(MyAggregate.Started):
|
|
1090
|
+
pass
|
|
1091
|
+
|
|
1092
|
+
self.assertTrue(MySubclass.Event.__qualname__.endswith("MySubclass.Event"))
|
|
1093
|
+
self.assertTrue(MySubclass.Created.__qualname__.endswith("MySubclass.Created"))
|
|
1094
|
+
self.assertTrue(
|
|
1095
|
+
MySubclass.Started.__qualname__.endswith("MySubclass.Started"),
|
|
1096
|
+
MySubclass.Started.__qualname__,
|
|
1097
|
+
)
|
|
1098
|
+
self.assertTrue(
|
|
1099
|
+
MySubclass.Ended.__qualname__.endswith("MySubclass.Ended"),
|
|
1100
|
+
MySubclass.Ended.__qualname__,
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
self.assertTrue(
|
|
1104
|
+
MySubclass._created_event_class.__qualname__.endswith("MySubclass.Opened")
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
class MySubSubClass(MySubclass):
|
|
1108
|
+
pass
|
|
1109
|
+
|
|
1110
|
+
self.assertTrue(
|
|
1111
|
+
MySubSubClass._created_event_class.__qualname__.endswith(
|
|
1112
|
+
"MySubSubClass.Opened"
|
|
1113
|
+
)
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
class TestBankAccount(TestCase):
|
|
1118
|
+
def test_subclass_bank_account(self):
|
|
1119
|
+
# Open an account.
|
|
1120
|
+
account: BankAccount = BankAccount.open(
|
|
1121
|
+
full_name="Alice",
|
|
1122
|
+
email_address="alice@example.com",
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
# Check the created_on.
|
|
1126
|
+
self.assertEqual(account.created_on, account.modified_on)
|
|
1127
|
+
|
|
1128
|
+
# Check the initial balance.
|
|
1129
|
+
self.assertEqual(account.balance, 0)
|
|
1130
|
+
|
|
1131
|
+
# Credit the account.
|
|
1132
|
+
account.append_transaction(Decimal("10.00"))
|
|
1133
|
+
|
|
1134
|
+
# Check the modified_on time was updated.
|
|
1135
|
+
assert account.created_on < account.modified_on
|
|
1136
|
+
|
|
1137
|
+
# Check the balance.
|
|
1138
|
+
self.assertEqual(account.balance, Decimal("10.00"))
|
|
1139
|
+
|
|
1140
|
+
# Credit the account again.
|
|
1141
|
+
account.append_transaction(Decimal("10.00"))
|
|
1142
|
+
|
|
1143
|
+
# Check the balance.
|
|
1144
|
+
self.assertEqual(account.balance, Decimal("20.00"))
|
|
1145
|
+
|
|
1146
|
+
# Debit the account.
|
|
1147
|
+
account.append_transaction(Decimal("-15.00"))
|
|
1148
|
+
|
|
1149
|
+
# Check the balance.
|
|
1150
|
+
self.assertEqual(account.balance, Decimal("5.00"))
|
|
1151
|
+
|
|
1152
|
+
# Fail to debit account (insufficient funds).
|
|
1153
|
+
with self.assertRaises(InsufficientFundsError):
|
|
1154
|
+
account.append_transaction(Decimal("-15.00"))
|
|
1155
|
+
|
|
1156
|
+
# Increase the overdraft limit.
|
|
1157
|
+
account.set_overdraft_limit(Decimal("100.00"))
|
|
1158
|
+
|
|
1159
|
+
# Debit the account.
|
|
1160
|
+
account.append_transaction(Decimal("-15.00"))
|
|
1161
|
+
|
|
1162
|
+
# Check the balance.
|
|
1163
|
+
self.assertEqual(account.balance, Decimal("-10.00"))
|
|
1164
|
+
|
|
1165
|
+
# Close the account.
|
|
1166
|
+
account.close()
|
|
1167
|
+
|
|
1168
|
+
# Fail to debit account (account closed).
|
|
1169
|
+
with self.assertRaises(AccountClosedError):
|
|
1170
|
+
account.append_transaction(Decimal("-15.00"))
|
|
1171
|
+
|
|
1172
|
+
# Collect pending events.
|
|
1173
|
+
pending = account.collect_events()
|
|
1174
|
+
self.assertEqual(len(pending), 7)
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
class TestAggregateNotFound(TestCase):
|
|
1178
|
+
def test(self):
|
|
1179
|
+
e = AggregateNotFound()
|
|
1180
|
+
self.assertIsInstance(e, AggregateNotFoundError)
|