mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
c7c5b45ae8
commit
817ec5872c
@@ -15,6 +15,25 @@ use crate::model::devices::Device;
|
|||||||
use crate::model::notifications::{Notification, NotificationType};
|
use crate::model::notifications::{Notification, NotificationType};
|
||||||
|
|
||||||
pub use delivery::run_delivery;
|
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
|
/// 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
|
/// grouped by notification type: a type with exactly one change produces the usual single-device
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub mod utils;
|
|||||||
devices::update,
|
devices::update,
|
||||||
devices::unregister,
|
devices::unregister,
|
||||||
notifications::list,
|
notifications::list,
|
||||||
|
notifications::send_test,
|
||||||
notifications::read,
|
notifications::read,
|
||||||
notifications::read_without_flagging,
|
notifications::read_without_flagging,
|
||||||
notifications::mark_as_new,
|
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/dhcp_scanner/status", get(dhcp_scanner::status))
|
||||||
.route("/api/snmp_scanner/status", get(snmp_scanner::status))
|
.route("/api/snmp_scanner/status", get(snmp_scanner::status))
|
||||||
.route("/api/notifications", get(notifications::list))
|
.route("/api/notifications", get(notifications::list))
|
||||||
|
.route("/api/notifications/test", post(notifications::send_test))
|
||||||
.route(
|
.route(
|
||||||
"/api/notifications/mark_all_as_old",
|
"/api/notifications/mark_all_as_old",
|
||||||
post(notifications::mark_all_as_old),
|
post(notifications::mark_all_as_old),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use axum::{
|
|||||||
use log::error;
|
use log::error;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db,
|
db, notifications,
|
||||||
model::notifications::{Notification, NotificationListResponse},
|
model::notifications::{Notification, NotificationListResponse},
|
||||||
web_server::utils,
|
web_server::utils,
|
||||||
};
|
};
|
||||||
@@ -118,6 +118,29 @@ pub async fn mark_all_as_old() -> impl IntoResponse {
|
|||||||
.await
|
.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(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/notifications",
|
path = "/api/notifications",
|
||||||
@@ -161,3 +184,29 @@ pub async fn list(
|
|||||||
})
|
})
|
||||||
.await
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class _SettingsState extends State<Settings> {
|
|||||||
late final PushService _pushService;
|
late final PushService _pushService;
|
||||||
bool _pushEnabled = false;
|
bool _pushEnabled = false;
|
||||||
bool _pushBusy = false;
|
bool _pushBusy = false;
|
||||||
|
bool _testBusy = false;
|
||||||
// Whether the backend delivers notifications via push. The per-device push
|
// Whether the backend delivers notifications via push. The per-device push
|
||||||
// toggle only makes sense then, so it stays hidden until this is confirmed.
|
// toggle only makes sense then, so it stays hidden until this is confirmed.
|
||||||
bool _pushMethodActive = false;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
@@ -287,6 +309,17 @@ class _SettingsState extends State<Settings> {
|
|||||||
value: _pushEnabled,
|
value: _pushEnabled,
|
||||||
onChanged: _pushBusy ? null : _togglePush,
|
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');
|
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(
|
Future<({List<Notification> items, int totalCount})> listNotifications(
|
||||||
bool? isNew, {
|
bool? isNew, {
|
||||||
int page = 0,
|
int page = 0,
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ void main() {
|
|||||||
await BackendAPI.instance.markAllNotificationsAsRead();
|
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(
|
test(
|
||||||
'listNotifications sends is_new + pagination and reports the total',
|
'listNotifications sends is_new + pagination and reports the total',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -161,4 +161,32 @@ void main() {
|
|||||||
expect(service.disableCalls, 1);
|
expect(service.disableCalls, 1);
|
||||||
expect(PrefUtil.getValue('push_enabled', false), isFalse);
|
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.'),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user