Skip to content

Commit 1a8ebc9

Browse files
committed
test: add unit tests for Firestore Transaction guards and retry logic
1 parent 3248aa2 commit 1a8ebc9

File tree

1 file changed

+272
-0
lines changed

1 file changed

+272
-0
lines changed
Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import 'package:google_cloud_firestore/google_cloud_firestore.dart';
2+
import 'package:test/test.dart';
3+
4+
Firestore _makeFirestore() =>
5+
Firestore(settings: const Settings(projectId: 'unit-test-project'));
6+
7+
void main() {
8+
group('Transaction (unit)', () {
9+
late Firestore firestore;
10+
11+
setUp(() {
12+
firestore = _makeFirestore();
13+
});
14+
15+
group('constants', () {
16+
test('defaultMaxTransactionsAttempts is 5', () {
17+
expect(Transaction.defaultMaxTransactionsAttempts, 5);
18+
});
19+
20+
test('readAfterWriteErrorMsg describes ordering constraint', () {
21+
expect(
22+
Transaction.readAfterWriteErrorMsg,
23+
contains('reads to be executed before all writes'),
24+
);
25+
});
26+
27+
test('readOnlyWriteErrorMsg describes read-only restriction', () {
28+
expect(
29+
Transaction.readOnlyWriteErrorMsg,
30+
contains('read-only transactions cannot execute writes'),
31+
);
32+
});
33+
});
34+
35+
group('read-only transaction write guard', () {
36+
late Transaction readOnlyTx;
37+
38+
setUp(() {
39+
readOnlyTx = Transaction(firestore, ReadOnlyTransactionOptions());
40+
});
41+
42+
test('create() throws on read-only transaction', () {
43+
final docRef = firestore.doc('col/doc');
44+
expect(
45+
() => readOnlyTx.create(docRef, {'foo': 'bar'}),
46+
throwsA(
47+
isA<FirestoreException>().having(
48+
(e) => e.message,
49+
'message',
50+
contains('read-only transactions cannot execute writes'),
51+
),
52+
),
53+
);
54+
});
55+
56+
test('set() throws on read-only transaction', () {
57+
final docRef = firestore.doc('col/doc');
58+
expect(
59+
() => readOnlyTx.set(docRef, {'foo': 'bar'}),
60+
throwsA(isA<FirestoreException>()),
61+
);
62+
});
63+
64+
test('update() throws on read-only transaction', () {
65+
final docRef = firestore.doc('col/doc');
66+
expect(
67+
() => readOnlyTx.update(docRef, {'foo': 'bar'}),
68+
throwsA(isA<FirestoreException>()),
69+
);
70+
});
71+
72+
test('delete() throws on read-only transaction', () {
73+
final docRef =
74+
firestore.doc('col/doc') as DocumentReference<Map<String, dynamic>>;
75+
expect(
76+
() => readOnlyTx.delete(docRef),
77+
throwsA(isA<FirestoreException>()),
78+
);
79+
});
80+
});
81+
82+
group('read-after-write guard', () {
83+
test('get() after write throws', () async {
84+
final docRef = firestore.doc('col/doc');
85+
final tx = Transaction(firestore, null);
86+
87+
tx.create(docRef, {'foo': 'bar'});
88+
89+
await expectLater(
90+
tx.get(docRef),
91+
throwsA(
92+
isA<FirestoreException>().having(
93+
(e) => e.message,
94+
'message',
95+
contains('reads to be executed before all writes'),
96+
),
97+
),
98+
);
99+
});
100+
101+
test('getQuery() after write throws', () async {
102+
final docRef = firestore.doc('col/doc');
103+
final query = firestore.collection('col');
104+
final tx = Transaction(firestore, null);
105+
106+
tx.create(docRef, {'foo': 'bar'});
107+
108+
await expectLater(
109+
tx.getQuery(query),
110+
throwsA(isA<FirestoreException>()),
111+
);
112+
});
113+
114+
test('getAll() after write throws', () async {
115+
final docRef = firestore.doc('col/doc');
116+
final tx = Transaction(firestore, null);
117+
118+
tx.create(docRef, {'foo': 'bar'});
119+
120+
await expectLater(
121+
tx.getAll([docRef]),
122+
throwsA(isA<FirestoreException>()),
123+
);
124+
});
125+
});
126+
127+
group('retry logic', () {
128+
final nonRetryableCodes = [
129+
(FirestoreClientErrorCode.notFound, 'notFound'),
130+
(FirestoreClientErrorCode.alreadyExists, 'alreadyExists'),
131+
(FirestoreClientErrorCode.permissionDenied, 'permissionDenied'),
132+
(FirestoreClientErrorCode.failedPrecondition, 'failedPrecondition'),
133+
(FirestoreClientErrorCode.outOfRange, 'outOfRange'),
134+
(FirestoreClientErrorCode.unimplemented, 'unimplemented'),
135+
(FirestoreClientErrorCode.dataLoss, 'dataLoss'),
136+
];
137+
138+
for (final (code, name) in nonRetryableCodes) {
139+
test('does not retry on non-retryable code: $name', () async {
140+
var callCount = 0;
141+
await expectLater(
142+
firestore.runTransaction((_) async {
143+
callCount++;
144+
throw FirestoreException(code, 'non-retryable error');
145+
}),
146+
throwsA(
147+
isA<FirestoreException>().having(
148+
(e) => e.errorCode,
149+
'errorCode',
150+
code,
151+
),
152+
),
153+
);
154+
expect(callCount, 1);
155+
});
156+
}
157+
158+
final retryableCodes = [
159+
(FirestoreClientErrorCode.aborted, 'aborted'),
160+
(FirestoreClientErrorCode.cancelled, 'cancelled'),
161+
(FirestoreClientErrorCode.unknown, 'unknown'),
162+
(FirestoreClientErrorCode.deadlineExceeded, 'deadlineExceeded'),
163+
(FirestoreClientErrorCode.internal, 'internal'),
164+
(FirestoreClientErrorCode.unavailable, 'unavailable'),
165+
(FirestoreClientErrorCode.unauthenticated, 'unauthenticated'),
166+
(FirestoreClientErrorCode.resourceExhausted, 'resourceExhausted'),
167+
];
168+
169+
for (final (code, name) in retryableCodes) {
170+
test('retries on retryable code: $name (maxAttempts=1)', () async {
171+
var callCount = 0;
172+
await expectLater(
173+
firestore.runTransaction((_) async {
174+
callCount++;
175+
throw FirestoreException(code, 'retryable error');
176+
}, transactionOptions: ReadWriteTransactionOptions(maxAttempts: 1)),
177+
throwsA(
178+
isA<FirestoreException>().having(
179+
(e) => e.message,
180+
'message',
181+
contains('max attempts exceeded'),
182+
),
183+
),
184+
);
185+
expect(callCount, 1);
186+
});
187+
}
188+
189+
test(
190+
'INVALID_ARGUMENT with "transaction has expired" is retried',
191+
() async {
192+
var callCount = 0;
193+
await expectLater(
194+
firestore.runTransaction((_) async {
195+
callCount++;
196+
throw FirestoreException(
197+
FirestoreClientErrorCode.invalidArgument,
198+
'The transaction has expired. Please retry.',
199+
);
200+
}, transactionOptions: ReadWriteTransactionOptions(maxAttempts: 1)),
201+
throwsA(
202+
isA<FirestoreException>().having(
203+
(e) => e.message,
204+
'message',
205+
contains('max attempts exceeded'),
206+
),
207+
),
208+
);
209+
expect(callCount, 1);
210+
},
211+
);
212+
213+
test('INVALID_ARGUMENT without expiry message is not retried', () async {
214+
var callCount = 0;
215+
await expectLater(
216+
firestore.runTransaction((_) async {
217+
callCount++;
218+
throw FirestoreException(
219+
FirestoreClientErrorCode.invalidArgument,
220+
'some other invalid argument',
221+
);
222+
}),
223+
throwsA(
224+
isA<FirestoreException>().having(
225+
(e) => e.errorCode,
226+
'errorCode',
227+
FirestoreClientErrorCode.invalidArgument,
228+
),
229+
),
230+
);
231+
expect(callCount, 1);
232+
});
233+
234+
test(
235+
'respects maxAttempts from ReadWriteTransactionOptions',
236+
() async {
237+
var callCount = 0;
238+
await expectLater(
239+
firestore.runTransaction((_) async {
240+
callCount++;
241+
throw FirestoreException(
242+
FirestoreClientErrorCode.aborted,
243+
'test abort',
244+
);
245+
}, transactionOptions: ReadWriteTransactionOptions(maxAttempts: 3)),
246+
throwsA(
247+
isA<FirestoreException>().having(
248+
(e) => e.message,
249+
'message',
250+
contains('max attempts exceeded'),
251+
),
252+
),
253+
);
254+
expect(callCount, 3);
255+
},
256+
timeout: const Timeout(Duration(seconds: 10)),
257+
);
258+
259+
test('user-thrown non-FirestoreException is not retried', () async {
260+
var callCount = 0;
261+
await expectLater(
262+
firestore.runTransaction((_) async {
263+
callCount++;
264+
throw StateError('user error');
265+
}),
266+
throwsA(isA<StateError>()),
267+
);
268+
expect(callCount, 1);
269+
});
270+
});
271+
});
272+
}

0 commit comments

Comments
 (0)