Skip to content

Commit 577f533

Browse files
authored
test(firestore): add unit tests for core internals (#183)
* fix: track backoff completion state in `ExponentialBackoff` * test: add tests for ExponentialBackoff in google_cloud_firestore * test: add unit tests for RateLimiter * test: add unit tests for Firestore Transaction guards and retry logic * test: add tests for empty batch commits, batch resets, and field path validation
1 parent c43ddd4 commit 577f533

File tree

5 files changed

+602
-0
lines changed

5 files changed

+602
-0
lines changed

packages/google_cloud_firestore/lib/src/backoff.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class ExponentialBackoff {
6868
_currentBaseMs = _currentBaseMs.clamp(initialDelayMs, maxDelayMs);
6969
_retryCount += 1;
7070

71+
_awaitingBackoffCompletion = true;
7172
await Future<void>.delayed(Duration(milliseconds: delayWithJitterMs));
7273
_awaitingBackoffCompletion = false;
7374
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// ignore_for_file: invalid_use_of_internal_member
2+
3+
import 'package:google_cloud_firestore/src/backoff.dart';
4+
import 'package:test/test.dart';
5+
6+
void main() {
7+
group('ExponentialBackoff', () {
8+
test('first backoffAndWait() has no delay', () async {
9+
final backoff = ExponentialBackoff(
10+
options: const ExponentialBackoffSetting(
11+
initialDelayMs: 1000,
12+
jitterFactor: 0,
13+
),
14+
);
15+
final before = DateTime.now();
16+
await backoff.backoffAndWait();
17+
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
18+
expect(elapsedMs, lessThan(100));
19+
});
20+
21+
test('respects the initial retry delay on second call', () async {
22+
final backoff = ExponentialBackoff(
23+
options: const ExponentialBackoffSetting(
24+
initialDelayMs: 50,
25+
backoffFactor: 2,
26+
maxDelayMs: 5000,
27+
jitterFactor: 0,
28+
),
29+
);
30+
await backoff.backoffAndWait();
31+
32+
final before = DateTime.now();
33+
await backoff.backoffAndWait();
34+
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
35+
expect(elapsedMs, greaterThanOrEqualTo(50));
36+
});
37+
38+
test('exponentially increases the delay', () async {
39+
final backoff = ExponentialBackoff(
40+
options: const ExponentialBackoffSetting(
41+
initialDelayMs: 10,
42+
backoffFactor: 2,
43+
maxDelayMs: 5000,
44+
jitterFactor: 0,
45+
),
46+
);
47+
await backoff.backoffAndWait();
48+
await backoff.backoffAndWait();
49+
50+
final before = DateTime.now();
51+
await backoff.backoffAndWait();
52+
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
53+
expect(elapsedMs, greaterThanOrEqualTo(20));
54+
});
55+
56+
test('delay increases until maximum then stays capped', () async {
57+
final backoff = ExponentialBackoff(
58+
options: const ExponentialBackoffSetting(
59+
initialDelayMs: 10,
60+
backoffFactor: 2,
61+
maxDelayMs: 35,
62+
jitterFactor: 0,
63+
),
64+
);
65+
await backoff.backoffAndWait();
66+
await backoff.backoffAndWait();
67+
await backoff.backoffAndWait();
68+
69+
final before = DateTime.now();
70+
await backoff.backoffAndWait();
71+
final elapsed = DateTime.now().difference(before).inMilliseconds;
72+
expect(elapsed, greaterThanOrEqualTo(35));
73+
74+
final before2 = DateTime.now();
75+
await backoff.backoffAndWait();
76+
final elapsed2 = DateTime.now().difference(before2).inMilliseconds;
77+
expect(elapsed2, greaterThanOrEqualTo(35));
78+
});
79+
80+
test('reset() resets delay and retry count to zero', () async {
81+
final backoff = ExponentialBackoff(
82+
options: const ExponentialBackoffSetting(
83+
initialDelayMs: 50,
84+
backoffFactor: 2,
85+
maxDelayMs: 5000,
86+
jitterFactor: 0,
87+
),
88+
);
89+
await backoff.backoffAndWait();
90+
await backoff.backoffAndWait();
91+
92+
backoff.reset();
93+
94+
final before = DateTime.now();
95+
await backoff.backoffAndWait();
96+
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
97+
expect(elapsedMs, lessThan(25));
98+
});
99+
100+
test('resetToMax() causes next delay to use maxDelayMs', () async {
101+
final backoff = ExponentialBackoff(
102+
options: const ExponentialBackoffSetting(
103+
initialDelayMs: 10,
104+
backoffFactor: 2,
105+
maxDelayMs: 50,
106+
jitterFactor: 0,
107+
),
108+
);
109+
await backoff.backoffAndWait();
110+
backoff.resetToMax();
111+
112+
final before = DateTime.now();
113+
await backoff.backoffAndWait();
114+
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
115+
expect(elapsedMs, greaterThanOrEqualTo(50));
116+
});
117+
118+
test('applies jitter within expected variance bounds', () async {
119+
final backoff = ExponentialBackoff(
120+
options: const ExponentialBackoffSetting(
121+
initialDelayMs: 100,
122+
backoffFactor: 1,
123+
maxDelayMs: 100,
124+
jitterFactor: 0.5,
125+
),
126+
);
127+
await backoff.backoffAndWait();
128+
129+
final before = DateTime.now();
130+
await backoff.backoffAndWait();
131+
final elapsedMs = DateTime.now().difference(before).inMilliseconds;
132+
expect(elapsedMs, greaterThanOrEqualTo(50));
133+
expect(elapsedMs, lessThan(200));
134+
});
135+
136+
test(
137+
'tracks retry attempts and throws after maxRetryAttempts+1 calls',
138+
() async {
139+
final backoff = ExponentialBackoff(
140+
options: const ExponentialBackoffSetting(
141+
initialDelayMs: 0,
142+
maxDelayMs: 0,
143+
jitterFactor: 0,
144+
),
145+
);
146+
for (var i = 0; i <= ExponentialBackoff.maxRetryAttempts; i++) {
147+
await backoff.backoffAndWait();
148+
}
149+
await expectLater(
150+
backoff.backoffAndWait(),
151+
throwsA(
152+
isA<Exception>().having(
153+
(e) => e.toString(),
154+
'message',
155+
contains('Exceeded maximum number of retries'),
156+
),
157+
),
158+
);
159+
},
160+
);
161+
162+
test('reset() clears retry count so attempts can start fresh', () async {
163+
final backoff = ExponentialBackoff(
164+
options: const ExponentialBackoffSetting(
165+
initialDelayMs: 0,
166+
maxDelayMs: 0,
167+
jitterFactor: 0,
168+
),
169+
);
170+
for (var i = 0; i <= ExponentialBackoff.maxRetryAttempts; i++) {
171+
await backoff.backoffAndWait();
172+
}
173+
backoff.reset();
174+
await expectLater(backoff.backoffAndWait(), completes);
175+
});
176+
177+
test('cannot queue two backoffAndWait() calls simultaneously', () async {
178+
final backoff = ExponentialBackoff(
179+
options: const ExponentialBackoffSetting(
180+
initialDelayMs: 50,
181+
backoffFactor: 1,
182+
maxDelayMs: 50,
183+
jitterFactor: 0,
184+
),
185+
);
186+
await backoff.backoffAndWait();
187+
188+
final future1 = backoff.backoffAndWait();
189+
expect(
190+
backoff.backoffAndWait,
191+
throwsA(
192+
isA<Exception>().having(
193+
(e) => e.toString(),
194+
'message',
195+
contains('already in progress'),
196+
),
197+
),
198+
);
199+
await future1;
200+
});
201+
});
202+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// ignore_for_file: invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
2+
3+
import 'package:google_cloud_firestore/src/firestore.dart';
4+
import 'package:test/test.dart';
5+
6+
void main() {
7+
group('RateLimiter', () {
8+
group('accepts and rejects requests based on capacity', () {
9+
test('initial available tokens equal the initial capacity', () {
10+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
11+
expect(limiter.availableTokens, closeTo(500, 1));
12+
});
13+
14+
test('tryMakeRequest succeeds when within available capacity', () {
15+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
16+
expect(limiter.tryMakeRequest(500), isTrue);
17+
});
18+
19+
test('tryMakeRequest deducts tokens from available balance', () {
20+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
21+
limiter.tryMakeRequest(200);
22+
expect(limiter.availableTokens, closeTo(300, 1));
23+
});
24+
25+
test(
26+
'tryMakeRequest returns false when request exceeds available tokens',
27+
() {
28+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
29+
limiter.tryMakeRequest(500);
30+
expect(limiter.tryMakeRequest(1), isFalse);
31+
},
32+
);
33+
});
34+
35+
group('getNextRequestDelayMs()', () {
36+
test('returns 0 when request is exactly equal to available tokens', () {
37+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
38+
expect(limiter.getNextRequestDelayMs(500), 0);
39+
});
40+
41+
test('returns 0 when request is less than available tokens', () {
42+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 1000 * 1000);
43+
expect(limiter.getNextRequestDelayMs(100), 0);
44+
});
45+
46+
test('returns -1 when request exceeds maximum capacity', () {
47+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 500);
48+
expect(limiter.getNextRequestDelayMs(501), -1);
49+
});
50+
});
51+
52+
group('calculateCapacity()', () {
53+
test('maximumCapacity getter returns configured maximum', () {
54+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 10000);
55+
expect(limiter.maximumCapacity, 10000);
56+
});
57+
58+
test('maximumCapacity uses 500/50/5 default BulkWriter limits', () {
59+
final limiter = RateLimiter(500, 1.5, 5 * 60 * 1000, 10000);
60+
expect(limiter.maximumCapacity, 10000);
61+
expect(limiter.availableTokens, closeTo(500, 1));
62+
});
63+
});
64+
});
65+
}

0 commit comments

Comments
 (0)