refactor: subscriber avatar store

This commit is contained in:
Mohamed Marrouchi 2025-05-09 16:49:04 +01:00
parent ae8ae7ea07
commit c56f176d85
5 changed files with 154 additions and 48 deletions

View File

@ -85,7 +85,7 @@ import { ConversationService } from './conversation.service';
import { MessageService } from './message.service';
import { SubscriberService } from './subscriber.service';
describe('BlockService', () => {
describe('BotService', () => {
let blockService: BlockService;
let subscriberService: SubscriberService;
let botService: BotService;

View File

@ -8,16 +8,8 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import mime from 'mime';
import { v4 as uuidv4 } from 'uuid';
import { BotStatsType } from '@/analytics/schemas/bot-stats.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import {
AttachmentAccess,
AttachmentCreatedByRef,
AttachmentResourceRef,
} from '@/attachment/types';
import EventWrapper from '@/channel/lib/EventWrapper';
import { config } from '@/config';
import { HelperService } from '@/helper/helper.service';
@ -47,7 +39,6 @@ export class ChatService {
private readonly botService: BotService,
private readonly websocketGateway: WebsocketGateway,
private readonly helperService: HelperService,
private readonly attachmentService: AttachmentService,
private readonly languageService: LanguageService,
) {}
@ -281,33 +272,9 @@ export class ChatService {
// Retrieve and store the subscriber avatar
if (handler.getSubscriberAvatar) {
try {
const metadata = await handler.getSubscriberAvatar(event);
if (metadata) {
const { file, type, size } = metadata;
const extension = mime.extension(type);
const avatar = await this.attachmentService.store(file, {
name: `avatar-${uuidv4()}.${extension}`,
size,
type,
resourceRef: AttachmentResourceRef.SubscriberAvatar,
access: AttachmentAccess.Private,
createdByRef: AttachmentCreatedByRef.Subscriber,
createdBy: subscriber.id,
});
if (avatar) {
subscriber = await this.subscriberService.updateOne(
subscriber.id,
{
avatar: avatar.id,
},
);
if (!subscriber) {
throw new Error('Unable to update the subscriber avatar');
}
}
const file = await handler.getSubscriberAvatar(event);
if (file) {
await this.subscriberService.storeAvatar(subscriber.id, file);
}
} catch (err) {
this.logger.error(

View File

@ -7,10 +7,20 @@
*/
import { MongooseModule } from '@nestjs/mongoose';
import mime from 'mime';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import {
Attachment,
AttachmentModel,
} from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import {
AttachmentAccess,
AttachmentCreatedByRef,
AttachmentFile,
AttachmentResourceRef,
} from '@/attachment/types';
import { InvitationRepository } from '@/user/repositories/invitation.repository';
import { RoleRepository } from '@/user/repositories/role.repository';
import { UserRepository } from '@/user/repositories/user.repository';
@ -37,11 +47,14 @@ import { Subscriber, SubscriberModel } from '../schemas/subscriber.schema';
import { LabelService } from './label.service';
import { SubscriberService } from './subscriber.service';
jest.mock('uuid', () => ({ v4: jest.fn(() => 'test-uuid') }));
describe('SubscriberService', () => {
let subscriberRepository: SubscriberRepository;
let labelRepository: LabelRepository;
let userRepository: UserRepository;
let subscriberService: SubscriberService;
let attachmentService: AttachmentService;
let allSubscribers: Subscriber[];
let allLabels: Label[];
let allUsers: User[];
@ -74,13 +87,19 @@ describe('SubscriberService', () => {
AttachmentRepository,
],
});
[labelRepository, userRepository, subscriberService, subscriberRepository] =
await getMocks([
LabelRepository,
UserRepository,
SubscriberService,
SubscriberRepository,
]);
[
labelRepository,
userRepository,
subscriberService,
subscriberRepository,
attachmentService,
] = await getMocks([
LabelRepository,
UserRepository,
SubscriberService,
SubscriberRepository,
AttachmentService,
]);
allSubscribers = await subscriberRepository.findAll();
allLabels = await labelRepository.findAll();
allUsers = await userRepository.findAll();
@ -146,4 +165,86 @@ describe('SubscriberService', () => {
});
});
});
describe('storeAvatar', () => {
it('should persist the avatar and patch the subscriber', async () => {
const subscriber = { ...allSubscribers[0], avatar: null };
const avatarPayload: AttachmentFile = {
file: Buffer.from('fake-png'),
type: 'image/png',
size: 8_192,
};
jest.spyOn(mime, 'extension').mockReturnValue('png');
const fakeAttachment = { id: '9'.repeat(24) } as Attachment;
jest.spyOn(attachmentService, 'store').mockResolvedValue(fakeAttachment);
const updateOneSpy = jest
.spyOn(subscriberService, 'updateOne')
.mockResolvedValue(allSubscribers[0]);
await subscriberService.storeAvatar(subscriber.id, avatarPayload);
expect(attachmentService.store).toHaveBeenCalledTimes(1);
expect(attachmentService.store).toHaveBeenCalledWith(
avatarPayload.file,
expect.objectContaining({
name: 'avatar-test-uuid.png',
type: 'image/png',
size: 8_192,
resourceRef: AttachmentResourceRef.SubscriberAvatar,
access: AttachmentAccess.Private,
createdByRef: AttachmentCreatedByRef.Subscriber,
createdBy: subscriber.id,
}),
);
expect(updateOneSpy).toHaveBeenCalledWith(subscriber.id, {
avatar: fakeAttachment.id,
});
});
it('should propagate an error from AttachmentService and leave the subscriber unchanged', async () => {
const subscriber = allSubscribers[0];
const avatarPayload: AttachmentFile = {
file: Buffer.from('fake-jpg'),
type: 'image/jpeg',
size: 5_048,
};
jest.spyOn(mime, 'extension').mockReturnValue('jpg');
const failure = new Error('disk full');
jest.spyOn(attachmentService, 'store').mockRejectedValue(failure);
const updateOneSpy = jest
.spyOn(subscriberService, 'updateOne')
.mockResolvedValue(allSubscribers[0]);
await expect(
subscriberService.storeAvatar(subscriber.id, avatarPayload),
).rejects.toThrow(failure);
expect(updateOneSpy).not.toHaveBeenCalled();
});
it('should generate the filename with the proper extension', async () => {
const subscriber = { ...allSubscribers[0], avatar: null };
const avatarPayload: AttachmentFile = {
file: Buffer.from('fake-png'),
type: 'image/png',
size: 1_024,
};
jest.spyOn(mime, 'extension').mockReturnValue('png');
jest
.spyOn(attachmentService, 'store')
.mockResolvedValue({ id: '9'.repeat(24) } as any);
jest
.spyOn(subscriberService, 'updateOne')
.mockResolvedValue(allSubscribers[0]);
await subscriberService.storeAvatar(subscriber.id, avatarPayload);
const { name } = (attachmentService.store as jest.Mock).mock.calls[0][1]; // second arg in the first call
expect(name).toBe('avatar-test-uuid.png');
});
});
});

View File

@ -12,8 +12,16 @@ import {
Optional,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import mime from 'mime';
import { v4 as uuidv4 } from 'uuid';
import { AttachmentService } from '@/attachment/services/attachment.service';
import {
AttachmentAccess,
AttachmentCreatedByRef,
AttachmentFile,
AttachmentResourceRef,
} from '@/attachment/types';
import { config } from '@/config';
import { BaseService } from '@/utils/generics/base-service';
import {
@ -47,7 +55,7 @@ export class SubscriberService extends BaseService<
constructor(
readonly repository: SubscriberRepository,
protected attachmentService: AttachmentService,
protected readonly attachmentService: AttachmentService,
@Optional() gateway?: WebsocketGateway,
) {
super(repository);
@ -135,6 +143,38 @@ export class SubscriberService extends BaseService<
return await this.repository.handOverByForeignIdQuery(foreignId, userId);
}
/**
* Persist a new avatar image for a given **Subscriber** and attach it to their profile.
* The method is **idempotent** regarding subscriber updates: calling it again simply
* replaces the existing avatar reference with the new one.
*
* @param subscriberId The unique identifier of the subscriber
* @param avatar The uploaded avatar payload containing:
* - `file` Raw binary buffer
* - `type` MIME type (e.g. `image/png`)
* - `size` File size in bytes
*
* @returns Resolves once the subscriber avatar is stored
*/
async storeAvatar(subscriberId: string, avatar: AttachmentFile) {
const { file, type, size } = avatar;
const extension = mime.extension(type);
const attachment = await this.attachmentService.store(file, {
name: `avatar-${uuidv4()}.${extension}`,
size,
type,
resourceRef: AttachmentResourceRef.SubscriberAvatar,
access: AttachmentAccess.Private,
createdByRef: AttachmentCreatedByRef.Subscriber,
createdBy: subscriberId,
});
await this.updateOne(subscriberId, {
avatar: attachment.id,
});
}
/**
* Apply updates on end-user such as :
* - Assign labels to specific end-user

View File

@ -12,7 +12,6 @@ import { MongooseModule } from '@nestjs/mongoose';
import { PassportModule } from '@nestjs/passport';
import { AttachmentModule } from '@/attachment/attachment.module';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { LocalAuthController } from './controllers/auth.controller';
import { ModelController } from './controllers/model.controller';
@ -53,7 +52,6 @@ import { ValidateAccountService } from './services/validate-account.service';
InvitationModel,
RoleModel,
PermissionModel,
AttachmentModel,
]),
PassportModule.register({
session: true,