Add a "Send test notification" button to verify push delivery

Adds POST /api/notifications/test, which delivers a canned test push to every
registered device through the existing relay path (not persisted to the
notifications list), wired into the router and OpenAPI. In Settings, a "Send test
notification" button appears under the push toggle, only when push is enabled on
this device, so the full backend -> relay -> FCM -> APNs -> device path can be
verified on demand without waiting for a real device event.

Backend, API and widget tests added; clippy and dart analyze clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-09 12:42:20 -04:00
co-authored by Claude Opus 4.8
parent c7c5b45ae8
commit 817ec5872c
7 changed files with 147 additions and 1 deletions
+19
View File
@@ -15,6 +15,25 @@ use crate::model::devices::Device;
use crate::model::notifications::{Notification, NotificationType};
pub use delivery::run_delivery;
pub use error::DeliveryError;
/// Send a one-off test push to every registered device through the push relay. Used by the app's
/// "Send test notification" button to verify end-to-end push delivery. Unlike scan-driven
/// notifications it is not persisted to the notifications list, and it is a no-op (still `Ok`) when
/// no devices are registered.
pub async fn send_test_push() -> Result<(), DeliveryError> {
let config = crate::settings::get_settings()
.notifications
.push
.clone()
.unwrap_or_default();
push::send(
&config,
"OOTT test".to_string(),
"Test notification — push is working on this device.".to_string(),
)
.await
}
/// Send notifications for the changes detected during a scan (or a single sighting). Changes are
/// grouped by notification type: a type with exactly one change produces the usual single-device
+2
View File
@@ -60,6 +60,7 @@ pub mod utils;
devices::update,
devices::unregister,
notifications::list,
notifications::send_test,
notifications::read,
notifications::read_without_flagging,
notifications::mark_as_new,
@@ -186,6 +187,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
.route("/api/dhcp_scanner/status", get(dhcp_scanner::status))
.route("/api/snmp_scanner/status", get(snmp_scanner::status))
.route("/api/notifications", get(notifications::list))
.route("/api/notifications/test", post(notifications::send_test))
.route(
"/api/notifications/mark_all_as_old",
post(notifications::mark_all_as_old),
+50 -1
View File
@@ -9,7 +9,7 @@ use axum::{
use log::error;
use crate::{
db,
db, notifications,
model::notifications::{Notification, NotificationListResponse},
web_server::utils,
};
@@ -118,6 +118,29 @@ pub async fn mark_all_as_old() -> impl IntoResponse {
.await
}
#[utoipa::path(
post,
path = "/api/notifications/test",
tag = "notifications",
responses(
(status = 200, description = "Test notification dispatched to the push relay"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn send_test() -> impl IntoResponse {
match notifications::send_test_push().await {
Ok(()) => (StatusCode::OK, "Test notification sent"),
Err(err) => {
error!("Error sending test push notification: {err}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Error sending test notification, check your logs",
)
}
}
}
#[utoipa::path(
get,
path = "/api/notifications",
@@ -161,3 +184,29 @@ pub async fn list(
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests_common;
#[tokio::test]
async fn send_test_succeeds_with_no_registered_devices() {
tests_common::setup().await;
// Clear any tokens left by other tests so the relay is never contacted (the no-op path),
// keeping this hermetic; actual relay delivery + pruning is covered in the
// notifications::push tests with a mock relay.
let tokens: Vec<String> = db::push_tokens::list()
.unwrap()
.into_iter()
.map(|token| token.token)
.collect();
if !tokens.is_empty() {
db::push_tokens::delete_many(&tokens).unwrap();
}
let response = send_test().await.into_response();
assert_eq!(response.status(), StatusCode::OK);
}
}
+33
View File
@@ -30,6 +30,7 @@ class _SettingsState extends State<Settings> {
late final PushService _pushService;
bool _pushEnabled = false;
bool _pushBusy = false;
bool _testBusy = false;
// Whether the backend delivers notifications via push. The per-device push
// toggle only makes sense then, so it stays hidden until this is confirmed.
bool _pushMethodActive = false;
@@ -136,6 +137,27 @@ class _SettingsState extends State<Settings> {
}
}
// Ask the backend to deliver a test push to every registered device, so the
// user can confirm push is working end to end without waiting for a real
// device event.
Future<void> _sendTestNotification() async {
setState(() => _testBusy = true);
try {
await BackendAPI.instance.sendTestNotification();
if (!mounted) return;
UISnackbars.showSuccess(
context,
'Test notification sent. It should arrive shortly.',
);
} catch (e) {
debugPrint('Failed to send test notification: $e');
if (!mounted) return;
UISnackbars.showError(context, 'Failed to send test notification');
} finally {
if (mounted) setState(() => _testBusy = false);
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -287,6 +309,17 @@ class _SettingsState extends State<Settings> {
value: _pushEnabled,
onChanged: _pushBusy ? null : _togglePush,
),
// Only useful once push is on for this device; sends a test push
// through the full backend → relay → device path.
if (_pushEnabled)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _testBusy ? null : _sendTestNotification,
icon: const Icon(Icons.notifications_active_outlined),
label: const Text('Send test notification'),
),
),
],
],
),
@@ -14,6 +14,12 @@ extension NotificationApi on BackendAPI {
await _dio.post('/notifications/mark_all_as_old');
}
/// Asks the backend to deliver a one-off test push to every registered device,
/// used from Settings to verify end-to-end push delivery.
Future<void> sendTestNotification() async {
await _dio.post('/notifications/test');
}
Future<({List<Notification> items, int totalCount})> listNotifications(
bool? isNew, {
int page = 0,
@@ -36,6 +36,15 @@ void main() {
await BackendAPI.instance.markAllNotificationsAsRead();
});
test('sendTestNotification POSTs to /notifications/test', () async {
adapter.onPost(
'/notifications/test',
(server) => server.reply(200, null),
);
await BackendAPI.instance.sendTestNotification();
});
test(
'listNotifications sends is_new + pagination and reports the total',
() async {
@@ -161,4 +161,32 @@ void main() {
expect(service.disableCalls, 1);
expect(PrefUtil.getValue('push_enabled', false), isFalse);
});
testWidgets('hides the test button when push is off on this device', (
tester,
) async {
stubNotificationMethod('push');
await PrefUtil.setValue('push_enabled', false);
await pumpScreen(tester, Settings(pushService: _FakePushService()));
await pumpUntilFound(tester, find.byType(SwitchListTile));
expect(find.text('Send test notification'), findsNothing);
});
testWidgets('sends a test notification when push is on', (tester) async {
stubNotificationMethod('push');
adapter.onPost(
'/notifications/test',
(server) => server.reply(200, null),
);
await PrefUtil.setValue('push_enabled', true);
await pumpScreen(tester, Settings(pushService: _FakePushService()));
await pumpUntilFound(tester, find.text('Send test notification'));
await tester.tap(find.text('Send test notification'));
await pumpUntilFound(
tester,
find.text('Test notification sent. It should arrive shortly.'),
);
});
}