1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace Tests\Libraries\Transactions;
7
8use App\Exceptions\ModelNotSavedException;
9use App\Libraries\Transactions\AfterCommit;
10use App\Libraries\Transactions\AfterRollback;
11use App\Libraries\TransactionStateManager;
12use App\Models\Model;
13use DB;
14use Exception;
15use Illuminate\Support\Facades\Schema;
16use Tests\TestCase;
17
18class AfterCommitTest extends TestCase
19{
20 protected $connectionsToTransact = [];
21
22 private $exceptionMessage = 'it should not run afterCommit';
23
24 public function testModelAfterCommitSupportDoesEnlist()
25 {
26 $model = $this->afterCommittable();
27 $model->save();
28
29 $this->assertSame(1, $model->afterCommitCount);
30 }
31
32 public function testModelWithoutAfterCommitSupportDoesNotEnlist()
33 {
34 $model = $this->notAfterCommittable();
35 $model->save();
36
37 $this->assertSame(0, $model->afterCommitCount);
38 }
39
40 public function testModelAfterCommitAfterTransaction()
41 {
42 DB::beginTransaction();
43 DB::commit();
44
45 $model = $this->afterCommittable();
46 $model->save();
47
48 $this->assertSame(1, $model->afterCommitCount);
49 }
50
51 public function testModelAfterCommitTransaction()
52 {
53 // count should increase after transaction completes but not before.
54 $model = $this->afterCommittable();
55
56 DB::transaction(function () use ($model) {
57 $model->save();
58
59 $this->assertSame(1, count($this->getPendingCommits('mysql')));
60 $this->assertSame(0, $model->afterCommitCount);
61 });
62
63 $this->assertSame(0, count($this->getPendingCommits('mysql')));
64 $this->assertSame(1, $model->afterCommitCount);
65 }
66
67 public function testModelAfterCommitTransactionUnrelatedConnection()
68 {
69 // count should increase after save.
70 $model = $this->afterCommittable();
71
72 DB::connection('mysql-store')->transaction(function () use ($model) {
73 $model->save();
74
75 $this->assertFalse($this->getTransactionState('mysql')->isReal());
76 $this->assertCount(0, $this->getPendingCommits(''));
77 $this->assertCount(0, $this->getPendingCommits('mysql-store'));
78 $this->assertSame(1, $model->afterCommitCount);
79 });
80
81 $this->assertSame(1, $model->afterCommitCount);
82 }
83
84 public function testMultipleSaveInTransaction()
85 {
86 $model = $this->afterCommittable();
87
88 DB::transaction(function () use ($model) {
89 $model->save();
90 $model->save();
91
92 $this->assertSame(0, $model->afterCommitCount);
93 $this->assertSame(2, count($this->getPendingCommits('mysql')));
94 $this->assertSame(1, count($this->getPendingUniqueCommits('mysql')));
95 });
96
97 $this->assertSame(0, count($this->getPendingCommits('mysql')));
98 $this->assertSame(1, $model->afterCommitCount);
99 }
100
101 public function testNestedTransactions()
102 {
103 $model = $this->afterCommittable();
104
105 DB::transaction(function () use ($model) {
106 $model->save();
107
108 DB::transaction(function () use ($model) {
109 $model->save();
110 });
111
112 $this->assertSame(0, $model->afterCommitCount);
113 $this->assertSame(2, count($this->getPendingCommits('mysql')));
114 $this->assertSame(1, count($this->getPendingUniqueCommits('mysql')));
115 });
116
117 $this->assertSame(0, count($this->getPendingCommits('mysql')));
118 $this->assertSame(1, $model->afterCommitCount);
119 }
120
121 public function testExceptionThrown()
122 {
123 $model = $this->afterCommittable();
124
125 try {
126 DB::transaction(function () use ($model) {
127 $model->save();
128
129 throw new Exception($this->exceptionMessage);
130 });
131 } catch (Exception $e) {
132 $this->assertSame($this->exceptionMessage, $e->getMessage());
133 }
134
135 $this->assertSame(0, count($this->getPendingCommits('mysql')));
136 $this->assertSame(0, $model->afterCommitCount);
137 }
138
139 public function testExceptionThrownAfterAnotherTransaction()
140 {
141 $model = $this->afterCommittable();
142
143 try {
144 DB::transaction(function () use ($model) {
145 $model->save();
146
147 DB::transaction(function () use ($model) {
148 $model->save();
149 });
150
151 throw new Exception($this->exceptionMessage);
152 });
153 } catch (Exception $e) {
154 $this->assertSame($this->exceptionMessage, $e->getMessage());
155 }
156
157 $this->assertSame(0, count($this->getPendingCommits('mysql')));
158 $this->assertSame(0, $model->afterCommitCount);
159 }
160
161 public function testExceptionThrownInOtherConnection()
162 {
163 $model = $this->afterCommittable();
164
165 try {
166 // After commit only runs if all transactions in scope complete.
167 DB::connection('mysql-store')->transaction(function () use ($model) {
168 DB::transaction(function () use ($model) {
169 $model->save();
170 });
171
172 throw new Exception($this->exceptionMessage);
173 });
174 } catch (Exception $e) {
175 $this->assertSame($this->exceptionMessage, $e->getMessage());
176 }
177
178 $this->assertSame(0, $model->afterCommitCount);
179 }
180
181 public function testRollbackExplictlyCalled()
182 {
183 $model = $this->afterCommittable();
184
185 // Explictly rolling back single transaction level should still allow
186 // after commit to run at the end.
187 // Same behaviour as Rails without enlisting a new transaction.
188 DB::transaction(function () use ($model) {
189 $model->save();
190
191 $this->assertSame(0, $model->afterCommitCount);
192 DB::transaction(function () {
193 DB::rollBack(); // this breaks the tests at the end when running on Travis
194 });
195 });
196
197 $this->assertSame(1, $model->afterCommitCount);
198 }
199
200 public function testSaveOrExplode()
201 {
202 $model = $this->afterCommittable();
203 $model->result = false;
204 $thrown = false;
205
206 try {
207 DB::transaction(function () use ($model) {
208 $model->saveOrExplode();
209 });
210 } catch (ModelNotSavedException $e) {
211 $thrown = true;
212 }
213
214 $this->assertTrue($thrown);
215 $this->assertSame(0, count($this->getPendingCommits('mysql')));
216 $this->assertSame(0, $model->afterCommitCount);
217 $this->assertSame(1, $model->afterRollbackCount);
218 }
219
220 protected function setUp(): void
221 {
222 parent::setUp();
223
224 // not ideal to create the table between every test
225 // but Laravel's resolvers do not work in the
226 // setUpBeforeClass/tearDownAfterClass methods.
227
228 // force cleanup
229 if (Schema::hasTable('test_after_commit')) {
230 Schema::drop('test_after_commit');
231 }
232
233 // create a dummy table
234 Schema::create('test_after_commit', function ($table) {
235 $table->charset = 'utf8mb4';
236 $table->increments('id');
237 $table->timestamp('created_at')->nullable();
238 $table->timestamp('updated_at')->nullable();
239 });
240 }
241
242 protected function tearDown(): void
243 {
244 Schema::drop('test_after_commit');
245
246 parent::tearDown();
247 }
248
249 private function getPendingCommits(string $connection)
250 {
251 $state = $this->getTransactionState($connection);
252
253 return $state ? $this->invokeProperty($state, 'commits') : null;
254 }
255
256 private function getPendingUniqueCommits(string $connection)
257 {
258 $state = $this->getTransactionState($connection);
259
260 return $state ? $this->invokeMethod($state, 'uniqueCommits') : null;
261 }
262
263 private function getTransactionState(string $connection)
264 {
265 return resolve(TransactionStateManager::class)->current($connection);
266 }
267
268 /*
269 Test double helpers.
270 */
271
272 private function notAfterCommittable()
273 {
274 return new class extends Model {
275 public $afterCommitCount = 0;
276
277 protected $connection = 'mysql';
278 protected $table = 'test_after_commit';
279 };
280 }
281
282 private function afterCommittable()
283 {
284 return new class extends Model implements AfterCommit, AfterRollback {
285 public $afterCommitCount = 0;
286 public $afterRollbackCount = 0;
287 public $result = true;
288
289 protected $connection = 'mysql';
290 protected $table = 'test_after_commit';
291
292 public function afterCommit()
293 {
294 $this->afterCommitCount++;
295 }
296
297 public function afterRollback()
298 {
299 $this->afterRollbackCount++;
300 }
301
302 public function save(array $options = [])
303 {
304 return parent::save($options) && $this->result;
305 }
306 };
307 }
308}