mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
fix(notifications): version mute replica retractions for soft-cleanup restore (#1703)
* fix(notifications): version mute replica retractions for soft-cleanup restore Soft cleanup and authoritative delete shared an unversioned permanent tombstone, so a later hub re-save could not restore scheduled mutes on a remote. Carry hub-authored kind and source_updated_at on replica DELETE, allow recoverable recreate when updated_at is newer, keep permanent deletes fail-closed, and reject stale recoverable DELETEs against newer rows. * fix(notifications): durable mute retractions across mixed-version fleets Gate recoverable replica DELETEs on a new capability, durable-queue failures and incompatible remotes for retry, fan permanent deletes to every known remote, and return applied vs ignored outcomes on replica writes.
This commit is contained in:
@@ -506,7 +506,7 @@ describe('Notification suppression - CRUD', () => {
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(940005);
|
||||
});
|
||||
|
||||
it('replica does not resurrect a rule after it was deleted, even with a newer updated_at', async () => {
|
||||
it('omitted-body replica DELETE is permanent; delayed POST cannot resurrect', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
@@ -538,9 +538,8 @@ describe('Notification suppression - CRUD', () => {
|
||||
expect(del.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(950006)).toBeUndefined();
|
||||
|
||||
// A delayed POST arrives after the DELETE, reordered by the network. Even
|
||||
// though its updated_at is newer than anything the sender ever sent before
|
||||
// the delete, the delete is authoritative: this id must stay gone.
|
||||
// Omitted DELETE body (old hub) fails closed as permanent. A delayed POST
|
||||
// with any updated_at must stay blocked.
|
||||
const delayed = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
@@ -612,21 +611,479 @@ describe('Notification suppression - CRUD', () => {
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 1000 }) });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.outcome).toBe('applied');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(970008)?.schedule).not.toBeNull();
|
||||
|
||||
// This is the worst case the fix protects: a capability-cleanup DELETE
|
||||
// retracts an all-day/scheduled mute from a node that stopped supporting
|
||||
// it. A delayed re-push of the scheduled rule must not undo that cleanup.
|
||||
// Capability-cleanup DELETE is recoverable at the pushed version. A delayed
|
||||
// re-push at the same or older watermark must not undo cleanup.
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/970008')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 1000 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.outcome).toBe('applied');
|
||||
|
||||
const delayed = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 2000 }) });
|
||||
.send({ rule: replicaRule({ updated_at: 1000 }) });
|
||||
expect(delayed.status).toBe(200);
|
||||
expect(delayed.body.outcome).toBe('ignored_recoverable_watermark');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(970008)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(970008)?.kind).toBe(
|
||||
'recoverable',
|
||||
);
|
||||
});
|
||||
|
||||
it('recoverable soft-cleanup allows recreate when hub re-save is newer', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const replicaRule = (overrides: Record<string, unknown>) => ({
|
||||
id: 980009,
|
||||
name: 'replica-soft-cleanup-resave',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: { days: [2], start_minute: 60, end_minute: 120, tz: 'UTC' },
|
||||
created_at: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
// Receiver clock skew must not affect ordering. Sign the JWT under the same
|
||||
// mocked clock so exp verification stays valid.
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(9_000_000_000_000);
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1h' });
|
||||
try {
|
||||
const first = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 1000 }) });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.outcome).toBe('applied');
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/980009')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 1000 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.outcome).toBe('applied');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(980009)).toBeUndefined();
|
||||
|
||||
const tie = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 1000 }) });
|
||||
expect(tie.status).toBe(200);
|
||||
expect(tie.body.outcome).toBe('ignored_recoverable_watermark');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(980009)).toBeUndefined();
|
||||
|
||||
const resave = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ name: 'replica-soft-cleanup-resave-v2', updated_at: 2000 }) });
|
||||
expect(resave.status).toBe(200);
|
||||
expect(resave.body.outcome).toBe('applied');
|
||||
const restored = DatabaseService.getInstance().getNotificationSuppressionRule(980009);
|
||||
expect(restored?.name).toBe('replica-soft-cleanup-resave-v2');
|
||||
expect(restored?.updated_at).toBe(2000);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(980009)).toBeUndefined();
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('permanent DELETE blocks any later POST regardless of updated_at', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/990010')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'permanent', source_updated_at: 50 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.outcome).toBe('applied');
|
||||
|
||||
const post = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 990010,
|
||||
name: 'should-not-return',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
});
|
||||
expect(post.status).toBe(200);
|
||||
expect(post.body.outcome).toBe('ignored_permanent_tombstone');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(990010)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(990010)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
});
|
||||
|
||||
it('stale recoverable DELETE does not remove a newer stored row', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const replicaRule = (overrides: Record<string, unknown>) => ({
|
||||
id: 991011,
|
||||
name: 'v200',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const post = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule: replicaRule({ updated_at: 200 }) });
|
||||
expect(post.status).toBe(200);
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/991011')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 100 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.outcome).toBe('ignored_stale');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(991011)?.updated_at).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(991011)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recoverable DELETE at exact stored version deletes and tombstones', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 992012,
|
||||
name: 'exact-tie-delete',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 150,
|
||||
},
|
||||
});
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/992012')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 150 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(992012)).toBeUndefined();
|
||||
const tomb = DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(992012);
|
||||
expect(tomb?.kind).toBe('recoverable');
|
||||
expect(tomb?.source_updated_at).toBe(150);
|
||||
});
|
||||
|
||||
it('reordered recoverable tombstones keep the max watermark; permanent wins', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/993013')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 200 });
|
||||
await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/993013')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 100 });
|
||||
expect(
|
||||
DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013)?.source_updated_at,
|
||||
).toBe(200);
|
||||
|
||||
await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/993013')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'permanent', source_updated_at: 50 });
|
||||
const tomb = DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013);
|
||||
expect(tomb?.kind).toBe('permanent');
|
||||
expect(tomb?.source_updated_at).toBe(200);
|
||||
|
||||
// Later recoverable cannot weaken permanent.
|
||||
await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/993013')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'recoverable', source_updated_at: 999 });
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(993013)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
});
|
||||
|
||||
it('partial or invalid DELETE body returns 400 without mutation', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 994014,
|
||||
name: 'keep-me',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const cases: object[] = [
|
||||
{ kind: 'recoverable' },
|
||||
{ source_updated_at: 1 },
|
||||
{ kind: 'nope', source_updated_at: 1 },
|
||||
{ kind: 'recoverable', source_updated_at: 1.5 },
|
||||
{ kind: 'recoverable', source_updated_at: -1 },
|
||||
{ kind: 'recoverable', source_updated_at: '1' },
|
||||
{ kind: 'recoverable', source_updated_at: null },
|
||||
{ kind: 'recoverable', source_updated_at: Number.MAX_SAFE_INTEGER + 1 },
|
||||
];
|
||||
for (const body of cases) {
|
||||
const res = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/994014')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send(body);
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(994014)?.name).toBe('keep-me');
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(994014)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('replica POST rejects missing or invalid created_at and updated_at', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const base = {
|
||||
id: 995015,
|
||||
name: 'bad-ts',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
};
|
||||
|
||||
for (const rule of [
|
||||
{ ...base, updated_at: 1 },
|
||||
{ ...base, created_at: 1 },
|
||||
{ ...base, created_at: -1, updated_at: 1 },
|
||||
{ ...base, created_at: 1, updated_at: 1.5 },
|
||||
{ ...base, created_at: 1, updated_at: '1' },
|
||||
]) {
|
||||
const res = await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ rule });
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(995015)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('permanent DELETE removes a newer stored row', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
await request(app)
|
||||
.post('/api/notification-suppression-rules/replica')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
rule: {
|
||||
id: 999019,
|
||||
name: 'newer-row',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
created_at: 1,
|
||||
updated_at: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/999019')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ kind: 'permanent', source_updated_at: 50 });
|
||||
expect(del.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(999019)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999019)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
});
|
||||
|
||||
it('empty JSON DELETE body fails closed as permanent', async () => {
|
||||
const jwt = await import('jsonwebtoken');
|
||||
const { TEST_JWT_SECRET } = await import('./helpers/testConstants');
|
||||
const token = jwt.default.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
|
||||
const del = await request(app)
|
||||
.delete('/api/notification-suppression-rules/replica/999020')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send({});
|
||||
expect(del.status).toBe(200);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999020)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
expect(
|
||||
DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(999020)?.source_updated_at,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('one-arg deleteNotificationSuppressionRule defaults to permanent', () => {
|
||||
DatabaseService.getInstance().upsertNotificationSuppressionRuleReplica({
|
||||
id: 996016,
|
||||
name: 'one-arg',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 5,
|
||||
});
|
||||
DatabaseService.getInstance().deleteNotificationSuppressionRule(996016);
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRule(996016)).toBeUndefined();
|
||||
expect(DatabaseService.getInstance().getNotificationSuppressionRuleTombstone(996016)?.kind).toBe(
|
||||
'permanent',
|
||||
);
|
||||
});
|
||||
|
||||
it('delete rolls back when tombstone upsert fails', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.upsertNotificationSuppressionRuleReplica({
|
||||
id: 997017,
|
||||
name: 'atomic-del',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 7,
|
||||
});
|
||||
const raw = db.getDb();
|
||||
const orig = raw.prepare.bind(raw);
|
||||
const spy = vi.spyOn(raw, 'prepare').mockImplementation(((sql: string) => {
|
||||
if (
|
||||
typeof sql === 'string' &&
|
||||
sql.includes('INSERT INTO notification_suppression_rule_tombstones')
|
||||
) {
|
||||
throw new Error('forced tombstone upsert failure');
|
||||
}
|
||||
return orig(sql);
|
||||
}) as typeof raw.prepare);
|
||||
|
||||
expect(() =>
|
||||
db.deleteNotificationSuppressionRule(997017, {
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 7,
|
||||
}),
|
||||
).toThrow(/forced tombstone upsert failure/);
|
||||
spy.mockRestore();
|
||||
|
||||
expect(db.getNotificationSuppressionRule(997017)?.name).toBe('atomic-del');
|
||||
expect(db.getNotificationSuppressionRuleTombstone(997017)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recoverable recreate rolls back when insert fails after tombstone clear attempt', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.deleteNotificationSuppressionRule(998018, {
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 10,
|
||||
});
|
||||
expect(db.getNotificationSuppressionRuleTombstone(998018)?.kind).toBe('recoverable');
|
||||
|
||||
const raw = db.getDb();
|
||||
const orig = raw.prepare.bind(raw);
|
||||
const spy = vi.spyOn(raw, 'prepare').mockImplementation(((sql: string) => {
|
||||
if (
|
||||
typeof sql === 'string' &&
|
||||
sql.includes('INSERT INTO notification_suppression_rules')
|
||||
) {
|
||||
throw new Error('forced replica insert failure');
|
||||
}
|
||||
return orig(sql);
|
||||
}) as typeof raw.prepare);
|
||||
|
||||
expect(() =>
|
||||
db.upsertNotificationSuppressionRuleReplica({
|
||||
id: 998018,
|
||||
name: 'recreate-fail',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 20,
|
||||
}),
|
||||
).toThrow(/forced replica insert failure/);
|
||||
spy.mockRestore();
|
||||
|
||||
expect(db.getNotificationSuppressionRule(998018)).toBeUndefined();
|
||||
expect(db.getNotificationSuppressionRuleTombstone(998018)?.kind).toBe('recoverable');
|
||||
expect(db.getNotificationSuppressionRuleTombstone(998018)?.source_updated_at).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Fleet sync for suppression rules: node_id normalize, capability gate, stale DELETE.
|
||||
* Fleet sync for suppression rules: node_id normalize, capability gate, stale DELETE,
|
||||
* durable pending retractions, permanent fan-out to all remotes.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
@@ -8,12 +9,18 @@ const mockGetProxyTarget = vi.fn();
|
||||
const mockGetNodes = vi.fn();
|
||||
const mockGetNode = vi.fn();
|
||||
const mockRemoteAdvertises = vi.fn();
|
||||
const mockUpsertPending = vi.fn();
|
||||
const mockDeletePending = vi.fn();
|
||||
const mockListPending = vi.fn();
|
||||
|
||||
vi.mock('../services/DatabaseService', () => ({
|
||||
DatabaseService: {
|
||||
getInstance: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
getNode: mockGetNode,
|
||||
upsertNotificationSuppressionPendingRetraction: mockUpsertPending,
|
||||
deleteNotificationSuppressionPendingRetraction: mockDeletePending,
|
||||
listNotificationSuppressionPendingRetractions: mockListPending,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -46,6 +53,7 @@ import {
|
||||
syncSuppressionRuleToFleet,
|
||||
syncSuppressionRuleUpdateToFleet,
|
||||
replicationTargetIds,
|
||||
flushPendingSuppressionRetractions,
|
||||
} from '../helpers/notificationSuppressionSync';
|
||||
import type { NotificationSuppressionRule } from '../services/DatabaseService';
|
||||
|
||||
@@ -72,6 +80,21 @@ function makeRule(overrides: Partial<NotificationSuppressionRule> = {}): Notific
|
||||
const remoteA = { id: 10, name: 'remote-a', type: 'remote' as const };
|
||||
const remoteB = { id: 11, name: 'remote-b', type: 'remote' as const };
|
||||
|
||||
const RETRACTION_CAP = 'notification-suppression-replica-retraction';
|
||||
|
||||
function metaWith(...caps: string[]) {
|
||||
return { capabilities: caps, online: true };
|
||||
}
|
||||
|
||||
function okDeleteResponse(outcome = 'applied') {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
json: async () => ({ success: true, outcome }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('notificationSuppressionSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -83,8 +106,10 @@ describe('notificationSuppressionSync', () => {
|
||||
apiUrl: `http://node-${id}.example:1852`,
|
||||
apiToken: 'tok',
|
||||
}));
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockFetch.mockResolvedValue(okDeleteResponse());
|
||||
mockRemoteAdvertises.mockResolvedValue(true);
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP, 'notification-suppression-schedule'));
|
||||
mockListPending.mockReturnValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -100,6 +125,7 @@ describe('notificationSuppressionSync', () => {
|
||||
syncSuppressionRuleToFleet(makeRule({ schedule: null, node_id: 10 }));
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(mockRemoteAdvertises).not.toHaveBeenCalled();
|
||||
expect(mockFetchMeta).not.toHaveBeenCalled();
|
||||
const body = JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body);
|
||||
expect(body.rule.node_id).toBeNull();
|
||||
expect(body.rule.schedule).toBeNull();
|
||||
@@ -121,44 +147,99 @@ describe('notificationSuppressionSync', () => {
|
||||
expect(body.rule.schedule.days).toEqual([6]);
|
||||
});
|
||||
|
||||
it('probe false + DELETE success: no POST; cleanup logged as removed', async () => {
|
||||
it('schedule unsupported + retraction supported: recoverable DELETE, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
updated_at: 555,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica was removed'))).toBe(true);
|
||||
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 555,
|
||||
});
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('recoverable DELETE applied'))).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
|
||||
expect(mockUpsertPending).not.toHaveBeenCalled();
|
||||
expect(mockDeletePending).toHaveBeenCalledWith(42, 10);
|
||||
});
|
||||
|
||||
it('probe false + no proxy target: no successful-cleanup claim', async () => {
|
||||
it('schedule unsupported + retraction unsupported: no DELETE; queues pending', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetchMeta.mockResolvedValue(metaWith('notification-suppression'));
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
updated_at: 555,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('queued pending retraction'))).toBe(true);
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rule_id: 42,
|
||||
node_id: 10,
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 555,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('schedule unsupported + probe unreachable (offline meta): no DELETE; queues pending', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetchMeta.mockResolvedValue({ capabilities: [], online: false });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpsertPending.mock.calls[0][0].last_error).toMatch(/unreachable/);
|
||||
});
|
||||
|
||||
it('schedule unsupported + probe unreachable (throw): no DELETE; queues pending', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetchMeta.mockRejectedValue(new Error('timeout'));
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('schedule unsupported + no proxy after supported probe: queues pending, no POST', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
mockGetProxyTarget.mockReturnValue(null);
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica was removed'))).toBe(false);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE success, no POST', async () => {
|
||||
it('scheduleInvalid: DELETE success when retraction supported, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
@@ -166,31 +247,44 @@ describe('notificationSuppressionSync', () => {
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('recoverable DELETE applied'))).toBe(true);
|
||||
});
|
||||
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(false);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE 404 counts as cleanup success, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 404, text: async () => 'gone' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => {
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(true);
|
||||
it('scheduleInvalid: opaque DELETE 404 queues pending (not treated as success)', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
text: async () => 'Not Found',
|
||||
json: async () => {
|
||||
throw new Error('no json');
|
||||
},
|
||||
});
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
expect(mockDeletePending).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scheduleInvalid: DELETE failure logs pending cleanup, no POST', async () => {
|
||||
it('scheduleInvalid: DELETE failure queues pending, no POST', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'down',
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
@@ -199,28 +293,32 @@ describe('notificationSuppressionSync', () => {
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(mockUpsertPending).toHaveBeenCalled();
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduleInvalid: no proxy target logs pending, no POST', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockGetProxyTarget.mockReturnValue(null);
|
||||
it('scheduleInvalid without retraction capability: no DELETE; queues pending', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith());
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
updated_at: 777,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(warn.mock.calls.some((c) => String(c[0]).includes('replica removed'))).toBe(false);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 777,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unscheduled-to-scheduled on unsupported target attempts DELETE', async () => {
|
||||
it('unscheduled-to-scheduled on unsupported schedule target attempts recoverable path', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => '' });
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
const previous = makeRule({ node_id: 10, schedule: null });
|
||||
const updated = makeRule({
|
||||
node_id: 10,
|
||||
@@ -231,22 +329,6 @@ describe('notificationSuppressionSync', () => {
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
});
|
||||
|
||||
it('probe false + DELETE failure: no POST; logs cleanup pending', async () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
mockFetch.mockResolvedValue({ ok: false, status: 503, text: async () => 'down' });
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: { days: [1], start_minute: 0, end_minute: 60, tz: 'UTC' },
|
||||
}));
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalled());
|
||||
|
||||
expect(mockFetch.mock.calls.every((c) => (c[1] as { method: string }).method === 'DELETE')).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('cleanup pending'))).toBe(true);
|
||||
expect(error.mock.calls.some((c) => String(c[0]).includes('rule 42'))).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduled-to-unscheduled POST refresh does not require capability', async () => {
|
||||
mockRemoteAdvertises.mockResolvedValue(false);
|
||||
const previous = makeRule({
|
||||
@@ -261,7 +343,8 @@ describe('notificationSuppressionSync', () => {
|
||||
expect(body.rule.schedule).toBeNull();
|
||||
});
|
||||
|
||||
it('stale targets receive DELETE on scope change', async () => {
|
||||
it('stale targets receive recoverable DELETE when retraction supported', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
const previous = makeRule({ node_id: null, schedule: null });
|
||||
const updated = makeRule({ node_id: 10, schedule: null });
|
||||
syncSuppressionRuleUpdateToFleet(previous, updated);
|
||||
@@ -272,4 +355,147 @@ describe('notificationSuppressionSync', () => {
|
||||
const posts = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'POST');
|
||||
expect(posts.some((c) => String(c[0]).includes('node-10'))).toBe(true);
|
||||
});
|
||||
|
||||
it('stale-target without retraction capability: no DELETE; queues pending', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith());
|
||||
const previous = makeRule({ id: 42, node_id: null, schedule: null, updated_at: 10 });
|
||||
const updated = makeRule({ id: 42, node_id: 10, schedule: null, updated_at: 99 });
|
||||
syncSuppressionRuleUpdateToFleet(previous, updated);
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
|
||||
const deletes = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'DELETE');
|
||||
expect(deletes).toHaveLength(0);
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rule_id: 42,
|
||||
node_id: 11,
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 99,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stale-target DELETE sends recoverable watermark from updated rule', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
const previous = makeRule({ id: 42, node_id: null, schedule: null, updated_at: 10 });
|
||||
const updated = makeRule({ id: 42, node_id: 10, schedule: null, updated_at: 99 });
|
||||
syncSuppressionRuleUpdateToFleet(previous, updated);
|
||||
await vi.waitFor(() => expect(mockFetch.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||
const deletes = mockFetch.mock.calls.filter((c) => (c[1] as { method: string }).method === 'DELETE');
|
||||
const stale = deletes.find((c) => String(c[0]).includes('node-11'));
|
||||
expect(stale).toBeTruthy();
|
||||
expect(JSON.parse((stale![1] as { body: string }).body)).toEqual({
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 99,
|
||||
});
|
||||
});
|
||||
|
||||
it('authoritative fleet delete fans permanent retraction to all remotes', async () => {
|
||||
const { deleteSuppressionRuleFromFleet } = await import('../helpers/notificationSuppressionSync');
|
||||
deleteSuppressionRuleFromFleet(makeRule({ node_id: 10, updated_at: 321 }));
|
||||
await vi.waitFor(() => expect(mockFetch.mock.calls.length).toBe(2));
|
||||
|
||||
for (const call of mockFetch.mock.calls) {
|
||||
const [, init] = call as [string, { method: string; body: string }];
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(JSON.parse(init.body)).toEqual({ kind: 'permanent', source_updated_at: 321 });
|
||||
}
|
||||
expect(mockFetch.mock.calls.some((c) => String(c[0]).includes('node-10'))).toBe(true);
|
||||
expect(mockFetch.mock.calls.some((c) => String(c[0]).includes('node-11'))).toBe(true);
|
||||
});
|
||||
|
||||
it('authoritative delete transport failure queues pending permanent row', async () => {
|
||||
const { deleteSuppressionRuleFromFleet } = await import('../helpers/notificationSuppressionSync');
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
text: async () => 'down',
|
||||
json: async () => ({}),
|
||||
});
|
||||
deleteSuppressionRuleFromFleet(makeRule({ node_id: 10, updated_at: 321 }));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'permanent',
|
||||
source_updated_at: 321,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignored_stale DELETE keeps pending and does not clear', async () => {
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '',
|
||||
json: async () => ({ success: true, outcome: 'ignored_stale' }),
|
||||
});
|
||||
|
||||
syncSuppressionRuleToFleet(makeRule({
|
||||
node_id: 10,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
}));
|
||||
await vi.waitFor(() => expect(mockUpsertPending).toHaveBeenCalled());
|
||||
expect(mockDeletePending).not.toHaveBeenCalled();
|
||||
expect(mockUpsertPending).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
last_error: expect.stringMatching(/ignored_stale/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('flushPendingSuppressionRetractions retries recoverable only when supported', async () => {
|
||||
mockListPending.mockReturnValue([
|
||||
{
|
||||
rule_id: 7,
|
||||
node_id: 10,
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 50,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
attempts: 1,
|
||||
last_error: 'earlier',
|
||||
},
|
||||
]);
|
||||
mockFetchMeta.mockResolvedValue(metaWith());
|
||||
|
||||
await flushPendingSuppressionRetractions(10);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
expect(mockUpsertPending).toHaveBeenCalled();
|
||||
|
||||
mockUpsertPending.mockClear();
|
||||
mockFetchMeta.mockResolvedValue(metaWith(RETRACTION_CAP));
|
||||
await flushPendingSuppressionRetractions(10);
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 50,
|
||||
});
|
||||
expect(mockDeletePending).toHaveBeenCalledWith(7, 10);
|
||||
});
|
||||
|
||||
it('flushPendingSuppressionRetractions sends permanent without retraction capability', async () => {
|
||||
mockListPending.mockReturnValue([
|
||||
{
|
||||
rule_id: 8,
|
||||
node_id: 11,
|
||||
kind: 'permanent',
|
||||
source_updated_at: 90,
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
attempts: 2,
|
||||
last_error: 'offline',
|
||||
},
|
||||
]);
|
||||
mockFetchMeta.mockResolvedValue(metaWith());
|
||||
|
||||
await flushPendingSuppressionRetractions(11);
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
expect(JSON.parse((mockFetch.mock.calls[0][1] as { body: string }).body)).toEqual({
|
||||
kind: 'permanent',
|
||||
source_updated_at: 90,
|
||||
});
|
||||
expect(mockDeletePending).toHaveBeenCalledWith(8, 11);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Additive kind + source_updated_at on notification_suppression_rule_tombstones.
|
||||
*/
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
function resetDatabaseSingleton(): void {
|
||||
const holder = DatabaseService as unknown as { instance?: DatabaseService };
|
||||
const existing = holder.instance;
|
||||
if (existing) {
|
||||
try {
|
||||
existing.getDb().close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
holder.instance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe('notification suppression tombstone migration', () => {
|
||||
let scratchDir: string | null = null;
|
||||
let prevDataDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
resetDatabaseSingleton();
|
||||
if (prevDataDir === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
} else {
|
||||
process.env.DATA_DIR = prevDataDir;
|
||||
}
|
||||
if (scratchDir) {
|
||||
try {
|
||||
fs.rmSync(scratchDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
scratchDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('adds kind and source_updated_at; legacy rows stay permanent', { timeout: 60_000 }, () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-tomb-mig-'));
|
||||
const dbPath = path.join(scratchDir, 'sencho.db');
|
||||
const seed = new Database(dbPath);
|
||||
try {
|
||||
seed.exec(`
|
||||
CREATE TABLE notification_suppression_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
node_id INTEGER NULL,
|
||||
stack_patterns TEXT NOT NULL,
|
||||
label_ids TEXT NULL,
|
||||
categories TEXT NULL,
|
||||
levels TEXT NULL,
|
||||
applies_to TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
expires_at INTEGER NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE notification_suppression_rule_tombstones (
|
||||
id INTEGER PRIMARY KEY,
|
||||
deleted_at INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO notification_suppression_rule_tombstones (id, deleted_at)
|
||||
VALUES (42, 1700000000000);
|
||||
`);
|
||||
} finally {
|
||||
seed.close();
|
||||
}
|
||||
|
||||
prevDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const cols = db.getDb().prepare('PRAGMA table_info(notification_suppression_rule_tombstones)').all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const names = cols.map((c) => c.name);
|
||||
expect(names).toContain('kind');
|
||||
expect(names).toContain('source_updated_at');
|
||||
|
||||
const tomb = db.getNotificationSuppressionRuleTombstone(42);
|
||||
expect(tomb?.kind).toBe('permanent');
|
||||
expect(tomb?.source_updated_at).toBe(1700000000000);
|
||||
|
||||
// Legacy permanent cannot be cleared by a newer hub POST.
|
||||
db.upsertNotificationSuppressionRuleReplica({
|
||||
id: 42,
|
||||
name: 'should-stay-gone',
|
||||
applies_to: 'both',
|
||||
stack_patterns: [],
|
||||
node_id: null,
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 9_999_999_999_999,
|
||||
});
|
||||
expect(db.getNotificationSuppressionRule(42)).toBeUndefined();
|
||||
expect(db.getNotificationSuppressionRuleTombstone(42)?.kind).toBe('permanent');
|
||||
});
|
||||
|
||||
it('creates pending retractions table and merges permanent over recoverable', { timeout: 60_000 }, () => {
|
||||
scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-supp-pending-'));
|
||||
prevDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = scratchDir;
|
||||
resetDatabaseSingleton();
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
const cols = db.getDb().prepare('PRAGMA table_info(notification_suppression_pending_retractions)').all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
expect(cols.map((c) => c.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'rule_id',
|
||||
'node_id',
|
||||
'kind',
|
||||
'source_updated_at',
|
||||
'attempts',
|
||||
'last_error',
|
||||
]),
|
||||
);
|
||||
|
||||
db.upsertNotificationSuppressionPendingRetraction({
|
||||
rule_id: 7,
|
||||
node_id: 3,
|
||||
kind: 'recoverable',
|
||||
source_updated_at: 100,
|
||||
last_error: 'unsupported',
|
||||
});
|
||||
db.upsertNotificationSuppressionPendingRetraction({
|
||||
rule_id: 7,
|
||||
node_id: 3,
|
||||
kind: 'permanent',
|
||||
source_updated_at: 50,
|
||||
last_error: 'offline',
|
||||
});
|
||||
|
||||
const rows = db.listNotificationSuppressionPendingRetractions(3);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].kind).toBe('permanent');
|
||||
expect(rows[0].source_updated_at).toBe(100);
|
||||
expect(rows[0].attempts).toBe(2);
|
||||
expect(rows[0].last_error).toBe('offline');
|
||||
|
||||
db.deleteNotificationSuppressionPendingRetraction(7, 3);
|
||||
expect(db.listNotificationSuppressionPendingRetractions(3)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user