// Environment variables must be set before any module is loaded process.env.MEDIA_STORAGE_TYPE = 'local'; process.env.MEDIA_LOCAL_STORAGE_PATH = '/tmp/media-test'; process.env.MEDIA_ENCRYPT_KEY = '12345678901234567890123456789012'; // 32 bytes for AES-256 jest.mock('@tomei/general', () => ({ ObjectBase: class ObjectBase {}, })); jest.mock('@nestjs/swagger', () => ({ ApiProperty: () => () => {}, ApiPropertyOptional: () => () => {}, })); jest.mock('sequelize-typescript', () => ({ Table: () => () => {}, Column: () => () => {}, Model: class Model { static findByPk = jest.fn(); static findOne = jest.fn(); static findAll = jest.fn(); static findAndCountAll = jest.fn(); static create = jest.fn(); get = jest.fn(); update = jest.fn(); destroy = jest.fn(); }, DataType: { STRING: 'STRING', TEXT: 'TEXT', DATE: 'DATE', BOOLEAN: 'BOOLEAN', ENUM: () => 'ENUM', }, PrimaryKey: () => () => {}, ForeignKey: () => () => {}, BelongsTo: () => () => {}, HasMany: () => () => {}, CreatedAt: () => () => {}, UpdatedAt: () => () => {}, Default: () => () => {}, AllowNull: () => () => {}, })); jest.mock('@nestjs/common', () => ({ BadRequestException: class BadRequestException extends Error { constructor(msg: string) { super(msg); this.name = 'BadRequestException'; } }, NotFoundException: class NotFoundException extends Error { constructor(msg: string) { super(msg); this.name = 'NotFoundException'; } }, Injectable: () => (t: any) => t, })); jest.mock('@tomei/activity-history', () => ({ Activity: jest.fn().mockImplementation(() => ({ create: jest.fn().mockResolvedValue(undefined), createId: jest.fn().mockReturnValue('activity-id'), ActivityId: '', Action: '', Description: '', EntityType: '', EntityId: '', EntityValueBefore: '', EntityValueAfter: '', })), ActionEnum: { CREATE: 'Create', UPDATE: 'Update', DELETE: 'Delete' }, })); jest.mock('@tomei/sso', () => ({ LoginUser: jest.fn(), })); jest.mock('@paralleldrive/cuid2', () => ({ createId: jest.fn().mockReturnValue('test-media-id'), })); // Variables prefixed with "mock" are hoisted by ts-jest alongside jest.mock() const mockRepoMethods = { create: jest.fn(), findOne: jest.fn(), findAll: jest.fn(), findAndCountAll: jest.fn(), }; jest.mock('../medias.repository', () => ({ MediasRepository: jest.fn().mockImplementation(() => mockRepoMethods), })); const mockUploadData = jest.fn().mockResolvedValue({}); const mockDeleteIfExists = jest.fn().mockResolvedValue({}); const mockDownload = jest.fn(); const mockGetBlockBlobClient = jest.fn().mockReturnValue({ uploadData: mockUploadData, download: mockDownload, deleteIfExists: mockDeleteIfExists, }); const mockGetContainerClient = jest.fn().mockReturnValue({ getBlockBlobClient: mockGetBlockBlobClient, }); jest.mock('@azure/storage-blob', () => ({ BlobServiceClient: { fromConnectionString: jest.fn().mockReturnValue({ getContainerClient: mockGetContainerClient, }), }, })); jest.mock('sequelize', () => ({ Op: { substring: Symbol('Op.substring') }, })); jest.mock('fs'); import * as fs from 'fs'; import { Medias } from '../medias'; import { MediaType } from '../enum/medias.enum'; import { Readable } from 'stream'; const mockedFs = fs as jest.Mocked; const makeLoginUser = () => ({ ObjectId: 'user-001' }) as any; const makeMediaAttr = (overrides: Partial = {}) => ({ MediaId: 'media-001', ObjectId: 'obj-001', ObjectType: 'Rental', Title: 'Test File', Description: 'A test file', Type: MediaType.Photo, IsExternalYN: 'N', ExternalSource: '', IsEncryptedYN: 'N', FileName: 'testfile', FileExtension: 'jpg', FilePath: '/tmp/media-test/Rental/obj-001/testfile.jpg', URL: '/tmp/media-test/Rental/obj-001', CreatedById: 'user-001', CreatedAt: new Date('2026-01-01'), UpdatedById: 'user-001', UpdatedAt: new Date('2026-01-01'), ...overrides, }); const makeMockModel = (attr: any = makeMediaAttr()) => ({ get: jest.fn().mockReturnValue(attr), update: jest.fn().mockResolvedValue({ get: jest.fn().mockReturnValue(attr) }), destroy: jest.fn().mockResolvedValue(undefined), CollectionId: 1, ...attr, }); describe('Medias', () => { beforeEach(() => { jest.clearAllMocks(); mockRepoMethods.create.mockResolvedValue(makeMockModel()); mockRepoMethods.findOne.mockResolvedValue(makeMockModel()); mockRepoMethods.findAll.mockResolvedValue([makeMockModel()]); mockRepoMethods.findAndCountAll.mockResolvedValue({ count: 1, rows: [makeMockModel()], }); }); // ── Constructor ──────────────────────────────────────────────────────────── describe('constructor', () => { it('creates an instance without media attributes', () => { const medias = new Medias(); expect(medias).toBeInstanceOf(Medias); }); it('creates an instance with media attributes', () => { const medias = new Medias(undefined, makeMediaAttr()); expect(medias.Title).toBe('Test File'); // ObjectType class field 'Media' is applied after super()/init(), so it takes precedence expect(medias.ObjectType).toBe('Media'); }); it('throws when storage type is azure but container is missing', () => { process.env.MEDIA_STORAGE_TYPE = 'azure'; delete process.env.MEDIA_AZUREBLOB_CONTAINER_NAME; expect(() => new Medias()).toThrow(); process.env.MEDIA_STORAGE_TYPE = 'local'; }); }); // ── BaseMedias: createSaveLocation ───────────────────────────────────────── describe('createSaveLocation', () => { it('returns local path when MEDIA_STORAGE_TYPE is local', () => { const medias = new Medias(undefined, makeMediaAttr()); const location = medias.createSaveLocation(); // ObjectType class field overrides init(), so ObjectType is 'Media' expect(location).toBe('/tmp/media-test/Media/obj-001'); }); it('throws when no storage path env var is set', () => { const saved = process.env.MEDIA_LOCAL_STORAGE_PATH; delete process.env.MEDIA_LOCAL_STORAGE_PATH; const medias = new Medias(); medias.ObjectType = 'Test'; medias.ObjectId = 'id'; expect(() => medias.createSaveLocation()).toThrow(); process.env.MEDIA_LOCAL_STORAGE_PATH = saved; }); }); // ── encrypt / decrypt ────────────────────────────────────────────────────── describe('encrypt', () => { it('returns an encrypted buffer with iv', async () => { const medias = new Medias(); const plaintext = Buffer.from('hello world'); const result = await medias.encrypt(plaintext); expect(result.isEncrypted).toBe(true); expect(result.buffer).not.toEqual(plaintext); expect(result.iv).toHaveLength(16); }); it('throws when MEDIA_ENCRYPT_KEY is not set', async () => { const saved = process.env.MEDIA_ENCRYPT_KEY; delete process.env.MEDIA_ENCRYPT_KEY; const medias = new Medias(); await expect(medias.encrypt(Buffer.from('test'))).rejects.toThrow(); process.env.MEDIA_ENCRYPT_KEY = saved; }); }); describe('decrypt', () => { it('round-trips correctly with encrypt', async () => { const medias = new Medias(); const plaintext = Buffer.from('secret message'); const encrypted = await medias.encrypt(plaintext); const decrypted = await medias.decrypt(encrypted); expect(decrypted.buffer.toString()).toBe('secret message'); expect(decrypted.isEncrypted).toBe(false); }); }); // ── getAll ───────────────────────────────────────────────────────────────── describe('getAll', () => { it('returns paginated results', async () => { const medias = new Medias(); const result = await medias.getAll(10, 1, ''); expect(result).toEqual({ count: 1, rows: expect.any(Array) }); expect(mockRepoMethods.findAndCountAll).toHaveBeenCalled(); }); it('applies search filter when search string is provided', async () => { const medias = new Medias(); await medias.getAll(10, 1, '{"Title":"test"}'); expect(mockRepoMethods.findAndCountAll).toHaveBeenCalledWith( expect.objectContaining({ where: expect.any(Object) }), ); }); it('throws BadRequestException for invalid search JSON', async () => { const medias = new Medias(); await expect(medias.getAll(10, 1, '{bad json')).rejects.toThrow(); }); }); // ── getOne ───────────────────────────────────────────────────────────────── describe('getOne', () => { it('returns the media model when found', async () => { const medias = new Medias(); const result = await medias.getOne('media-001'); expect(result).toBeDefined(); }); it('throws NotFoundException when not found', async () => { mockRepoMethods.findOne.mockResolvedValue(null); const medias = new Medias(); await expect(medias.getOne('missing-id')).rejects.toThrow(); }); }); // ── getFile ──────────────────────────────────────────────────────────────── describe('getFile', () => { it('returns file buffer and response options for a local unencrypted file', async () => { const fileContent = Buffer.from('file-data'); const stream = Readable.from([fileContent]); mockedFs.createReadStream = jest.fn().mockReturnValue(stream); const medias = new Medias(); const result = await medias.getFile('media-001'); expect(result).toHaveProperty('fileBuffer'); expect(result).toHaveProperty('resOption'); expect(result.resOption['Content-Type']).toBe('application/octet-stream'); }); it('throws NotFoundException when media record is not found', async () => { mockRepoMethods.findOne.mockResolvedValue(null); const medias = new Medias(); await expect(medias.getFile('missing-id')).rejects.toThrow(); }); }); // ── postExternal ─────────────────────────────────────────────────────────── describe('postExternal', () => { it('creates an external media record', async () => { const loginUser = makeLoginUser(); const dto = { ObjectId: 'obj-001', ObjectType: 'Rental', Title: 'External Image', Description: 'Hosted externally', Type: MediaType.Photo, IsEncryptedYN: 'N', ExternalSource: 'http://example.com/img.jpg', URL: 'http://example.com/img.jpg', } as any; const medias = new Medias(); const result = await medias.postExternal(dto, loginUser); expect(result.IsExternalYN).toBe('Y'); expect(result.MediaId).toBeDefined(); }); }); // ── postInternal ─────────────────────────────────────────────────────────── describe('postInternal', () => { it('creates an internal media record for local storage', async () => { const loginUser = makeLoginUser(); const fileStream = { buffer: Buffer.from('file-bytes') } as any; const dto = { ObjectId: 'obj-001', ObjectType: 'Rental', Title: 'Internal File', Description: 'Stored locally', Type: MediaType.Document, IsEncryptedYN: 'N', FileName: 'report', FileExtension: 'pdf', } as any; mockedFs.exists = jest.fn((_p, cb) => cb(null, true)) as any; mockedFs.writeFile = jest.fn((_p, _d, cb) => cb(null)) as any; const medias = new Medias(); const result = await medias.postInternal(fileStream, dto, loginUser); expect(result).toBeDefined(); expect(mockRepoMethods.create).toHaveBeenCalled(); }); }); // ── remove ───────────────────────────────────────────────────────────────── describe('remove', () => { it('deletes the media and returns success message', async () => { mockedFs.existsSync = jest.fn().mockReturnValue(false); const medias = new Medias(); const loginUser = makeLoginUser(); const result = await medias.remove('media-001', loginUser); expect(result).toEqual({ message: 'Media has been deleted.' }); }); it('throws NotFoundException when media is not found', async () => { mockRepoMethods.findOne.mockResolvedValue(null); const medias = new Medias(); await expect(medias.remove('bad-id', makeLoginUser())).rejects.toThrow(); }); }); // ── putExternal ──────────────────────────────────────────────────────────── describe('putExternal', () => { it('updates an external media record', async () => { // putExternal passes null loginUser to updateMedias; make model.update() // return null so the activity block (which reads loginUser.ObjectId) is skipped const nullUpdateModel = { ...makeMockModel(), update: jest.fn().mockResolvedValue(null), }; mockRepoMethods.findOne.mockResolvedValue(nullUpdateModel); const medias = new Medias(); const result = await medias.putExternal( 'media-001', { Title: 'Updated Title' } as any, 'user-001', ); expect(result).toBeNull(); }); it('throws NotFoundException when not found', async () => { mockRepoMethods.findOne.mockResolvedValue(null); const medias = new Medias(); await expect( medias.putExternal('bad-id', {} as any, 'user-001'), ).rejects.toThrow(); }); }); // ── putInternal ──────────────────────────────────────────────────────────── describe('putInternal', () => { it('updates an internal media record for local storage', async () => { const loginUser = makeLoginUser(); const fileStream = { buffer: Buffer.from('updated-bytes') } as any; mockedFs.exists = jest.fn((_p, cb) => cb(null, true)) as any; mockedFs.unlinkSync = jest.fn(); mockedFs.writeFile = jest.fn((_p, _d, cb) => cb(null)) as any; const medias = new Medias(); const result = await medias.putInternal( fileStream, 'media-001', { FileName: 'newfile', FileExtension: 'pdf' } as any, loginUser, ); expect(result).toBeDefined(); }); it('throws NotFoundException when not found', async () => { mockRepoMethods.findOne.mockResolvedValue(null); const medias = new Medias(); await expect( medias.putInternal({} as any, 'bad-id', {} as any, makeLoginUser()), ).rejects.toThrow(); }); }); // ── findFile (Azure fallback) ─────────────────────────────────────────────── describe('findFile via Azure storage', () => { beforeEach(() => { process.env.MEDIA_STORAGE_TYPE = 'azure'; process.env.MEDIA_AZUREBLOB_CONTAINER_NAME = 'test-container'; }); afterEach(() => { process.env.MEDIA_STORAGE_TYPE = 'local'; delete process.env.MEDIA_AZUREBLOB_CONTAINER_NAME; }); it('downloads a file from Azure blob storage', async () => { const fileBuffer = Buffer.from('azure-file-content'); const readable = Readable.from([fileBuffer]); mockDownload.mockResolvedValue({ readableStreamBody: readable }); const medias = new Medias( undefined, makeMediaAttr({ URL: '/azure/Rental/obj-001' }), ); medias.IsEncryptedYN = 'N'; const result = await medias.findFile(); expect(result).toBeInstanceOf(Buffer); expect(mockGetContainerClient).toHaveBeenCalled(); }); }); });