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