mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
698b7d0713
* 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.
133 lines
5.2 KiB
TypeScript
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);
|
|
});
|
|
});
|