Merge branch 'master' into default_notification_settings
Signed-off-by: Ben Lambert <blam@spotify.com>
This commit is contained in:
@@ -1,5 +1,88 @@
|
||||
# @backstage/plugin-notifications-backend
|
||||
|
||||
## 0.5.8-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies
|
||||
- @backstage/config@1.3.3-next.0
|
||||
- @backstage/catalog-model@1.7.5-next.0
|
||||
- @backstage/backend-plugin-api@1.4.1-next.0
|
||||
- @backstage/plugin-auth-node@0.6.5-next.0
|
||||
- @backstage/plugin-notifications-common@0.0.10-next.0
|
||||
- @backstage/plugin-signals-node@0.1.22-next.0
|
||||
- @backstage/plugin-catalog-node@1.17.2-next.0
|
||||
- @backstage/plugin-notifications-node@0.2.17-next.0
|
||||
- @backstage/plugin-events-node@0.4.13-next.0
|
||||
|
||||
## 0.5.8-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9a5a73f: Fix `addTopic` migration when `user_settings` present
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-auth-node@0.6.4
|
||||
- @backstage/backend-plugin-api@1.4.0
|
||||
- @backstage/plugin-catalog-node@1.17.1
|
||||
- @backstage/plugin-events-node@0.4.12
|
||||
- @backstage/plugin-notifications-node@0.2.16
|
||||
- @backstage/catalog-model@1.7.4
|
||||
- @backstage/config@1.3.2
|
||||
- @backstage/errors@1.2.7
|
||||
- @backstage/types@1.2.1
|
||||
- @backstage/plugin-notifications-common@0.0.9
|
||||
- @backstage/plugin-signals-node@0.1.21
|
||||
|
||||
## 0.5.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 41d4d6e: Notifications are now automatically deleted after 1 year by default.
|
||||
|
||||
There is a new scheduled task that runs every 24 hours to delete notifications older than 1 year.
|
||||
This can be configured by setting the `notifications.retention` in the `app-config.yaml` file.
|
||||
|
||||
```yaml
|
||||
notifications:
|
||||
retention: 1y
|
||||
```
|
||||
|
||||
If the retention is set to false, notifications will not be automatically deleted.
|
||||
|
||||
- 8a150bf: Internal changes to switch to the non-alpha `catalogServiceRef`
|
||||
- 1fb5f06: Adds ability for user to turn on/off notifications for specific topics within an origin.
|
||||
- ef9ab82: Notifications API will now return user as null always for broadcast notifications
|
||||
- Updated dependencies
|
||||
- @backstage/plugin-notifications-common@0.0.9
|
||||
- @backstage/plugin-catalog-node@1.17.1
|
||||
- @backstage/plugin-auth-node@0.6.4
|
||||
- @backstage/backend-plugin-api@1.4.0
|
||||
- @backstage/plugin-notifications-node@0.2.16
|
||||
- @backstage/catalog-model@1.7.4
|
||||
- @backstage/config@1.3.2
|
||||
- @backstage/errors@1.2.7
|
||||
- @backstage/types@1.2.1
|
||||
- @backstage/plugin-events-node@0.4.12
|
||||
- @backstage/plugin-signals-node@0.1.21
|
||||
|
||||
## 0.5.7-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8a150bf: Internal changes to switch to the non-alpha `catalogServiceRef`
|
||||
- Updated dependencies
|
||||
- @backstage/backend-plugin-api@1.4.0-next.1
|
||||
- @backstage/catalog-model@1.7.4
|
||||
- @backstage/config@1.3.2
|
||||
- @backstage/errors@1.2.7
|
||||
- @backstage/types@1.2.1
|
||||
- @backstage/plugin-auth-node@0.6.4-next.1
|
||||
- @backstage/plugin-catalog-node@1.17.1-next.1
|
||||
- @backstage/plugin-events-node@0.4.12-next.1
|
||||
- @backstage/plugin-notifications-common@0.0.9-next.0
|
||||
- @backstage/plugin-notifications-node@0.2.16-next.1
|
||||
- @backstage/plugin-signals-node@0.1.21-next.1
|
||||
|
||||
## 0.5.7-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+5
@@ -44,5 +44,10 @@ export interface Config {
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
/*
|
||||
* Time to keep the notifications in the database, defaults to 365 days.
|
||||
* Can be disabled by setting to false.
|
||||
*/
|
||||
retention?: HumanDuration | string | false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,14 +18,10 @@ const crypto = require('crypto');
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.string('topic').nullable().after('origin');
|
||||
table.string('settings_key_hash', 64).notNullable();
|
||||
table.string('settings_key_hash', 64).nullable();
|
||||
table.dropUnique([], 'user_settings_unique_idx');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.unique(['settings_key_hash'], 'user_settings_unique_idx');
|
||||
});
|
||||
|
||||
const rows = await knex('user_settings').select('user', 'channel', 'origin');
|
||||
for (const row of rows) {
|
||||
const rawKey = `${row.user}|${row.channel}|${row.origin}|}`;
|
||||
@@ -35,10 +31,14 @@ exports.up = async function up(knex) {
|
||||
user: row.user,
|
||||
channel: row.channel,
|
||||
origin: row.origin,
|
||||
topic: row.topic,
|
||||
})
|
||||
.update({ settings_key_hash: hash });
|
||||
}
|
||||
|
||||
await knex.schema.alterTable('user_settings', table => {
|
||||
table.string('settings_key_hash', 64).notNullable().alter();
|
||||
table.unique(['settings_key_hash'], 'user_settings_unique_idx');
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/plugin-notifications-backend",
|
||||
"version": "0.5.7-next.1",
|
||||
"version": "0.5.8-next.1",
|
||||
"backstage": {
|
||||
"role": "backend-plugin",
|
||||
"pluginId": "notifications",
|
||||
|
||||
@@ -228,6 +228,13 @@ describe.each(databases.eachSupportedId())(
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return null value for user for broadcast notifications', async () => {
|
||||
await storage.saveBroadcast({ ...testNotification1, read: new Date() });
|
||||
const notifications = await storage.getNotifications({ user });
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].user).toBeNull();
|
||||
});
|
||||
|
||||
it('should return read notifications for user', async () => {
|
||||
await storage.saveNotification(testNotification1);
|
||||
await storage.saveBroadcast(testNotification2);
|
||||
@@ -298,7 +305,7 @@ describe.each(databases.eachSupportedId())(
|
||||
user: otherUser,
|
||||
});
|
||||
expect(otherUserNotifications.map(idOnly)).toEqual([id0, id2]);
|
||||
expect(otherUserNotifications[1].user).toBe(otherUser);
|
||||
expect(otherUserNotifications[1].user).toBeNull();
|
||||
});
|
||||
|
||||
it('should allow searching for notifications', async () => {
|
||||
@@ -635,7 +642,7 @@ describe.each(databases.eachSupportedId())(
|
||||
expect(notification?.user).toBeNull();
|
||||
await storage.markRead({ ids: [id1], user });
|
||||
notification = await storage.getNotification({ id: id1, user });
|
||||
expect(notification?.user).toBe(user);
|
||||
expect(notification?.user).toBeNull();
|
||||
|
||||
const otherNotification = await storage.getNotification({
|
||||
id: id1,
|
||||
@@ -798,5 +805,24 @@ describe.each(databases.eachSupportedId())(
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearNotifications', () => {
|
||||
it('should clear notifications older than specified days', async () => {
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 10); // 10 days ago
|
||||
await storage.saveNotification({
|
||||
...testNotification1,
|
||||
created: oldDate,
|
||||
});
|
||||
await storage.saveNotification(testNotification2);
|
||||
|
||||
const result = await storage.clearNotifications({
|
||||
maxAge: { days: 5 },
|
||||
}); // Clear notifications older than 5 days
|
||||
expect(result.deletedCount).toBe(1); // Only the first notification should be cleared
|
||||
const remainingNotifications = await storage.getNotifications({ user });
|
||||
expect(remainingNotifications.map(idOnly)).toEqual([id2]);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { Knex } from 'knex';
|
||||
import crypto from 'crypto';
|
||||
import { durationToMilliseconds, HumanDuration } from '@backstage/types';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-notifications-backend',
|
||||
@@ -149,7 +150,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
private mapToNotifications = (rows: any[]): Notification[] => {
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
user: row.user,
|
||||
user: row.type === 'broadcast' ? null : row.user,
|
||||
created: new Date(row.created),
|
||||
saved: row.saved,
|
||||
read: row.read,
|
||||
@@ -252,7 +253,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
join.andOnVal('user', '=', user);
|
||||
}
|
||||
})
|
||||
.select(NOTIFICATION_COLUMNS);
|
||||
.select([...NOTIFICATION_COLUMNS, this.db.raw("'broadcast' as type")]);
|
||||
};
|
||||
|
||||
private getNotificationsBaseQuery = (
|
||||
@@ -261,7 +262,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
const { user, orderField } = options;
|
||||
|
||||
const subQuery = this.db<NotificationRowType>('notification')
|
||||
.select(NOTIFICATION_COLUMNS)
|
||||
.select([...NOTIFICATION_COLUMNS, this.db.raw("'entity' as type")])
|
||||
.unionAll([this.getBroadcastUnion(user)])
|
||||
.as('notifications');
|
||||
|
||||
@@ -331,7 +332,10 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
|
||||
async getNotifications(options: NotificationGetOptions) {
|
||||
const notificationQuery = this.getNotificationsBaseQuery(options);
|
||||
const notifications = await notificationQuery.select(NOTIFICATION_COLUMNS);
|
||||
const notifications = await notificationQuery.select([
|
||||
...NOTIFICATION_COLUMNS,
|
||||
'type',
|
||||
]);
|
||||
return this.mapToNotifications(notifications);
|
||||
}
|
||||
|
||||
@@ -462,7 +466,7 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
.select('*')
|
||||
.from(
|
||||
this.db<NotificationRowType>('notification')
|
||||
.select(NOTIFICATION_COLUMNS)
|
||||
.select([...NOTIFICATION_COLUMNS, this.db.raw("'entity' as type")])
|
||||
.unionAll([this.getBroadcastUnion(options.user)])
|
||||
.as('notifications'),
|
||||
)
|
||||
@@ -653,4 +657,24 @@ export class DatabaseNotificationsStore implements NotificationsStore {
|
||||
.distinct(['topic']);
|
||||
return { topics: topics.map(row => row.topic) };
|
||||
}
|
||||
|
||||
async clearNotifications(options: {
|
||||
maxAge: HumanDuration;
|
||||
}): Promise<{ deletedCount: number }> {
|
||||
const ms = durationToMilliseconds(options.maxAge);
|
||||
const now = new Date(new Date().getTime() - ms);
|
||||
const notificationsCount = await this.db('notification')
|
||||
.where(builder => {
|
||||
builder.where('created', '<=', now).whereNull('updated');
|
||||
})
|
||||
.orWhere('updated', '<=', now)
|
||||
.delete();
|
||||
const broadcastsCount = await this.db('broadcast')
|
||||
.where(builder => {
|
||||
builder.where('created', '<=', now).whereNull('updated');
|
||||
})
|
||||
.orWhere('updated', '<=', now)
|
||||
.delete();
|
||||
return { deletedCount: notificationsCount + broadcastsCount };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
NotificationSeverity,
|
||||
NotificationStatus,
|
||||
} from '@backstage/plugin-notifications-common';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
|
||||
/** @internal */
|
||||
export type EntityOrder = {
|
||||
@@ -99,6 +100,10 @@ export interface NotificationsStore {
|
||||
user: string;
|
||||
}): Promise<{ origins: string[] }>;
|
||||
|
||||
getUserNotificationTopics(options: {
|
||||
user: string;
|
||||
}): Promise<{ topics: { origin: string; topic: string }[] }>;
|
||||
|
||||
getNotificationSettings(options: {
|
||||
user: string;
|
||||
}): Promise<NotificationSettings>;
|
||||
@@ -109,4 +114,8 @@ export interface NotificationsStore {
|
||||
}): Promise<void>;
|
||||
|
||||
getTopics(options: TopicGetOptions): Promise<{ topics: string[] }>;
|
||||
|
||||
clearNotifications(options: {
|
||||
maxAge: HumanDuration;
|
||||
}): Promise<{ deletedCount: number }>;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
NotificationsProcessingExtensionPoint,
|
||||
} from '@backstage/plugin-notifications-node';
|
||||
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
|
||||
import { DatabaseNotificationsStore } from './database';
|
||||
import { NotificationCleaner } from './service/NotificationCleaner.ts';
|
||||
|
||||
class NotificationsProcessingExtensionPointImpl
|
||||
implements NotificationsProcessingExtensionPoint
|
||||
@@ -69,6 +71,7 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
signals: signalsServiceRef,
|
||||
config: coreServices.rootConfig,
|
||||
catalog: catalogServiceRef,
|
||||
scheduler: coreServices.scheduler,
|
||||
},
|
||||
async init({
|
||||
auth,
|
||||
@@ -80,7 +83,10 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
signals,
|
||||
config,
|
||||
catalog,
|
||||
scheduler,
|
||||
}) {
|
||||
const store = await DatabaseNotificationsStore.create({ database });
|
||||
|
||||
httpRouter.use(
|
||||
await createRouter({
|
||||
auth,
|
||||
@@ -88,7 +94,7 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
userInfo,
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
store,
|
||||
catalog,
|
||||
signals,
|
||||
processors: processingExtensions.processors,
|
||||
@@ -98,6 +104,14 @@ export const notificationsPlugin = createBackendPlugin({
|
||||
path: '/health',
|
||||
allow: 'unauthenticated',
|
||||
});
|
||||
|
||||
const cleaner = new NotificationCleaner(
|
||||
config,
|
||||
scheduler,
|
||||
logger,
|
||||
store,
|
||||
);
|
||||
await cleaner.initTaskRunner();
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { LoggerService, SchedulerService } from '@backstage/backend-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
import { mockServices } from '@backstage/backend-test-utils';
|
||||
import { NotificationsStore } from '../database';
|
||||
import { NotificationCleaner } from './NotificationCleaner.ts';
|
||||
|
||||
describe('NotificationCleaner', () => {
|
||||
let mockConfig: Config;
|
||||
let mockScheduler: SchedulerService;
|
||||
let mockLogger: LoggerService;
|
||||
let mockDatabase: NotificationsStore;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = mockServices.rootConfig();
|
||||
mockScheduler = mockServices.scheduler.mock();
|
||||
mockLogger = mockServices.logger.mock();
|
||||
mockDatabase = {
|
||||
clearNotifications: jest.fn(),
|
||||
} as unknown as NotificationsStore;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initNotificationCleaner', () => {
|
||||
it('should initialize the notification cleaner with the correct schedule', async () => {
|
||||
const mockTaskRunner = {
|
||||
run: jest.fn(),
|
||||
};
|
||||
mockScheduler.createScheduledTaskRunner = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockTaskRunner);
|
||||
|
||||
const cleaner = new NotificationCleaner(
|
||||
mockConfig,
|
||||
mockScheduler,
|
||||
mockLogger,
|
||||
mockDatabase,
|
||||
);
|
||||
expect(cleaner).toBeInstanceOf(NotificationCleaner);
|
||||
await cleaner.initTaskRunner();
|
||||
|
||||
expect(mockScheduler.createScheduledTaskRunner).toHaveBeenCalled();
|
||||
expect(mockTaskRunner.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'notification-cleaner',
|
||||
fn: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not create a task runner if retention is disabled', async () => {
|
||||
mockConfig = mockServices.rootConfig({
|
||||
data: { notifications: { retention: false } },
|
||||
});
|
||||
const cleaner = new NotificationCleaner(
|
||||
mockConfig,
|
||||
mockScheduler,
|
||||
mockLogger,
|
||||
mockDatabase,
|
||||
);
|
||||
await cleaner.initTaskRunner();
|
||||
|
||||
expect(mockScheduler.createScheduledTaskRunner).not.toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Notification retention is disabled, skipping notification cleaner task',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearNotifications', () => {
|
||||
it('should clear notifications', async () => {
|
||||
mockDatabase.clearNotifications = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ deletedCount: 1 });
|
||||
const mockTaskRunner = {
|
||||
run: jest.fn().mockImplementation(({ fn }) => fn()),
|
||||
};
|
||||
mockScheduler.createScheduledTaskRunner = jest
|
||||
.fn()
|
||||
.mockReturnValue(mockTaskRunner);
|
||||
|
||||
const cleaner = new NotificationCleaner(
|
||||
mockConfig,
|
||||
mockScheduler,
|
||||
mockLogger,
|
||||
mockDatabase,
|
||||
);
|
||||
await cleaner.initTaskRunner();
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Starting notification cleaner task',
|
||||
);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
'Notification cleaner task completed successfully, deleted 1 notifications',
|
||||
);
|
||||
expect(mockDatabase.clearNotifications).toHaveBeenCalledWith({
|
||||
maxAge: { years: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
LoggerService,
|
||||
SchedulerService,
|
||||
SchedulerServiceTaskScheduleDefinition,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Config, readDurationFromConfig } from '@backstage/config';
|
||||
import { NotificationsStore } from '../database';
|
||||
import { HumanDuration } from '@backstage/types';
|
||||
import { ForwardedError } from '@backstage/errors';
|
||||
|
||||
export class NotificationCleaner {
|
||||
private readonly retention: HumanDuration = { years: 1 };
|
||||
private readonly enabled: boolean = true;
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly scheduler: SchedulerService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly database: NotificationsStore,
|
||||
) {
|
||||
if (config.has('notifications.retention')) {
|
||||
const retentionConfig = config.get('notifications.retention');
|
||||
if (typeof retentionConfig === 'boolean' && !retentionConfig) {
|
||||
logger.info(
|
||||
'Notification retention is disabled, skipping notification cleaner task',
|
||||
);
|
||||
this.enabled = false;
|
||||
return;
|
||||
}
|
||||
this.retention = readDurationFromConfig(config, {
|
||||
key: 'notifications.retention',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async initTaskRunner() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schedule: SchedulerServiceTaskScheduleDefinition = {
|
||||
frequency: { cron: '0 0 * * *' },
|
||||
timeout: { hours: 1 },
|
||||
initialDelay: { hours: 1 },
|
||||
scope: 'global',
|
||||
};
|
||||
|
||||
const taskRunner = this.scheduler.createScheduledTaskRunner(schedule);
|
||||
await taskRunner.run({
|
||||
id: 'notification-cleaner',
|
||||
fn: async () => {
|
||||
await this.clearNotifications(
|
||||
this.logger,
|
||||
this.database,
|
||||
this.retention,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async clearNotifications(
|
||||
logger: LoggerService,
|
||||
database: NotificationsStore,
|
||||
retention: HumanDuration,
|
||||
) {
|
||||
logger.info('Starting notification cleaner task');
|
||||
try {
|
||||
const result = await database.clearNotifications({ maxAge: retention });
|
||||
logger.info(
|
||||
`Notification cleaner task completed successfully, deleted ${result.deletedCount} notifications`,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ForwardedError('Notification cleaner task failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,10 @@ import { NotificationSendOptions } from '@backstage/plugin-notifications-node';
|
||||
import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils';
|
||||
import { DatabaseService } from '@backstage/backend-plugin-api';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { DatabaseNotificationsStore } from '../database';
|
||||
|
||||
const databases = TestDatabases.create();
|
||||
let store: DatabaseNotificationsStore;
|
||||
|
||||
async function createDatabase(
|
||||
databaseId: TestDatabaseId,
|
||||
@@ -100,6 +102,9 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await createDatabase(databaseId);
|
||||
store = await DatabaseNotificationsStore.create({
|
||||
database,
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /notifications', () => {
|
||||
@@ -110,7 +115,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: mockServices.logger.mock(),
|
||||
database,
|
||||
store,
|
||||
signals: signalService,
|
||||
userInfo,
|
||||
config,
|
||||
@@ -498,7 +503,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: mockServices.logger.mock(),
|
||||
database,
|
||||
store,
|
||||
signals: signalService,
|
||||
userInfo,
|
||||
config,
|
||||
@@ -588,7 +593,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: mockServices.logger.mock(),
|
||||
database,
|
||||
store,
|
||||
signals: signalService,
|
||||
userInfo,
|
||||
config,
|
||||
@@ -726,7 +731,7 @@ describe.each(databases.eachSupportedId())('createRouter (%s)', databaseId => {
|
||||
beforeAll(async () => {
|
||||
const router = await createRouter({
|
||||
logger: mockServices.logger.mock(),
|
||||
database,
|
||||
store,
|
||||
signals: signalService,
|
||||
userInfo,
|
||||
config,
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import {
|
||||
DatabaseNotificationsStore,
|
||||
normalizeSeverity,
|
||||
NotificationGetOptions,
|
||||
NotificationsStore,
|
||||
TopicGetOptions,
|
||||
} from '../database';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
import {
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
HttpAuthService,
|
||||
LoggerService,
|
||||
UserInfoService,
|
||||
@@ -58,7 +57,7 @@ import pThrottle from 'p-throttle';
|
||||
export interface RouterOptions {
|
||||
logger: LoggerService;
|
||||
config: Config;
|
||||
database: DatabaseService;
|
||||
store: NotificationsStore;
|
||||
auth: AuthService;
|
||||
httpAuth: HttpAuthService;
|
||||
userInfo: UserInfoService;
|
||||
@@ -74,7 +73,7 @@ export async function createRouter(
|
||||
const {
|
||||
config,
|
||||
logger,
|
||||
database,
|
||||
store,
|
||||
auth,
|
||||
httpAuth,
|
||||
userInfo,
|
||||
@@ -84,7 +83,6 @@ export async function createRouter(
|
||||
} = options;
|
||||
|
||||
const WEB_NOTIFICATION_CHANNEL = 'Web';
|
||||
const store = await DatabaseNotificationsStore.create({ database });
|
||||
const frontendBaseUrl = config.getString('app.baseUrl');
|
||||
const concurrencyLimit =
|
||||
config.getOptionalNumber('notifications.concurrencyLimit') ?? 10;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Knex } from 'knex';
|
||||
import { TestDatabases } from '@backstage/backend-test-utils';
|
||||
import fs from 'fs';
|
||||
|
||||
const migrationsDir = `${__dirname}/../../migrations`;
|
||||
const migrationsFiles = fs.readdirSync(migrationsDir).sort();
|
||||
|
||||
async function migrateUpOnce(knex: Knex): Promise<void> {
|
||||
await knex.migrate.up({ directory: migrationsDir });
|
||||
}
|
||||
|
||||
async function migrateDownOnce(knex: Knex): Promise<void> {
|
||||
await knex.migrate.down({ directory: migrationsDir });
|
||||
}
|
||||
|
||||
async function migrateUntilBefore(knex: Knex, target: string): Promise<void> {
|
||||
const index = migrationsFiles.indexOf(target);
|
||||
if (index === -1) {
|
||||
throw new Error(`Migration ${target} not found`);
|
||||
}
|
||||
for (let i = 0; i < index; i++) {
|
||||
await migrateUpOnce(knex);
|
||||
}
|
||||
}
|
||||
|
||||
jest.setTimeout(60_000);
|
||||
|
||||
describe('migrations', () => {
|
||||
const databases = TestDatabases.create();
|
||||
|
||||
it.each(databases.eachSupportedId())(
|
||||
'20221109192547_search_add_original_value_column.js, %p',
|
||||
async databaseId => {
|
||||
const knex = await databases.init(databaseId);
|
||||
|
||||
await migrateUntilBefore(knex, '20250317_addTopic.js');
|
||||
|
||||
await knex
|
||||
.insert({
|
||||
user: 'user1',
|
||||
channel: 'channel1',
|
||||
origin: 'origin1',
|
||||
enabled: true,
|
||||
})
|
||||
.into('user_settings');
|
||||
|
||||
await migrateUpOnce(knex);
|
||||
|
||||
let rows = await knex('user_settings');
|
||||
let normalized = rows.map(r => ({ ...r, enabled: !!r.enabled }));
|
||||
|
||||
expect(normalized).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user: 'user1',
|
||||
channel: 'channel1',
|
||||
origin: 'origin1',
|
||||
enabled: true,
|
||||
settings_key_hash:
|
||||
'73f97aff883b8b08a7f4e366234ef4f86827702b0016574ac4c1bf313c703d15',
|
||||
topic: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
await migrateDownOnce(knex);
|
||||
|
||||
rows = await knex('user_settings');
|
||||
normalized = rows.map(r => ({ ...r, enabled: !!r.enabled }));
|
||||
|
||||
expect(normalized).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user: 'user1',
|
||||
channel: 'channel1',
|
||||
origin: 'origin1',
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user