@proteinjs/user-server 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/src/services/AccessInvite.d.ts.map +1 -1
- package/dist/src/services/AccessInvite.js +32 -15
- package/dist/src/services/AccessInvite.js.map +1 -1
- package/dist/src/services/UpdateUserInfo.d.ts +27 -5
- package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
- package/dist/src/services/UpdateUserInfo.js +72 -13
- package/dist/src/services/UpdateUserInfo.js.map +1 -1
- package/dist/test/AvatarPhotoFidelity.test.d.ts +2 -0
- package/dist/test/AvatarPhotoFidelity.test.d.ts.map +1 -0
- package/dist/test/AvatarPhotoFidelity.test.js +221 -0
- package/dist/test/AvatarPhotoFidelity.test.js.map +1 -0
- package/dist/test/UpdateUserInfoAvatar.test.js +5 -4
- package/dist/test/UpdateUserInfoAvatar.test.js.map +1 -1
- package/dist/test/avatarRoute.test.js +4 -2
- package/dist/test/avatarRoute.test.js.map +1 -1
- package/generated/index.ts +1 -1
- package/index.ts +4 -1
- package/package.json +8 -8
- package/src/services/AccessInvite.ts +36 -12
- package/src/services/UpdateUserInfo.ts +72 -8
- package/test/AvatarPhotoFidelity.test.ts +109 -0
- package/test/UpdateUserInfoAvatar.test.ts +7 -4
- package/test/avatarRoute.test.ts +4 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pixel-fidelity contract of THE avatar image pipeline (`toAvatarJpeg` — the only place avatar
|
|
8
|
+
* pixels are ever resampled), tested straight through the pipeline with no DB: a stored master
|
|
9
|
+
* never contains invented (enlarged) pixels, honors the client's crop frame exactly, and still
|
|
10
|
+
* caps large sources at 512. This is the categorical guard for the founder's fuzzy-avatar
|
|
11
|
+
* defect: a small source blown up to a fixed 512 master reads as permanent blur on every chip
|
|
12
|
+
* that renders it.
|
|
13
|
+
*
|
|
14
|
+
* Helpers are reached via the house typed-cast-on-an-instance pattern (no widening of the
|
|
15
|
+
* class surface).
|
|
16
|
+
*/
|
|
17
|
+
type AvatarPipelineInternals = {
|
|
18
|
+
toAvatarJpeg(imageBytes: Buffer, mimeType: string, crop?: { sx: number; sy: number; size: number }): Promise<Buffer>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const pipeline = () => new UpdateUserInfo() as unknown as AvatarPipelineInternals;
|
|
22
|
+
|
|
23
|
+
const solidPng = async (width: number, height: number, rgb: { r: number; g: number; b: number }) =>
|
|
24
|
+
await sharp({ create: { width, height, channels: 3, background: rgb } })
|
|
25
|
+
.png()
|
|
26
|
+
.toBuffer();
|
|
27
|
+
|
|
28
|
+
const dimensionsOf = async (jpeg: Buffer) => {
|
|
29
|
+
const metadata = await sharp(jpeg).metadata();
|
|
30
|
+
return { width: metadata.width, height: metadata.height, format: metadata.format };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Average color of the whole output — crop-mapping assertions read regions, not single pixels. */
|
|
34
|
+
const averageColor = async (jpeg: Buffer) => {
|
|
35
|
+
const stats = await sharp(jpeg).stats();
|
|
36
|
+
const [r, g, b] = stats.channels.map((channel) => channel.mean);
|
|
37
|
+
return { r, g, b };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('avatar photo pixel fidelity', () => {
|
|
41
|
+
it('keeps a small source at its honest pixel count — never enlarged to the 512 cap', async () => {
|
|
42
|
+
const png = await solidPng(96, 96, { r: 200, g: 80, b: 40 });
|
|
43
|
+
|
|
44
|
+
const jpeg = await pipeline().toAvatarJpeg(png, 'image/png');
|
|
45
|
+
|
|
46
|
+
// The defect: the old pipeline resized EVERYTHING to a fixed 512 square, inventing 5x the
|
|
47
|
+
// pixels for a 96px source — permanent blur baked into the master.
|
|
48
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 96, height: 96, format: 'jpeg' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('center-crops a small non-square source to its honest square (no enlargement)', async () => {
|
|
52
|
+
const png = await solidPng(300, 200, { r: 10, g: 120, b: 210 });
|
|
53
|
+
|
|
54
|
+
const jpeg = await pipeline().toAvatarJpeg(png, 'image/png');
|
|
55
|
+
|
|
56
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 200, height: 200, format: 'jpeg' });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('extracts exactly the client-framed crop square', async () => {
|
|
60
|
+
// Left half blue, right half red; the client frames the right half.
|
|
61
|
+
const left = await solidPng(100, 100, { r: 20, g: 40, b: 220 });
|
|
62
|
+
const source = await sharp({
|
|
63
|
+
create: { width: 200, height: 100, channels: 3, background: { r: 220, g: 30, b: 30 } },
|
|
64
|
+
})
|
|
65
|
+
.composite([{ input: left, left: 0, top: 0 }])
|
|
66
|
+
.png()
|
|
67
|
+
.toBuffer();
|
|
68
|
+
|
|
69
|
+
const jpeg = await pipeline().toAvatarJpeg(source, 'image/png', { sx: 100, sy: 0, size: 100 });
|
|
70
|
+
|
|
71
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 100, height: 100, format: 'jpeg' });
|
|
72
|
+
const color = await averageColor(jpeg);
|
|
73
|
+
expect(color.r).toBeGreaterThan(180);
|
|
74
|
+
expect(color.b).toBeLessThan(80);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('clamps an out-of-bounds crop inside the image, staying square', async () => {
|
|
78
|
+
const png = await solidPng(300, 200, { r: 90, g: 90, b: 90 });
|
|
79
|
+
|
|
80
|
+
const jpeg = await pipeline().toAvatarJpeg(png, 'image/png', { sx: 250, sy: 150, size: 120 });
|
|
81
|
+
|
|
82
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 120, height: 120, format: 'jpeg' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('still lands a large source at the 512 cap', async () => {
|
|
86
|
+
const png = await solidPng(1024, 768, { r: 40, g: 160, b: 90 });
|
|
87
|
+
|
|
88
|
+
const jpeg = await pipeline().toAvatarJpeg(png, 'image/png');
|
|
89
|
+
|
|
90
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 512, height: 512, format: 'jpeg' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps a small HEIC source at its honest centered square (the WASM decode path)', async () => {
|
|
94
|
+
// The 96x64 fixture: centered 64px square, not an enlarged 512.
|
|
95
|
+
const heic = fs.readFileSync(path.join(__dirname, 'fixtures', 'fixture.heic'));
|
|
96
|
+
|
|
97
|
+
const jpeg = await pipeline().toAvatarJpeg(heic, 'image/heic');
|
|
98
|
+
|
|
99
|
+
expect(await dimensionsOf(jpeg)).toEqual({ width: 64, height: 64, format: 'jpeg' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects a non-numeric crop in plain words', async () => {
|
|
103
|
+
const png = await solidPng(64, 64, { r: 10, g: 10, b: 10 });
|
|
104
|
+
|
|
105
|
+
await expect(pipeline().toAvatarJpeg(png, 'image/png', { sx: NaN, sy: 0, size: 32 })).rejects.toThrow(
|
|
106
|
+
'Avatar crop must be numeric'
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -15,7 +15,8 @@ const isJpeg = (buffer: Buffer) => buffer[0] === 0xff && buffer[1] === 0xd8 && b
|
|
|
15
15
|
const getFileRow = async (fileId: string) => await getScopedDbAsSystem<File>().get(fileTables.File, { id: fileId });
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Avatars are photo OR emoji — exactly one active — stored as
|
|
18
|
+
* Avatars are photo OR emoji — exactly one active — stored as a square JPEG capped at 512px
|
|
19
|
+
* (never enlarged past the source's honest pixels; see AvatarPhotoFidelity.test.ts), previous photo file
|
|
19
20
|
* deleted on every change, and the server session cache refreshed in the same stroke (the same
|
|
20
21
|
* helper that fixes the updateName rename-staleness wart). All assertions are outcomes: rows,
|
|
21
22
|
* bytes, cache reads.
|
|
@@ -85,9 +86,11 @@ describe('UpdateUserInfo avatar mutations', () => {
|
|
|
85
86
|
expect(await getFileRow(second.avatarFileId!)).toBeTruthy();
|
|
86
87
|
});
|
|
87
88
|
|
|
88
|
-
it('updateAvatarPhoto normalizes HEIC (default iPhone camera format) to JPEG', async () => {
|
|
89
|
+
it('updateAvatarPhoto normalizes HEIC (default iPhone camera format) to JPEG at its honest size', async () => {
|
|
89
90
|
const user = await testEnv.createUser({ name: 'iPhone user', email: 'heic@test.local' });
|
|
90
91
|
testEnv.actAs(user);
|
|
92
|
+
// The fixture is 96x64 — small on purpose: the stored master is its centered 64px square,
|
|
93
|
+
// NOT an enlarged 512 (enlargement invents pixels that read as permanent blur).
|
|
91
94
|
const heic = fs.readFileSync(path.join(__dirname, 'fixtures', 'fixture.heic'));
|
|
92
95
|
|
|
93
96
|
const updated = await service.updateAvatarPhoto(heic.toString('base64'), 'image/heic');
|
|
@@ -95,8 +98,8 @@ describe('UpdateUserInfo avatar mutations', () => {
|
|
|
95
98
|
const bytes = Buffer.from(await new FileStorage().getFileData(updated.avatarFileId!), 'base64');
|
|
96
99
|
expect(isJpeg(bytes)).toBe(true);
|
|
97
100
|
const metadata = await sharp(bytes).metadata();
|
|
98
|
-
expect(metadata.width).toBe(
|
|
99
|
-
expect(metadata.height).toBe(
|
|
101
|
+
expect(metadata.width).toBe(64);
|
|
102
|
+
expect(metadata.height).toBe(64);
|
|
100
103
|
});
|
|
101
104
|
|
|
102
105
|
it('updateAvatarEmoji sets the emoji, nulls the photo, and deletes the previous photo file', async () => {
|
package/test/avatarRoute.test.ts
CHANGED
|
@@ -70,8 +70,10 @@ describe('GET /avatar/:userId', () => {
|
|
|
70
70
|
expect(bytes[0]).toBe(0xff); // JPEG SOI
|
|
71
71
|
expect(bytes[1]).toBe(0xd8);
|
|
72
72
|
const metadata = await sharp(bytes).metadata();
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
// The 100px source stays its honest 100px square — masters are capped at 512, never
|
|
74
|
+
// enlarged to it (AvatarPhotoFidelity.test.ts owns the pipeline contract).
|
|
75
|
+
expect(metadata.width).toBe(100);
|
|
76
|
+
expect(metadata.height).toBe(100);
|
|
75
77
|
|
|
76
78
|
// The client-side URL contract carries the cache-buster.
|
|
77
79
|
expect(avatarRoute.path(owner.id, updated.avatarFileId!)).toBe(`/avatar/${owner.id}?v=${updated.avatarFileId}`);
|