Files
sencho/backend/src/__tests__/stack-delete-purges-notifications.test.ts
T
Anso 698b7d0713 fix: purge deleted-stack notifications from panel and ticker (#1674)
* fix: purge deleted-stack notifications from panel and ticker

Stack delete already cascaded scans and drift but left notification_history rows, so the bell and Activity ticker kept showing the deleted stack. Purge those rows in the shared deletion lifecycle, invalidate connected clients, and drop node-scoped in-memory rows immediately.

* fix: target remote notification purge by hub node id

Remote stack-deleted invalidations always reconcile with hub rn.id, and
notification refetch preserves failed node slices instead of wiping them.
2026-07-22 20:00:45 -04:00

133 lines
5.2 KiB
TypeScript

/**
* DELETE /api/stacks/:stackName must purge notification_history for that
* (node_id, stack_name) so the bell panel, Activity ticker, Stack Activity,
* and Networking recent activity no longer show the deleted stack.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ComposeService: typeof import('../services/ComposeService').ComposeService;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let MeshService: typeof import('../services/MeshService').MeshService;
let NotificationService: typeof import('../services/NotificationService').NotificationService;
let adminCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ ComposeService } = await import('../services/ComposeService'));
({ FileSystemService } = await import('../services/FileSystemService'));
({ MeshService } = await import('../services/MeshService'));
({ NotificationService } = await import('../services/NotificationService'));
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
vi.spyOn(FileSystemService.prototype, 'deleteStack').mockResolvedValue(undefined);
vi.spyOn(MeshService.getInstance(), 'optOutStack').mockResolvedValue(undefined);
const raw = DatabaseService.getInstance().getDb();
raw.prepare('DELETE FROM notification_history').run();
});
function seedNotif(
nodeId: number,
opts: { stack_name?: string; message?: string; timestamp?: number },
): void {
DatabaseService.getInstance().addNotificationHistory(nodeId, {
level: 'error',
message: opts.message ?? 'test',
timestamp: opts.timestamp ?? Date.now(),
stack_name: opts.stack_name ?? 'web',
category: 'deploy_failure',
});
}
function stackNamesForNode(nodeId: number): Array<string | null> {
const rows = DatabaseService.getInstance()
.getDb()
.prepare('SELECT stack_name FROM notification_history WHERE node_id = ? ORDER BY id')
.all(nodeId) as Array<{ stack_name: string | null }>;
return rows.map((r) => r.stack_name);
}
describe('DELETE /api/stacks/:stackName purges notifications', () => {
it('removes only the target node/stack rows and emits one invalidation', async () => {
const db = DatabaseService.getInstance();
const nodeA = db.getNodes()[0].id;
const nodeB = db.addNode({
name: 'other-node',
type: 'remote',
api_url: 'http://other:1852',
api_token: 't',
compose_dir: '/tmp',
is_default: false,
});
seedNotif(nodeA, { stack_name: 'web', message: 'a-web' });
seedNotif(nodeA, { stack_name: 'db', message: 'a-db' });
// Unattached (null stack_name) via SQL; addNotificationHistory omits null.
db.getDb()
.prepare(
`INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, category)
VALUES (?, 'info', 'a-unattached', ?, 0, NULL, 'system')`,
)
.run(nodeA, Date.now());
seedNotif(nodeB, { stack_name: 'web', message: 'b-web' });
const broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent');
const res = await request(app).delete('/api/stacks/web').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(stackNamesForNode(nodeA).sort((a, b) => String(a).localeCompare(String(b)))).toEqual([
'db',
null,
]);
expect(stackNamesForNode(nodeB)).toEqual(['web']);
const notifInvalidations = broadcastSpy.mock.calls
.map((c) => c[0])
.filter(
(e) =>
e.type === 'state-invalidate' &&
e.scope === 'notifications' &&
e.action === 'stack-deleted',
);
expect(notifInvalidations).toHaveLength(1);
expect(notifInvalidations[0]).toMatchObject({
type: 'state-invalidate',
scope: 'notifications',
action: 'stack-deleted',
nodeId: nodeA,
stackName: 'web',
});
expect(typeof notifInvalidations[0].ts).toBe('number');
});
it('emits no notification invalidation when delete fails before success', async () => {
vi.spyOn(FileSystemService.prototype, 'deleteStack').mockRejectedValue(new Error('fs boom'));
const broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent');
const db = DatabaseService.getInstance();
const nodeId = db.getNodes()[0].id;
seedNotif(nodeId, { stack_name: 'web' });
const res = await request(app).delete('/api/stacks/web').set('Cookie', adminCookie);
expect(res.status).toBe(500);
expect(stackNamesForNode(nodeId)).toEqual(['web']);
const notifInvalidations = broadcastSpy.mock.calls
.map((c) => c[0])
.filter((e) => e.scope === 'notifications' && e.action === 'stack-deleted');
expect(notifInvalidations).toHaveLength(0);
});
});