react-native-notify-kit 9.4.0 → 9.6.0
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.
- package/README.md +200 -5
- package/android/build.gradle +1 -0
- package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
- package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +569 -0
- package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
- package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
- package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
- package/android/src/main/java/app/notifee/core/AlarmPermissionBroadcastReceiver.java +17 -6
- package/android/src/main/java/app/notifee/core/InitProvider.java +109 -2
- package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +385 -93
- package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
- package/android/src/main/java/app/notifee/core/NotificationManager.java +153 -96
- package/android/src/main/java/app/notifee/core/Preferences.java +9 -0
- package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +20 -5
- package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
- package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +3 -2
- package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +251 -4
- package/android/src/test/java/app/notifee/core/InitProviderBootCheckTest.java +81 -0
- package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerHandleStaleTest.java +242 -0
- package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerSetAlarmClockTest.java +264 -0
- package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
- package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +199 -3
- package/android/src/test/java/io/invertase/notifee/HeadlessTaskConfigTest.kt +34 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
package app.notifee.core;
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Copyright (c) 2016-present Invertase Limited & Contributors
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this library except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import static org.junit.Assert.assertEquals;
|
|
20
|
+
import static org.junit.Assert.assertNotNull;
|
|
21
|
+
import static org.mockito.ArgumentMatchers.any;
|
|
22
|
+
import static org.mockito.ArgumentMatchers.anyInt;
|
|
23
|
+
import static org.mockito.ArgumentMatchers.anyLong;
|
|
24
|
+
import static org.mockito.ArgumentMatchers.eq;
|
|
25
|
+
import static org.mockito.Mockito.doThrow;
|
|
26
|
+
import static org.mockito.Mockito.mock;
|
|
27
|
+
import static org.mockito.Mockito.mockStatic;
|
|
28
|
+
import static org.mockito.Mockito.never;
|
|
29
|
+
import static org.mockito.Mockito.times;
|
|
30
|
+
import static org.mockito.Mockito.verify;
|
|
31
|
+
import static org.mockito.Mockito.when;
|
|
32
|
+
|
|
33
|
+
import android.app.AlarmManager;
|
|
34
|
+
import android.app.PendingIntent;
|
|
35
|
+
import android.os.Bundle;
|
|
36
|
+
import app.notifee.core.model.NotificationModel;
|
|
37
|
+
import app.notifee.core.model.TimestampTriggerModel;
|
|
38
|
+
import app.notifee.core.utility.AlarmUtils;
|
|
39
|
+
import org.junit.After;
|
|
40
|
+
import org.junit.Before;
|
|
41
|
+
import org.junit.Test;
|
|
42
|
+
import org.junit.runner.RunWith;
|
|
43
|
+
import org.mockito.ArgumentCaptor;
|
|
44
|
+
import org.mockito.MockedStatic;
|
|
45
|
+
import org.robolectric.RobolectricTestRunner;
|
|
46
|
+
import org.robolectric.RuntimeEnvironment;
|
|
47
|
+
import org.robolectric.annotation.Config;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Unit coverage for the {@link TimestampTriggerModel.AlarmType#SET_ALARM_CLOCK} branch of {@link
|
|
51
|
+
* NotifeeAlarmManager#scheduleTimestampTriggerNotification} and its supporting helper {@link
|
|
52
|
+
* NotifeeAlarmManager#buildShowIntentPressActionBundle}. This branch was added in upstream {@code
|
|
53
|
+
* invertase/notifee#749} (closing {@code #655}) and refactored in this fork to reuse the {@code
|
|
54
|
+
* pressAction} path that the 9.1.19 / 9.3.0 fixes rely on — see the commit that introduces this
|
|
55
|
+
* file for the full rationale.
|
|
56
|
+
*
|
|
57
|
+
* <p>Three scenarios are guarded:
|
|
58
|
+
*
|
|
59
|
+
* <ol>
|
|
60
|
+
* <li><b>Happy path</b> — a non-repeating TIMESTAMP trigger with {@code
|
|
61
|
+
* AlarmType.SET_ALARM_CLOCK} results in exactly one {@link
|
|
62
|
+
* AlarmManager#setAlarmClock(AlarmManager.AlarmClockInfo, PendingIntent)} call, the captured
|
|
63
|
+
* {@link AlarmManager.AlarmClockInfo} carries a non-null show intent, and the inexact
|
|
64
|
+
* fallback path is not exercised.
|
|
65
|
+
* <li><b>SecurityException fallback</b> — if {@code setAlarmClock} throws (Android 12+ can reject
|
|
66
|
+
* even after {@code canScheduleExactAlarms()} returns true, e.g. when the permission is
|
|
67
|
+
* revoked between the pre-check and the schedule call), the catch in {@code
|
|
68
|
+
* scheduleTimestampTriggerNotification} must route to {@code setAndAllowWhileIdle} so the
|
|
69
|
+
* notification still fires as an inexact alarm rather than disappearing silently.
|
|
70
|
+
* <li><b>Show-intent pressAction resolution</b> — a regression guard for the refactor that
|
|
71
|
+
* replaced the ad-hoc {@code getLaunchIntentForPackage} with a call into {@link
|
|
72
|
+
* NotificationPendingIntent#createIntent}. The helper must (a) synthesize the {@code {
|
|
73
|
+
* id:'default', launchActivity:'default' }} bundle when the notification has no {@code
|
|
74
|
+
* pressAction} (typical for triggers rehydrated from Room after an app kill), (b) synthesize
|
|
75
|
+
* the same default when the user opted out via {@link
|
|
76
|
+
* NotificationPendingIntent#PRESS_ACTION_OPT_OUT_ID} (the alarm-clock icon in the status bar
|
|
77
|
+
* has no non-tappable mode, so we still need a valid show intent), and (c) pass a custom
|
|
78
|
+
* pressAction through unchanged so {@code launchActivity} / {@code mainComponent} routing is
|
|
79
|
+
* honoured end-to-end.
|
|
80
|
+
* </ol>
|
|
81
|
+
*
|
|
82
|
+
* <p>Test strategy: {@link MockedStatic} intercepts {@link AlarmUtils#getAlarmManager()} so the
|
|
83
|
+
* production code receives a Mockito-controlled {@link AlarmManager} instead of Robolectric's
|
|
84
|
+
* shadow. This lets the tests stub {@code canScheduleExactAlarms()} to skip the Android S+
|
|
85
|
+
* pre-check, stub {@code setAlarmClock} to either succeed or throw, and capture the {@link
|
|
86
|
+
* AlarmManager.AlarmClockInfo} that reached the platform. Everything else — {@link ContextHolder},
|
|
87
|
+
* {@link NotificationPendingIntent#createIntent}, the Intent/PendingIntent plumbing — runs against
|
|
88
|
+
* Robolectric's real application context.
|
|
89
|
+
*/
|
|
90
|
+
@RunWith(RobolectricTestRunner.class)
|
|
91
|
+
// Robolectric's default SDK is too low for AlarmManager#canScheduleExactAlarms() (API 31+).
|
|
92
|
+
// Pin to API 34 — matches ForegroundServiceTest and exercises the Android S+ pre-check branch
|
|
93
|
+
// that the production code in scheduleTimestampTriggerNotification guards against.
|
|
94
|
+
@Config(sdk = 34)
|
|
95
|
+
public class NotifeeAlarmManagerSetAlarmClockTest {
|
|
96
|
+
|
|
97
|
+
/** A trigger timestamp comfortably in the future so {@code setNextTimestamp} is a no-op. */
|
|
98
|
+
private static final long FUTURE_OFFSET_MS = 60_000L;
|
|
99
|
+
|
|
100
|
+
private MockedStatic<AlarmUtils> alarmUtilsMock;
|
|
101
|
+
private AlarmManager mockAlarmManager;
|
|
102
|
+
|
|
103
|
+
@Before
|
|
104
|
+
public void setUp() {
|
|
105
|
+
// NotificationPendingIntent.createIntent, getAlarmManagerIntentForNotification, and the
|
|
106
|
+
// trigger-rescheduling path all call ContextHolder.getApplicationContext(). Robolectric's
|
|
107
|
+
// application context exposes a real PackageManager so the launch-intent fallback inside
|
|
108
|
+
// NotificationPendingIntent.createLaunchActivityIntent succeeds without mocking.
|
|
109
|
+
ContextHolder.setApplicationContext(RuntimeEnvironment.getApplication());
|
|
110
|
+
|
|
111
|
+
mockAlarmManager = mock(AlarmManager.class);
|
|
112
|
+
// Skip the Android S+ pre-check — we explicitly want the switch to reach the SET_ALARM_CLOCK
|
|
113
|
+
// branch. If canScheduleExactAlarms() returned Mockito's default (false), the pre-check would
|
|
114
|
+
// fall back to setAndAllowWhileIdle before the switch and the tests would never exercise the
|
|
115
|
+
// production path under scrutiny.
|
|
116
|
+
when(mockAlarmManager.canScheduleExactAlarms()).thenReturn(true);
|
|
117
|
+
|
|
118
|
+
alarmUtilsMock = mockStatic(AlarmUtils.class);
|
|
119
|
+
alarmUtilsMock.when(AlarmUtils::getAlarmManager).thenReturn(mockAlarmManager);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@After
|
|
123
|
+
public void tearDown() {
|
|
124
|
+
if (alarmUtilsMock != null) {
|
|
125
|
+
alarmUtilsMock.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Test 1: Happy path — setAlarmClock called with a non-null show intent ──
|
|
130
|
+
|
|
131
|
+
@Test
|
|
132
|
+
public void setAlarmClock_happyPath_usesAlarmClockInfoWithShowIntent() {
|
|
133
|
+
NotificationModel model = buildModel("happy-path");
|
|
134
|
+
TimestampTriggerModel trigger = buildSetAlarmClockTrigger();
|
|
135
|
+
|
|
136
|
+
NotifeeAlarmManager.scheduleTimestampTriggerNotification(model, trigger);
|
|
137
|
+
|
|
138
|
+
ArgumentCaptor<AlarmManager.AlarmClockInfo> infoCaptor =
|
|
139
|
+
ArgumentCaptor.forClass(AlarmManager.AlarmClockInfo.class);
|
|
140
|
+
ArgumentCaptor<PendingIntent> operationCaptor = ArgumentCaptor.forClass(PendingIntent.class);
|
|
141
|
+
verify(mockAlarmManager, times(1))
|
|
142
|
+
.setAlarmClock(infoCaptor.capture(), operationCaptor.capture());
|
|
143
|
+
|
|
144
|
+
AlarmManager.AlarmClockInfo info = infoCaptor.getValue();
|
|
145
|
+
assertNotNull("AlarmClockInfo must be non-null", info);
|
|
146
|
+
assertNotNull(
|
|
147
|
+
"AlarmClockInfo.showIntent must be non-null — AlarmManager requires a tap target for"
|
|
148
|
+
+ " the status-bar alarm-clock icon",
|
|
149
|
+
info.getShowIntent());
|
|
150
|
+
assertNotNull(
|
|
151
|
+
"operation PendingIntent (alarm fire target) must be non-null", operationCaptor.getValue());
|
|
152
|
+
|
|
153
|
+
// The happy path must never touch the inexact fallback.
|
|
154
|
+
verify(mockAlarmManager, never())
|
|
155
|
+
.setAndAllowWhileIdle(anyInt(), anyLong(), any(PendingIntent.class));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Test 2: SecurityException → fallback to setAndAllowWhileIdle ──────────
|
|
159
|
+
|
|
160
|
+
@Test
|
|
161
|
+
public void setAlarmClock_securityException_fallsBackToInexact() {
|
|
162
|
+
// canScheduleExactAlarms() can race with permission revocation on Android 12+: the pre-check
|
|
163
|
+
// passes, but the subsequent setAlarmClock call still throws. The production code's
|
|
164
|
+
// try/catch inside scheduleTimestampTriggerNotification must route that throw to
|
|
165
|
+
// setAndAllowWhileIdle so the notification degrades gracefully instead of disappearing.
|
|
166
|
+
doThrow(new SecurityException("synthetic SCHEDULE_EXACT_ALARM denied at fire time"))
|
|
167
|
+
.when(mockAlarmManager)
|
|
168
|
+
.setAlarmClock(any(AlarmManager.AlarmClockInfo.class), any(PendingIntent.class));
|
|
169
|
+
|
|
170
|
+
NotificationModel model = buildModel("security-exception");
|
|
171
|
+
TimestampTriggerModel trigger = buildSetAlarmClockTrigger();
|
|
172
|
+
|
|
173
|
+
NotifeeAlarmManager.scheduleTimestampTriggerNotification(model, trigger);
|
|
174
|
+
|
|
175
|
+
// Primary attempt happened.
|
|
176
|
+
verify(mockAlarmManager, times(1))
|
|
177
|
+
.setAlarmClock(any(AlarmManager.AlarmClockInfo.class), any(PendingIntent.class));
|
|
178
|
+
// Fallback fired with RTC_WAKEUP — matches the other AlarmType branches' wake semantics.
|
|
179
|
+
verify(mockAlarmManager, times(1))
|
|
180
|
+
.setAndAllowWhileIdle(eq(AlarmManager.RTC_WAKEUP), anyLong(), any(PendingIntent.class));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Test 3: show-intent reuses the pressAction path (refactor guard) ─────
|
|
184
|
+
|
|
185
|
+
@Test
|
|
186
|
+
public void setAlarmClock_showIntentReusesPressActionPath() {
|
|
187
|
+
// Case 1: notification has no pressAction at all (e.g. rehydrated from Room after app kill).
|
|
188
|
+
// buildShowIntentPressActionBundle must synthesize the default so the status-bar icon opens
|
|
189
|
+
// the app via the same route as a normal tap.
|
|
190
|
+
Bundle absent = NotifeeAlarmManager.buildShowIntentPressActionBundle(buildModel("absent"));
|
|
191
|
+
assertNotNull("default pressAction must be synthesized when absent", absent);
|
|
192
|
+
assertEquals("default", absent.getString("id"));
|
|
193
|
+
assertEquals("default", absent.getString("launchActivity"));
|
|
194
|
+
|
|
195
|
+
// Case 2: notification was built with pressAction:null in JS, which surfaces in the native
|
|
196
|
+
// layer as PRESS_ACTION_OPT_OUT_ID. Unlike the content intent (where null means "non-tappable
|
|
197
|
+
// notification"), AlarmClockInfo demands a non-null show intent, so the helper must still
|
|
198
|
+
// synthesize the default instead of returning null and crashing the schedule call.
|
|
199
|
+
Bundle optOut =
|
|
200
|
+
NotifeeAlarmManager.buildShowIntentPressActionBundle(
|
|
201
|
+
buildModelWithPressAction(
|
|
202
|
+
"opt-out", NotificationPendingIntent.PRESS_ACTION_OPT_OUT_ID, null));
|
|
203
|
+
assertNotNull("default pressAction must be synthesized on opt-out sentinel", optOut);
|
|
204
|
+
assertEquals("default", optOut.getString("id"));
|
|
205
|
+
assertEquals("default", optOut.getString("launchActivity"));
|
|
206
|
+
|
|
207
|
+
// Case 3: a real custom pressAction must pass through untouched so custom launchActivity
|
|
208
|
+
// routing reaches NotificationPendingIntent.createLaunchActivityIntent.
|
|
209
|
+
Bundle custom =
|
|
210
|
+
NotifeeAlarmManager.buildShowIntentPressActionBundle(
|
|
211
|
+
buildModelWithPressAction("custom", "my-action", "com.example.CustomActivity"));
|
|
212
|
+
assertNotNull("custom pressAction must pass through", custom);
|
|
213
|
+
assertEquals("my-action", custom.getString("id"));
|
|
214
|
+
assertEquals("com.example.CustomActivity", custom.getString("launchActivity"));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── Builders ──────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
private static NotificationModel buildModel(String id) {
|
|
220
|
+
Bundle notificationBundle = new Bundle();
|
|
221
|
+
notificationBundle.putString("id", id);
|
|
222
|
+
notificationBundle.putString("title", "SetAlarmClockTest " + id);
|
|
223
|
+
|
|
224
|
+
Bundle androidBundle = new Bundle();
|
|
225
|
+
androidBundle.putString("channelId", "set-alarm-clock-test-channel");
|
|
226
|
+
notificationBundle.putBundle("android", androidBundle);
|
|
227
|
+
|
|
228
|
+
return NotificationModel.fromBundle(notificationBundle);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private static NotificationModel buildModelWithPressAction(
|
|
232
|
+
String id, String pressActionId, String launchActivity) {
|
|
233
|
+
Bundle notificationBundle = new Bundle();
|
|
234
|
+
notificationBundle.putString("id", id);
|
|
235
|
+
notificationBundle.putString("title", "SetAlarmClockTest " + id);
|
|
236
|
+
|
|
237
|
+
Bundle androidBundle = new Bundle();
|
|
238
|
+
androidBundle.putString("channelId", "set-alarm-clock-test-channel");
|
|
239
|
+
|
|
240
|
+
Bundle pressAction = new Bundle();
|
|
241
|
+
pressAction.putString("id", pressActionId);
|
|
242
|
+
if (launchActivity != null) {
|
|
243
|
+
pressAction.putString("launchActivity", launchActivity);
|
|
244
|
+
}
|
|
245
|
+
androidBundle.putBundle("pressAction", pressAction);
|
|
246
|
+
|
|
247
|
+
notificationBundle.putBundle("android", androidBundle);
|
|
248
|
+
|
|
249
|
+
return NotificationModel.fromBundle(notificationBundle);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private static TimestampTriggerModel buildSetAlarmClockTrigger() {
|
|
253
|
+
Bundle triggerBundle = new Bundle();
|
|
254
|
+
triggerBundle.putInt("type", 0); // TIMESTAMP
|
|
255
|
+
triggerBundle.putLong("timestamp", System.currentTimeMillis() + FUTURE_OFFSET_MS);
|
|
256
|
+
triggerBundle.putInt("repeatFrequency", -1); // non-repeating
|
|
257
|
+
|
|
258
|
+
Bundle alarmManagerBundle = new Bundle();
|
|
259
|
+
alarmManagerBundle.putInt("type", 4); // SET_ALARM_CLOCK (TimestampTriggerModel switch case 4)
|
|
260
|
+
triggerBundle.putBundle("alarmManager", alarmManagerBundle);
|
|
261
|
+
|
|
262
|
+
return TimestampTriggerModel.fromBundle(triggerBundle);
|
|
263
|
+
}
|
|
264
|
+
}
|
package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
package app.notifee.core.database;
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Copyright (c) 2016-present Invertase Limited & Contributors
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this library except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import static org.junit.Assert.assertFalse;
|
|
14
|
+
import static org.junit.Assert.assertNotNull;
|
|
15
|
+
import static org.junit.Assert.assertSame;
|
|
16
|
+
import static org.junit.Assert.assertTrue;
|
|
17
|
+
import static org.junit.Assert.fail;
|
|
18
|
+
import static org.mockito.Mockito.doAnswer;
|
|
19
|
+
import static org.mockito.Mockito.doThrow;
|
|
20
|
+
import static org.mockito.Mockito.mock;
|
|
21
|
+
import static org.mockito.Mockito.verify;
|
|
22
|
+
|
|
23
|
+
import com.google.common.util.concurrent.ListenableFuture;
|
|
24
|
+
import com.google.common.util.concurrent.ListeningExecutorService;
|
|
25
|
+
import com.google.common.util.concurrent.MoreExecutors;
|
|
26
|
+
import java.util.Collections;
|
|
27
|
+
import java.util.concurrent.CountDownLatch;
|
|
28
|
+
import java.util.concurrent.ExecutionException;
|
|
29
|
+
import java.util.concurrent.Executors;
|
|
30
|
+
import java.util.concurrent.TimeUnit;
|
|
31
|
+
import org.junit.After;
|
|
32
|
+
import org.junit.Before;
|
|
33
|
+
import org.junit.Test;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Locks in the post-#549 contract that every {@link WorkDataRepository} mutation method returns a
|
|
37
|
+
* non-null {@link ListenableFuture} that only transitions to done after the underlying DAO call has
|
|
38
|
+
* actually returned — and that DAO exceptions propagate through {@code ExecutionException}.
|
|
39
|
+
*
|
|
40
|
+
* <p>Before the #549 fix, these methods were {@code void} and enqueued their work fire-and-forget
|
|
41
|
+
* on a cached thread pool. Any future refactor that drops the future return type or silently
|
|
42
|
+
* swallows DAO exceptions would be a regression and must fail these tests.
|
|
43
|
+
*/
|
|
44
|
+
public class WorkDataRepositoryFutureContractTest {
|
|
45
|
+
|
|
46
|
+
private WorkDataDao mockDao;
|
|
47
|
+
private ListeningExecutorService executor;
|
|
48
|
+
private WorkDataRepository repo;
|
|
49
|
+
|
|
50
|
+
@Before
|
|
51
|
+
public void setUp() {
|
|
52
|
+
mockDao = mock(WorkDataDao.class);
|
|
53
|
+
// Single-threaded so we can deterministically gate the DAO call with a latch
|
|
54
|
+
// and assert future.isDone() before the DAO has returned.
|
|
55
|
+
executor = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
|
|
56
|
+
repo = new WorkDataRepository(mockDao, executor);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@After
|
|
60
|
+
public void tearDown() {
|
|
61
|
+
executor.shutdownNow();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// -------- insert --------
|
|
65
|
+
|
|
66
|
+
@Test
|
|
67
|
+
public void insert_returnsNonNullFuture() {
|
|
68
|
+
assertNotNull(repo.insert(newEntity("a")));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Test
|
|
72
|
+
public void insert_futureNotDoneUntilDaoReturns() throws Exception {
|
|
73
|
+
CountDownLatch daoEntered = new CountDownLatch(1);
|
|
74
|
+
CountDownLatch daoGate = new CountDownLatch(1);
|
|
75
|
+
WorkDataEntity entity = newEntity("a");
|
|
76
|
+
doAnswer(
|
|
77
|
+
inv -> {
|
|
78
|
+
daoEntered.countDown();
|
|
79
|
+
daoGate.await();
|
|
80
|
+
return null;
|
|
81
|
+
})
|
|
82
|
+
.when(mockDao)
|
|
83
|
+
.insert(entity);
|
|
84
|
+
|
|
85
|
+
ListenableFuture<Void> f = repo.insert(entity);
|
|
86
|
+
assertTrue("DAO should be entered within 1s", daoEntered.await(1, TimeUnit.SECONDS));
|
|
87
|
+
assertFalse("insert future must not be done while DAO is blocked", f.isDone());
|
|
88
|
+
|
|
89
|
+
daoGate.countDown();
|
|
90
|
+
f.get(1, TimeUnit.SECONDS);
|
|
91
|
+
assertTrue(f.isDone());
|
|
92
|
+
verify(mockDao).insert(entity);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Test
|
|
96
|
+
public void insert_propagatesDaoException() throws Exception {
|
|
97
|
+
RuntimeException boom = new RuntimeException("disk full");
|
|
98
|
+
WorkDataEntity entity = newEntity("a");
|
|
99
|
+
doThrow(boom).when(mockDao).insert(entity);
|
|
100
|
+
|
|
101
|
+
ListenableFuture<Void> f = repo.insert(entity);
|
|
102
|
+
try {
|
|
103
|
+
f.get(1, TimeUnit.SECONDS);
|
|
104
|
+
fail("Expected ExecutionException wrapping the DAO failure");
|
|
105
|
+
} catch (ExecutionException e) {
|
|
106
|
+
assertSame(boom, e.getCause());
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// -------- deleteById --------
|
|
111
|
+
|
|
112
|
+
@Test
|
|
113
|
+
public void deleteById_returnsNonNullFuture() {
|
|
114
|
+
assertNotNull(repo.deleteById("a"));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@Test
|
|
118
|
+
public void deleteById_futureNotDoneUntilDaoReturns() throws Exception {
|
|
119
|
+
CountDownLatch daoEntered = new CountDownLatch(1);
|
|
120
|
+
CountDownLatch daoGate = new CountDownLatch(1);
|
|
121
|
+
doAnswer(
|
|
122
|
+
inv -> {
|
|
123
|
+
daoEntered.countDown();
|
|
124
|
+
daoGate.await();
|
|
125
|
+
return null;
|
|
126
|
+
})
|
|
127
|
+
.when(mockDao)
|
|
128
|
+
.deleteById("a");
|
|
129
|
+
|
|
130
|
+
ListenableFuture<Void> f = repo.deleteById("a");
|
|
131
|
+
assertTrue(daoEntered.await(1, TimeUnit.SECONDS));
|
|
132
|
+
assertFalse(f.isDone());
|
|
133
|
+
|
|
134
|
+
daoGate.countDown();
|
|
135
|
+
f.get(1, TimeUnit.SECONDS);
|
|
136
|
+
verify(mockDao).deleteById("a");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@Test
|
|
140
|
+
public void deleteById_propagatesDaoException() throws Exception {
|
|
141
|
+
RuntimeException boom = new RuntimeException("row missing");
|
|
142
|
+
doThrow(boom).when(mockDao).deleteById("a");
|
|
143
|
+
|
|
144
|
+
ListenableFuture<Void> f = repo.deleteById("a");
|
|
145
|
+
try {
|
|
146
|
+
f.get(1, TimeUnit.SECONDS);
|
|
147
|
+
fail("Expected ExecutionException wrapping the DAO failure");
|
|
148
|
+
} catch (ExecutionException e) {
|
|
149
|
+
assertSame(boom, e.getCause());
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// -------- deleteByIds --------
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
public void deleteByIds_futureNotDoneUntilDaoReturns() throws Exception {
|
|
157
|
+
CountDownLatch daoEntered = new CountDownLatch(1);
|
|
158
|
+
CountDownLatch daoGate = new CountDownLatch(1);
|
|
159
|
+
doAnswer(
|
|
160
|
+
inv -> {
|
|
161
|
+
daoEntered.countDown();
|
|
162
|
+
daoGate.await();
|
|
163
|
+
return null;
|
|
164
|
+
})
|
|
165
|
+
.when(mockDao)
|
|
166
|
+
.deleteByIds(Collections.singletonList("a"));
|
|
167
|
+
|
|
168
|
+
ListenableFuture<Void> f = repo.deleteByIds(Collections.singletonList("a"));
|
|
169
|
+
assertTrue(daoEntered.await(1, TimeUnit.SECONDS));
|
|
170
|
+
assertFalse(f.isDone());
|
|
171
|
+
|
|
172
|
+
daoGate.countDown();
|
|
173
|
+
f.get(1, TimeUnit.SECONDS);
|
|
174
|
+
verify(mockDao).deleteByIds(Collections.singletonList("a"));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@Test
|
|
178
|
+
public void deleteByIds_propagatesDaoException() throws Exception {
|
|
179
|
+
RuntimeException boom = new RuntimeException("constraint violation");
|
|
180
|
+
doThrow(boom).when(mockDao).deleteByIds(Collections.singletonList("a"));
|
|
181
|
+
|
|
182
|
+
ListenableFuture<Void> f = repo.deleteByIds(Collections.singletonList("a"));
|
|
183
|
+
try {
|
|
184
|
+
f.get(1, TimeUnit.SECONDS);
|
|
185
|
+
fail("Expected ExecutionException");
|
|
186
|
+
} catch (ExecutionException e) {
|
|
187
|
+
assertSame(boom, e.getCause());
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// -------- deleteAll --------
|
|
192
|
+
|
|
193
|
+
@Test
|
|
194
|
+
public void deleteAll_returnsNonNullFuture() {
|
|
195
|
+
assertNotNull(repo.deleteAll());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@Test
|
|
199
|
+
public void deleteAll_futureNotDoneUntilDaoReturns() throws Exception {
|
|
200
|
+
CountDownLatch daoEntered = new CountDownLatch(1);
|
|
201
|
+
CountDownLatch daoGate = new CountDownLatch(1);
|
|
202
|
+
doAnswer(
|
|
203
|
+
inv -> {
|
|
204
|
+
daoEntered.countDown();
|
|
205
|
+
daoGate.await();
|
|
206
|
+
return null;
|
|
207
|
+
})
|
|
208
|
+
.when(mockDao)
|
|
209
|
+
.deleteAll();
|
|
210
|
+
|
|
211
|
+
ListenableFuture<Void> f = repo.deleteAll();
|
|
212
|
+
assertTrue(daoEntered.await(1, TimeUnit.SECONDS));
|
|
213
|
+
assertFalse(f.isDone());
|
|
214
|
+
|
|
215
|
+
daoGate.countDown();
|
|
216
|
+
f.get(1, TimeUnit.SECONDS);
|
|
217
|
+
verify(mockDao).deleteAll();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@Test
|
|
221
|
+
public void deleteAll_propagatesDaoException() throws Exception {
|
|
222
|
+
RuntimeException boom = new RuntimeException("db locked");
|
|
223
|
+
doThrow(boom).when(mockDao).deleteAll();
|
|
224
|
+
|
|
225
|
+
ListenableFuture<Void> f = repo.deleteAll();
|
|
226
|
+
try {
|
|
227
|
+
f.get(1, TimeUnit.SECONDS);
|
|
228
|
+
fail("Expected ExecutionException");
|
|
229
|
+
} catch (ExecutionException e) {
|
|
230
|
+
assertSame(boom, e.getCause());
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// -------- update --------
|
|
235
|
+
|
|
236
|
+
@Test
|
|
237
|
+
public void update_futureNotDoneUntilDaoReturns() throws Exception {
|
|
238
|
+
CountDownLatch daoEntered = new CountDownLatch(1);
|
|
239
|
+
CountDownLatch daoGate = new CountDownLatch(1);
|
|
240
|
+
WorkDataEntity entity = newEntity("a");
|
|
241
|
+
doAnswer(
|
|
242
|
+
inv -> {
|
|
243
|
+
daoEntered.countDown();
|
|
244
|
+
daoGate.await();
|
|
245
|
+
return null;
|
|
246
|
+
})
|
|
247
|
+
.when(mockDao)
|
|
248
|
+
.update(entity);
|
|
249
|
+
|
|
250
|
+
ListenableFuture<Void> f = repo.update(entity);
|
|
251
|
+
assertTrue(daoEntered.await(1, TimeUnit.SECONDS));
|
|
252
|
+
assertFalse(f.isDone());
|
|
253
|
+
|
|
254
|
+
daoGate.countDown();
|
|
255
|
+
f.get(1, TimeUnit.SECONDS);
|
|
256
|
+
verify(mockDao).update(entity);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@Test
|
|
260
|
+
public void update_propagatesDaoException() throws Exception {
|
|
261
|
+
RuntimeException boom = new RuntimeException("schema mismatch");
|
|
262
|
+
WorkDataEntity entity = newEntity("a");
|
|
263
|
+
doThrow(boom).when(mockDao).update(entity);
|
|
264
|
+
|
|
265
|
+
ListenableFuture<Void> f = repo.update(entity);
|
|
266
|
+
try {
|
|
267
|
+
f.get(1, TimeUnit.SECONDS);
|
|
268
|
+
fail("Expected ExecutionException");
|
|
269
|
+
} catch (ExecutionException e) {
|
|
270
|
+
assertSame(boom, e.getCause());
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// -------- helpers --------
|
|
275
|
+
|
|
276
|
+
private static WorkDataEntity newEntity(String id) {
|
|
277
|
+
return new WorkDataEntity(id, new byte[0], new byte[0], false);
|
|
278
|
+
}
|
|
279
|
+
}
|