feat: notifications (#17701)

* feat: notifications

* UI works

* chore: pr feedback

* initial fetch and clear notification upon logging out

* fix: merge

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2025-04-28 10:36:14 -04:00
committed by GitHub
parent 23717ce981
commit 1b5fc9c665
55 changed files with 3186 additions and 196 deletions
+23 -1
View File
@@ -13,9 +13,11 @@ import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
@@ -42,10 +44,12 @@ type RepositoriesTypes = {
config: ConfigRepository;
crypto: CryptoRepository;
database: DatabaseRepository;
email: EmailRepository;
job: JobRepository;
user: UserRepository;
logger: LoggingRepository;
memory: MemoryRepository;
notification: NotificationRepository;
partner: PartnerRepository;
person: PersonRepository;
search: SearchRepository;
@@ -142,6 +146,11 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
return new DatabaseRepository(db, new LoggingRepository(undefined, configRepo), configRepo);
}
case 'email': {
const logger = new LoggingRepository(undefined, new ConfigRepository());
return new EmailRepository(logger);
}
case 'logger': {
const configMock = { getEnv: () => ({ noColor: false }) };
return new LoggingRepository(undefined, configMock as ConfigRepository);
@@ -151,6 +160,10 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
return new MemoryRepository(db);
}
case 'notification': {
return new NotificationRepository(db);
}
case 'partner': {
return new PartnerRepository(db);
}
@@ -221,6 +234,10 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
});
}
case 'email': {
return automock(EmailRepository, { args: [{ setContext: () => {} }] });
}
case 'job': {
return automock(JobRepository, { args: [undefined, undefined, undefined, { setContext: () => {} }] });
}
@@ -234,6 +251,10 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
return automock(MemoryRepository);
}
case 'notification': {
return automock(NotificationRepository);
}
case 'partner': {
return automock(PartnerRepository);
}
@@ -284,7 +305,7 @@ export const asDeps = (repositories: ServiceOverrides) => {
repositories.crypto || getRepositoryMock('crypto'),
repositories.database || getRepositoryMock('database'),
repositories.downloadRepository,
repositories.email,
repositories.email || getRepositoryMock('email'),
repositories.event,
repositories.job || getRepositoryMock('job'),
repositories.library,
@@ -294,6 +315,7 @@ export const asDeps = (repositories: ServiceOverrides) => {
repositories.memory || getRepositoryMock('memory'),
repositories.metadata,
repositories.move,
repositories.notification || getRepositoryMock('notification'),
repositories.oauth,
repositories.partner || getRepositoryMock('partner'),
repositories.person || getRepositoryMock('person'),
@@ -0,0 +1,86 @@
import { NotificationController } from 'src/controllers/notification.controller';
import { AuthService } from 'src/services/auth.service';
import { NotificationService } from 'src/services/notification.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { createControllerTestApp, TestControllerApp } from 'test/medium/utils';
import { factory } from 'test/small.factory';
describe(NotificationController.name, () => {
let realApp: TestControllerApp;
let mockApp: TestControllerApp;
beforeEach(async () => {
realApp = await createControllerTestApp({ authType: 'real' });
mockApp = await createControllerTestApp({ authType: 'mock' });
});
describe('GET /notifications', () => {
it('should require authentication', async () => {
const { status, body } = await request(realApp.getHttpServer()).get('/notifications');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should call the service with an auth dto', async () => {
const auth = factory.auth({ user: factory.user() });
mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth);
const service = mockApp.getMockedService(NotificationService);
const { status } = await request(mockApp.getHttpServer())
.get('/notifications')
.set('Authorization', `Bearer token`);
expect(status).toBe(200);
expect(service.search).toHaveBeenCalledWith(auth, {});
});
it(`should reject an invalid notification level`, async () => {
const auth = factory.auth({ user: factory.user() });
mockApp.getMockedService(AuthService).authenticate.mockResolvedValue(auth);
const service = mockApp.getMockedService(NotificationService);
const { status, body } = await request(mockApp.getHttpServer())
.get(`/notifications`)
.query({ level: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('level must be one of the following values')]));
expect(service.search).not.toHaveBeenCalled();
});
});
describe('PUT /notifications', () => {
it('should require authentication', async () => {
const { status, body } = await request(realApp.getHttpServer())
.put(`/notifications`)
.send({ ids: [], readAt: new Date().toISOString() });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('GET /notifications/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(realApp.getHttpServer()).get(`/notifications/${factory.uuid()}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
describe('PUT /notifications/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(realApp.getHttpServer())
.put(`/notifications/${factory.uuid()}`)
.send({ readAt: factory.date() });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
});
afterAll(async () => {
await realApp.close();
await mockApp.close();
});
});
@@ -37,6 +37,10 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
notification: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
person: {
checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
+1
View File
@@ -314,4 +314,5 @@ export const factory = {
sidecarWrite: assetSidecarWriteFactory,
},
uuid: newUuid,
date: newDate,
};
+4
View File
@@ -29,6 +29,7 @@ import { MediaRepository } from 'src/repositories/media.repository';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { MoveRepository } from 'src/repositories/move.repository';
import { NotificationRepository } from 'src/repositories/notification.repository';
import { OAuthRepository } from 'src/repositories/oauth.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
@@ -135,6 +136,7 @@ export type ServiceOverrides = {
memory: MemoryRepository;
metadata: MetadataRepository;
move: MoveRepository;
notification: NotificationRepository;
oauth: OAuthRepository;
partner: PartnerRepository;
person: PersonRepository;
@@ -202,6 +204,7 @@ export const newTestService = <T extends BaseService>(
memory: automock(MemoryRepository),
metadata: newMetadataRepositoryMock(),
move: automock(MoveRepository, { strict: false }),
notification: automock(NotificationRepository),
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: newPersonRepositoryMock(),
@@ -250,6 +253,7 @@ export const newTestService = <T extends BaseService>(
overrides.memory || (mocks.memory as As<MemoryRepository>),
overrides.metadata || (mocks.metadata as As<MetadataRepository>),
overrides.move || (mocks.move as As<MoveRepository>),
overrides.notification || (mocks.notification as As<NotificationRepository>),
overrides.oauth || (mocks.oauth as As<OAuthRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>),
overrides.person || (mocks.person as As<PersonRepository>),