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.
Files changed (27) hide show
  1. package/README.md +200 -5
  2. package/android/build.gradle +1 -0
  3. package/android/src/androidTest/java/app/notifee/core/DoScheduledWorkOrderingTest.java +234 -0
  4. package/android/src/androidTest/java/app/notifee/core/RebootRecoveryTest.java +569 -0
  5. package/android/src/androidTest/java/app/notifee/core/database/TimingWorkDataRepository.java +56 -0
  6. package/android/src/androidTest/java/app/notifee/core/database/WorkDataRepositoryRaceTest.java +221 -0
  7. package/android/src/androidTest/res/drawable/test_icon.xml +14 -0
  8. package/android/src/main/java/app/notifee/core/AlarmPermissionBroadcastReceiver.java +17 -6
  9. package/android/src/main/java/app/notifee/core/InitProvider.java +109 -2
  10. package/android/src/main/java/app/notifee/core/NotifeeAlarmManager.java +385 -93
  11. package/android/src/main/java/app/notifee/core/NotificationAlarmReceiver.java +16 -5
  12. package/android/src/main/java/app/notifee/core/NotificationManager.java +153 -96
  13. package/android/src/main/java/app/notifee/core/Preferences.java +9 -0
  14. package/android/src/main/java/app/notifee/core/RebootBroadcastReceiver.java +20 -5
  15. package/android/src/main/java/app/notifee/core/database/WorkDataRepository.java +38 -34
  16. package/android/src/main/kotlin/io/invertase/notifee/HeadlessTask.kt +3 -2
  17. package/android/src/test/java/app/notifee/core/ForegroundServiceTest.java +251 -4
  18. package/android/src/test/java/app/notifee/core/InitProviderBootCheckTest.java +81 -0
  19. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerHandleStaleTest.java +242 -0
  20. package/android/src/test/java/app/notifee/core/NotifeeAlarmManagerSetAlarmClockTest.java +264 -0
  21. package/android/src/test/java/app/notifee/core/database/WorkDataRepositoryFutureContractTest.java +279 -0
  22. package/android/src/test/java/app/notifee/core/model/TimestampTriggerModelTest.java +199 -3
  23. package/android/src/test/java/io/invertase/notifee/HeadlessTaskConfigTest.kt +34 -0
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +1 -1
  27. 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
+ }
@@ -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
+ }