Files
musicshare/apps/api/test/avatars.test.ts
T
chrisandClaude Opus 5.5 2b02aa8e89
CI / check (push) Canceled after 0s
Profile pictures, display name default, coloured service names
- Upload a profile picture on web and mobile. The API square-crops it to
  512px WebP, strips metadata (phone photos carry GPS), stores it in
  Postgres and serves it with immutable caching. Avatar URLs are stored as
  paths, so they survive moving the API. Arbitrary avatar URLs are no
  longer accepted (they'd let a profile track its viewers).
- New accounts get the username as typed as their display name; existing
  accounts are backfilled, and clearing it falls back to the username.
- Service names in Settings get a brand-coloured badge and larger type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
2026-10-05 23:38:00 -04:00

82 lines
3.8 KiB
TypeScript

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('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('<script>alert(1)</script>'), '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');
});
});