feat(notifications): dedicate AI Failure Detection notification event (#1794)

Split Obico failure-detection dispatch out of the multiplexed
  on_printer_error event onto its own on_ai_failure_detection event so
  users can subscribe to AI alerts without also enabling HMS hardware-
  error pages, and so the discoverable label "AI Failure Detection" is
  what subscribes them rather than the unrelated "Printer Error" toggle.

  New column on notification_providers (default False, branched
  SQLite/Postgres migration), new notification_service.on_ai_failure_detection
  method, new ai_failure_detection template, obico_actions._notify swap.
  Frontend gets a summary badge, a toggle row with description, and ntfy
  priority surfacing. 14 new tests pin the routing + the regression guard
  ("Printer Error" alone must NOT receive AI notifications now). 11 locales
  covered.

  Existing providers keep working: HMS hardware errors continue to ride
  on_printer_error unchanged; users who want spaghetti alerts opt in via
  the new toggle.
This commit is contained in:
maziggy
2026-06-22 08:15:29 +02:00
parent 12b21ce0df
commit 4206d675eb
33 changed files with 579 additions and 23 deletions
@@ -40,6 +40,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
on_print_missing_spool_assignment: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
@@ -336,3 +337,52 @@ describe('AddNotificationModal — stock alert toggles', () => {
void user; // referenced to avoid unused-var lint warning
});
});
describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
it('renders the toggle in the Printer Status section', async () => {
render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
});
it('persists on_ai_failure_detection on save (and does NOT touch on_printer_error)', async () => {
let captured: Record<string, unknown> | null = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
const label = await screen.findByText('AI Failure Detection');
const row = label.closest('div.flex')!;
const toggle = within(row).getByRole('switch');
await user.click(toggle);
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(captured).not.toBeNull();
expect(captured!.on_ai_failure_detection).toBe(true);
// Critical regression guard: don't accidentally flip the legacy multiplexed field.
expect(captured!.on_printer_error).toBe(false);
});
it('AI Failure Detection appears in ntfy priority section when enabled', async () => {
render(
<AddNotificationModal
provider={buildProvider({ on_ai_failure_detection: true })}
onClose={() => undefined}
/>,
);
const priorityHeader = await screen.findByText(/ntfy priority/i);
const priorityRoot = priorityHeader.closest('div')!;
expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
});
});
@@ -57,6 +57,7 @@ const createMockProvider = (
on_print_progress: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
@@ -0,0 +1,128 @@
/**
* Tests for the AI Failure Detection toggle on NotificationProviderCard (#1794).
*
* Before #1794, Obico failure detection rode the multiplexed
* on_printer_error toggle so users couldn't subscribe to one without the
* other. These tests pin the standalone toggle:
* - Summary badge renders when enabled.
* - The toggle row appears in the expanded settings panel.
* - Flipping the toggle PATCHes the correct field.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { server } from '../mocks/server';
import { NotificationProviderCard } from '../../components/NotificationProviderCard';
import type { NotificationProvider } from '../../api/client';
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
return {
id: 1,
name: 'Test Provider',
provider_type: 'ntfy',
enabled: true,
config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
on_print_start: false,
on_print_complete: false,
on_print_failed: false,
on_print_stopped: false,
on_print_progress: false,
on_print_missing_spool_assignment: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
on_ams_temperature_high: false,
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: false,
on_plate_not_empty: false,
on_bed_cooled: false,
on_first_layer_complete: false,
on_queue_job_added: false,
on_queue_job_assigned: false,
on_queue_job_started: false,
on_queue_job_waiting: false,
on_queue_job_skipped: false,
on_queue_job_failed: false,
on_queue_completed: false,
on_stock_reorder_alert: false,
on_stock_break_alert: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
daily_digest_enabled: false,
daily_digest_time: null,
printer_id: null,
last_success: null,
last_error: null,
last_error_at: null,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
...overrides,
};
}
describe('NotificationProviderCard — AI Failure Detection badge', () => {
it('renders the badge when on_ai_failure_detection is true', async () => {
render(
<NotificationProviderCard
provider={buildProvider({ on_ai_failure_detection: true })}
onEdit={vi.fn()}
/>,
);
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
});
it('omits the badge when on_ai_failure_detection is false', async () => {
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await screen.findByText('Test Provider');
expect(screen.queryByText('AI Failure Detection')).not.toBeInTheDocument();
});
});
describe('NotificationProviderCard — AI Failure Detection toggle', () => {
it('renders the toggle in the expanded settings panel', async () => {
const user = userEvent.setup();
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await user.click(await screen.findByText(/event settings/i));
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
});
it('PATCHes on_ai_failure_detection (NOT on_printer_error) when toggled on — #1794 regression guard', async () => {
let captured: Record<string, unknown> | null = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(buildProvider({ on_ai_failure_detection: true }));
}),
);
const user = userEvent.setup();
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await user.click(await screen.findByText(/event settings/i));
// The toggle label "AI Failure Detection" is unique to this row.
const label = await screen.findByText('AI Failure Detection');
const row = label.closest('div.flex')!;
const toggle = within(row).getByRole('switch');
await user.click(toggle);
await waitFor(() => expect(captured).not.toBeNull());
expect(captured).toMatchObject({ on_ai_failure_detection: true });
// Critical: must NOT also flip the legacy multiplexed field.
expect(captured).not.toHaveProperty('on_printer_error');
});
});
@@ -37,6 +37,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
on_print_missing_spool_assignment: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,