import sharp from 'sharp'; import { describe, expect, it } from 'vitest'; import { bearer, createUser, useTestApp } from './helpers'; const app = useTestApp(); /** Builds a multipart body by hand, the way a browser or phone would send it. */ function multipart(file: Buffer, type = 'image/jpeg', name = 'photo.jpg') { const boundary = '----musicshare-test'; const payload = Buffer.concat([ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: ${type}\r\n\r\n`), file, Buffer.from(`\r\n--${boundary}--\r\n`), ]); return { payload, headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } }; } const photo = () => sharp({ create: { width: 1200, height: 800, channels: 3, background: '#7b2ff7' } }) .jpeg() .withExif({ IFD0: { Make: 'TestPhone', Model: 'GPS-Leaker' } }) .toBuffer(); describe('avatars', () => { it('takes a full-size photo from a high-resolution phone camera (64 MP)', async () => { const { token } = await createUser(app(), 'hires'); const big = await sharp({ create: { width: 8000, height: 8000, channels: 3, background: '#203040' } }).jpeg().toBuffer(); const { payload, headers } = multipart(big); const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); expect(res.statusCode).toBe(200); }); it('resizes, re-encodes and serves an uploaded picture', async () => { const { token, user } = await createUser(app(), 'alice'); const { payload, headers } = multipart(await photo()); const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); expect(res.statusCode).toBe(200); const url: string = res.json().avatarUrl; expect(url).toMatch(new RegExp(`^https://api\\.test/avatars/${user.id}\\?v=\\d+$`)); const img = await app().inject({ url: url.replace('https://api.test', '') }); expect(img.statusCode).toBe(200); expect(img.headers['content-type']).toBe('image/webp'); expect(img.headers['cache-control']).toContain('immutable'); const meta = await sharp(img.rawPayload).metadata(); expect(meta).toMatchObject({ format: 'webp', width: 512, height: 512 }); expect(meta.exif).toBeUndefined(); // camera/GPS metadata stripped // Shows up wherever the user does, e.g. their profile. const profile = await app().inject({ url: '/users/alice', headers: bearer(token) }); expect(profile.json().user.avatarUrl).toBe(url); }); it('rejects files that are not images', async () => { const { token } = await createUser(app(), 'alice'); const { payload, headers } = multipart(Buffer.from(''), 'image/png', 'x.png'); const res = await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); expect(res.statusCode).toBe(400); expect(res.json().error).toMatch(/isn't an image/); }); it('removes the picture', async () => { const { token, user } = await createUser(app(), 'alice'); const { payload, headers } = multipart(await photo()); await app().inject({ method: 'POST', url: '/me/avatar', payload, headers: { ...headers, ...bearer(token) } }); const res = await app().inject({ method: 'DELETE', url: '/me/avatar', headers: bearer(token) }); expect(res.json().avatarUrl).toBeNull(); expect((await app().inject({ url: `/avatars/${user.id}` })).statusCode).toBe(404); }); it('requires sign-in to upload', async () => { const { payload, headers } = multipart(await photo()); expect((await app().inject({ method: 'POST', url: '/me/avatar', payload, headers })).statusCode).toBe(401); }); }); describe('display name', () => { it('defaults to the username as typed, and falls back to it when cleared', async () => { const res = await app().inject({ method: 'POST', url: '/auth/register', payload: { username: 'BinaryGnome', email: 'g@example.com', password: 'password123' }, }); expect(res.json().user).toMatchObject({ username: 'binarygnome', displayName: 'BinaryGnome' }); const cleared = await app().inject({ method: 'PATCH', url: '/me', headers: bearer(res.json().token), payload: { displayName: '' } }); expect(cleared.json().displayName).toBe('binarygnome'); }); });