Files
chrisandClaude Opus 5.5 31aed8dec4 Fix profile picture uploads from the app
Expo's fetch rejects React Native's { uri, name, type } FormData parts ("Unsupported FormDataPart
implementation"), so every upload failed. Upload an expo-file-system File instead, shrunk to 1024 px
first. The server also takes bigger photos (25 MB, sharp's default pixel limit) and logs why a
picture couldn't be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
2026-10-15 13:20:12 -04:00

90 lines
4.3 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('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('<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');
});
});