@proteinjs/user-server 1.5.3 → 1.6.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/dist/generated/index.d.ts +1 -4
  4. package/dist/generated/index.d.ts.map +1 -1
  5. package/dist/generated/index.js +6 -7
  6. package/dist/generated/index.js.map +1 -1
  7. package/dist/src/authentication/DbSessionStore.d.ts +0 -1
  8. package/dist/src/authentication/DbSessionStore.d.ts.map +1 -1
  9. package/dist/src/routes/avatar.d.ts +19 -0
  10. package/dist/src/routes/avatar.d.ts.map +1 -0
  11. package/dist/src/routes/avatar.js +108 -0
  12. package/dist/src/routes/avatar.js.map +1 -0
  13. package/dist/src/routes/devLogin.d.ts.map +1 -1
  14. package/dist/src/routes/devLogin.js +11 -0
  15. package/dist/src/routes/devLogin.js.map +1 -1
  16. package/dist/src/services/UpdateUserInfo.d.ts +40 -2
  17. package/dist/src/services/UpdateUserInfo.d.ts.map +1 -1
  18. package/dist/src/services/UpdateUserInfo.js +183 -12
  19. package/dist/src/services/UpdateUserInfo.js.map +1 -1
  20. package/dist/test/UpdateUserInfoAvatar.test.d.ts +2 -0
  21. package/dist/test/UpdateUserInfoAvatar.test.d.ts.map +1 -0
  22. package/dist/test/UpdateUserInfoAvatar.test.js +337 -0
  23. package/dist/test/UpdateUserInfoAvatar.test.js.map +1 -0
  24. package/dist/test/UpdateUserInfoSessionRefresh.test.d.ts +2 -0
  25. package/dist/test/UpdateUserInfoSessionRefresh.test.d.ts.map +1 -0
  26. package/dist/test/UpdateUserInfoSessionRefresh.test.js +94 -0
  27. package/dist/test/UpdateUserInfoSessionRefresh.test.js.map +1 -0
  28. package/dist/test/UserServerTestEnvironment.d.ts +26 -0
  29. package/dist/test/UserServerTestEnvironment.d.ts.map +1 -0
  30. package/dist/test/UserServerTestEnvironment.js +202 -0
  31. package/dist/test/UserServerTestEnvironment.js.map +1 -0
  32. package/dist/test/avatarRoute.test.d.ts +2 -0
  33. package/dist/test/avatarRoute.test.d.ts.map +1 -0
  34. package/dist/test/avatarRoute.test.js +222 -0
  35. package/dist/test/avatarRoute.test.js.map +1 -0
  36. package/generated/index.ts +21 -25
  37. package/jest.config.js +4 -1
  38. package/package.json +9 -5
  39. package/src/heic-decode.d.ts +13 -0
  40. package/src/routes/avatar.ts +57 -0
  41. package/src/routes/devLogin.ts +5 -0
  42. package/src/services/UpdateUserInfo.ts +124 -13
  43. package/test/UpdateUserInfoAvatar.test.ts +174 -0
  44. package/test/UpdateUserInfoSessionRefresh.test.ts +37 -0
  45. package/test/UserServerTestEnvironment.ts +97 -0
  46. package/test/avatarRoute.test.ts +111 -0
  47. package/test/emulatorLock.globalSetup.js +11 -0
  48. package/test/emulatorLock.globalTeardown.js +8 -0
  49. package/test/emulatorLock.js +124 -0
  50. package/test/fixtures/fixture.heic +0 -0
  51. package/tsconfig.json +1 -0
@@ -0,0 +1,174 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import sharp from 'sharp';
4
+ import { getDbAsSystem } from '@proteinjs/db';
5
+ import { FileStorage, File, tables as fileTables } from '@proteinjs/db-file';
6
+ import { tables, UserRepo, getScopedDbAsSystem } from '@proteinjs/user';
7
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
8
+ import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
9
+
10
+ const testEnv = new UserServerTestEnvironment();
11
+
12
+ /** JPEG magic bytes (SOI + marker prefix). */
13
+ const isJpeg = (buffer: Buffer) => buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff;
14
+
15
+ const getFileRow = async (fileId: string) => await getScopedDbAsSystem<File>().get(fileTables.File, { id: fileId });
16
+
17
+ /**
18
+ * Avatars are photo OR emoji — exactly one active — stored as 512x512 JPEG, previous photo file
19
+ * deleted on every change, and the server session cache refreshed in the same stroke (the same
20
+ * helper that fixes the updateName rename-staleness wart). All assertions are outcomes: rows,
21
+ * bytes, cache reads.
22
+ */
23
+ describe('UpdateUserInfo avatar mutations', () => {
24
+ const service = new UpdateUserInfo();
25
+
26
+ beforeAll(async () => {
27
+ await testEnv.beforeAll();
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await testEnv.afterAll();
32
+ });
33
+
34
+ it('updateAvatarPhoto stores a 512x512 JPEG file and points the user row at it', async () => {
35
+ const user = await testEnv.createUser({ name: 'Photo user', email: 'photo@test.local' });
36
+ testEnv.actAs(user);
37
+ const png = await sharp({
38
+ create: { width: 800, height: 600, channels: 3, background: { r: 200, g: 80, b: 40 } },
39
+ })
40
+ .png()
41
+ .toBuffer();
42
+
43
+ const updated = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
44
+
45
+ // Returned user: photo active, emoji null, no password hash.
46
+ expect(updated.avatarFileId).toBeTruthy();
47
+ expect(updated.avatarEmoji).toBeFalsy();
48
+ expect((updated as any).password).toBeUndefined();
49
+
50
+ // User row persisted with the exactly-one invariant.
51
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
52
+ expect(row.avatarFileId).toBe(updated.avatarFileId);
53
+ expect(row.avatarEmoji).toBeFalsy();
54
+
55
+ // File row + bytes written: a real 512x512 JPEG.
56
+ const file = await getFileRow(row.avatarFileId!);
57
+ expect(file).toBeTruthy();
58
+ expect(file.type).toBe('image/jpeg');
59
+ const bytes = Buffer.from(await new FileStorage().getFileData(file.id), 'base64');
60
+ expect(isJpeg(bytes)).toBe(true);
61
+ expect(file.size).toBe(bytes.length);
62
+ const metadata = await sharp(bytes).metadata();
63
+ expect(metadata.width).toBe(512);
64
+ expect(metadata.height).toBe(512);
65
+
66
+ // Session cache refreshed in the same stroke.
67
+ expect(new UserRepo().getUser().avatarFileId).toBe(row.avatarFileId);
68
+ });
69
+
70
+ it('a second updateAvatarPhoto deletes the previous avatar file', async () => {
71
+ const user = await testEnv.createUser({ name: 'Replacer', email: 'replace@test.local' });
72
+ testEnv.actAs(user);
73
+ const png = await sharp({ create: { width: 300, height: 300, channels: 3, background: { r: 0, g: 120, b: 200 } } })
74
+ .png()
75
+ .toBuffer();
76
+
77
+ const first = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
78
+ const second = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
79
+ expect(second.avatarFileId).not.toBe(first.avatarFileId);
80
+
81
+ // The prior file is gone — row and bytes.
82
+ expect(await getFileRow(first.avatarFileId!)).toBeFalsy();
83
+ expect(await new FileStorage().getFileData(first.avatarFileId!)).toBe('');
84
+ // The new one is intact.
85
+ expect(await getFileRow(second.avatarFileId!)).toBeTruthy();
86
+ });
87
+
88
+ it('updateAvatarPhoto normalizes HEIC (default iPhone camera format) to JPEG', async () => {
89
+ const user = await testEnv.createUser({ name: 'iPhone user', email: 'heic@test.local' });
90
+ testEnv.actAs(user);
91
+ const heic = fs.readFileSync(path.join(__dirname, 'fixtures', 'fixture.heic'));
92
+
93
+ const updated = await service.updateAvatarPhoto(heic.toString('base64'), 'image/heic');
94
+
95
+ const bytes = Buffer.from(await new FileStorage().getFileData(updated.avatarFileId!), 'base64');
96
+ expect(isJpeg(bytes)).toBe(true);
97
+ const metadata = await sharp(bytes).metadata();
98
+ expect(metadata.width).toBe(512);
99
+ expect(metadata.height).toBe(512);
100
+ });
101
+
102
+ it('updateAvatarEmoji sets the emoji, nulls the photo, and deletes the previous photo file', async () => {
103
+ const user = await testEnv.createUser({ name: 'Emoji user', email: 'emoji@test.local' });
104
+ testEnv.actAs(user);
105
+ const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: { r: 9, g: 9, b: 9 } } })
106
+ .png()
107
+ .toBuffer();
108
+ const withPhoto = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
109
+
110
+ const updated = await service.updateAvatarEmoji('🦞');
111
+
112
+ // Exactly-one invariant, in the row and the returned user.
113
+ expect(updated.avatarEmoji).toBe('🦞');
114
+ expect(updated.avatarFileId).toBeFalsy();
115
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
116
+ expect(row.avatarEmoji).toBe('🦞');
117
+ expect(row.avatarFileId).toBeFalsy();
118
+
119
+ // Previous photo file deleted; session cache refreshed.
120
+ expect(await getFileRow(withPhoto.avatarFileId!)).toBeFalsy();
121
+ expect(new UserRepo().getUser().avatarEmoji).toBe('🦞');
122
+ });
123
+
124
+ it('accepts multi-codepoint emoji (ZWJ sequences)', async () => {
125
+ const user = await testEnv.createUser({ name: 'Astronaut', email: 'zwj@test.local' });
126
+ testEnv.actAs(user);
127
+ const updated = await service.updateAvatarEmoji('👩‍🚀');
128
+ expect(updated.avatarEmoji).toBe('👩‍🚀');
129
+ });
130
+
131
+ it('clearAvatar removes both representations and the stored photo file', async () => {
132
+ const user = await testEnv.createUser({ name: 'Clearer', email: 'clear@test.local' });
133
+ testEnv.actAs(user);
134
+ const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: { r: 1, g: 2, b: 3 } } })
135
+ .png()
136
+ .toBuffer();
137
+ const withPhoto = await service.updateAvatarPhoto(png.toString('base64'), 'image/png');
138
+
139
+ const updated = await service.clearAvatar();
140
+
141
+ expect(updated.avatarEmoji).toBeFalsy();
142
+ expect(updated.avatarFileId).toBeFalsy();
143
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
144
+ expect(row.avatarEmoji).toBeFalsy();
145
+ expect(row.avatarFileId).toBeFalsy();
146
+ expect(await getFileRow(withPhoto.avatarFileId!)).toBeFalsy();
147
+ expect(new UserRepo().getUser().avatarFileId).toBeFalsy();
148
+ });
149
+
150
+ it('rejects oversized photos before transforming, and unsupported types', async () => {
151
+ const user = await testEnv.createUser({ name: 'Rejected', email: 'reject@test.local' });
152
+ testEnv.actAs(user);
153
+
154
+ const oversized = Buffer.alloc(10 * 1024 * 1024 + 1);
155
+ await expect(service.updateAvatarPhoto(oversized.toString('base64'), 'image/png')).rejects.toThrow(/too large/);
156
+ await expect(service.updateAvatarPhoto(Buffer.alloc(10).toString('base64'), 'image/gif')).rejects.toThrow(
157
+ /Unsupported avatar image type/
158
+ );
159
+
160
+ // Neither rejection touched the user row.
161
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
162
+ expect(row.avatarFileId).toBeFalsy();
163
+ expect(row.avatarEmoji).toBeFalsy();
164
+ });
165
+
166
+ it('rejects non-emoji strings as avatar emoji', async () => {
167
+ const user = await testEnv.createUser({ name: 'Wordy', email: 'wordy@test.local' });
168
+ testEnv.actAs(user);
169
+
170
+ await expect(service.updateAvatarEmoji('hello')).rejects.toThrow(/single short emoji/);
171
+ await expect(service.updateAvatarEmoji('')).rejects.toThrow(/single short emoji/);
172
+ await expect(service.updateAvatarEmoji('🦞🦞🦞🦞🦞🦞🦞🦞🦞')).rejects.toThrow(/single short emoji/);
173
+ });
174
+ });
@@ -0,0 +1,37 @@
1
+ import { getDbAsSystem } from '@proteinjs/db';
2
+ import { tables, UserRepo } from '@proteinjs/user';
3
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
4
+ import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
5
+
6
+ const testEnv = new UserServerTestEnvironment();
7
+
8
+ /**
9
+ * The rename-staleness wart, red-before-green: UpdateUserInfo.updateName wrote the DB but never
10
+ * refreshed the server session cache (userCache serializes the user into session data), so every
11
+ * read through UserRepo in the same request context — and any long-lived context that keeps its
12
+ * session data, e.g. socket sessions — kept serving the OLD name until re-login. The fix routes
13
+ * every user-info mutation through one helper that persists AND refreshes the session cache.
14
+ */
15
+ describe('UpdateUserInfo.updateName — persists AND refreshes the server session cache', () => {
16
+ beforeAll(async () => {
17
+ await testEnv.beforeAll();
18
+ });
19
+
20
+ afterAll(async () => {
21
+ await testEnv.afterAll();
22
+ });
23
+
24
+ it('updates the user row and the session-cached user in the same stroke', async () => {
25
+ const user = await testEnv.createUser({ name: 'Old Name', email: 'rename@test.local' });
26
+ testEnv.actAs(user);
27
+
28
+ await new UpdateUserInfo().updateName('New Name');
29
+
30
+ // Outcome 1: the row is renamed (held even pre-fix).
31
+ const row = await getDbAsSystem().get(tables.User, { id: user.id });
32
+ expect(row.name).toBe('New Name');
33
+
34
+ // Outcome 2 (the wart): the session cache must serve the new name too. RED pre-fix.
35
+ expect(new UserRepo().getUser().name).toBe('New Name');
36
+ });
37
+ });
@@ -0,0 +1,97 @@
1
+ import moment from 'moment';
2
+ import { Db, Table, getDbAsSystem } from '@proteinjs/db';
3
+ import { SpannerDriver } from '@proteinjs/db-driver-spanner';
4
+ import { SpannerEmulatorProvisioner, getDropTestTable } from '@proteinjs/db-driver-spanner/test';
5
+ import { tables as fileTables } from '@proteinjs/db-file';
6
+ import { Session, type SessionData, type SessionDataStorage } from '@proteinjs/server-api';
7
+ import { SourceRepository } from '@proteinjs/reflection';
8
+ import { tables as userTables, User, UserRepo } from '@proteinjs/user';
9
+
10
+ /** In-memory SessionDataStorage seeded into the SourceRepository cache (tests don't load the
11
+ * generated source graph, so `Session` would otherwise find no implementation). */
12
+ class TestSessionDataStorage implements SessionDataStorage {
13
+ environment = 'node' as const;
14
+ private static data: SessionData;
15
+
16
+ setData(data: SessionData) {
17
+ TestSessionDataStorage.data = data;
18
+ }
19
+
20
+ getData(): SessionData {
21
+ return TestSessionDataStorage.data;
22
+ }
23
+ }
24
+
25
+ type SourceRepositoryInternals = { objectCache: Record<string, unknown[]> };
26
+
27
+ /**
28
+ * Emulator-backed test environment for user-server integration tests (Spanner emulator on
29
+ * SPANNER_EMULATOR_HOST, provisioned on demand). Same shape as chat-server's ChatTestEnvironment:
30
+ * seed deterministic session storage + db driver into the SourceRepository cache, load the tables
31
+ * the suites touch, drop them on the way out.
32
+ */
33
+ export class UserServerTestEnvironment {
34
+ readonly spannerDriver = new SpannerDriver({
35
+ projectId: 'proteinjs-test',
36
+ instanceName: 'proteinjs-test',
37
+ databaseName: 'test',
38
+ });
39
+ private dropTestTable = getDropTestTable(this.spannerDriver);
40
+ private userRepo = new UserRepo();
41
+
42
+ async beforeAll() {
43
+ // The emulator wipes its in-memory instances on any container restart (and CI service
44
+ // containers start empty) — self-provision so suites never depend on out-of-band setup.
45
+ await SpannerEmulatorProvisioner.ensureProvisioned({
46
+ projectId: 'proteinjs-test',
47
+ instanceName: 'proteinjs-test',
48
+ databaseName: 'test',
49
+ });
50
+ const objectCache = (SourceRepository.get() as unknown as SourceRepositoryInternals).objectCache;
51
+ objectCache['@proteinjs/server-api/SessionDataStorage'] = [new TestSessionDataStorage()];
52
+ objectCache['@proteinjs/db/DefaultDbDriverFactory'] = [{ getDbDriver: () => this.spannerDriver }];
53
+ Session.setData({
54
+ sessionId: 'test-session',
55
+ user: 'guest',
56
+ data: {},
57
+ });
58
+
59
+ await this.loadTables(userTables);
60
+ await this.loadTables(fileTables);
61
+ }
62
+
63
+ async afterAll() {
64
+ await this.dropTables(fileTables);
65
+ await this.dropTables(userTables);
66
+ SpannerEmulatorProvisioner.release();
67
+ }
68
+
69
+ /** Insert a user row (as system) and return it. */
70
+ async createUser(args: { name: string; email: string; roles?: string[] }): Promise<User> {
71
+ return await getDbAsSystem().insert(userTables.User, {
72
+ name: args.name,
73
+ email: args.email,
74
+ password: 'test',
75
+ emailVerified: true,
76
+ roles: args.roles ?? [],
77
+ });
78
+ }
79
+
80
+ /** Seed the session cache with `user` — the identity service calls and scoped db ops run under. */
81
+ actAs(user: User) {
82
+ this.userRepo.setUser(user);
83
+ }
84
+
85
+ private async loadTables(tables: { [key: string]: Table<any> }) {
86
+ const dbDriver = Db.getDefaultDbDriver();
87
+ for (const table of Object.values(tables)) {
88
+ await dbDriver.getTableManager().loadTable(table);
89
+ }
90
+ }
91
+
92
+ private async dropTables(tables: { [key: string]: Table<any> }) {
93
+ for (const table of Object.values(tables)) {
94
+ await this.dropTestTable(table);
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,111 @@
1
+ import sharp from 'sharp';
2
+ import { guestUser, avatarRoute } from '@proteinjs/user';
3
+ import { UserServerTestEnvironment } from './UserServerTestEnvironment';
4
+ import { UpdateUserInfo } from '../src/services/UpdateUserInfo';
5
+ import { avatar } from '../src/routes/avatar';
6
+
7
+ const testEnv = new UserServerTestEnvironment();
8
+
9
+ /** Captures exactly what the route writes — status, headers, body. */
10
+ class MockResponse {
11
+ statusCode = 200;
12
+ headers: Record<string, string> = {};
13
+ body: any;
14
+
15
+ status(code: number) {
16
+ this.statusCode = code;
17
+ return this;
18
+ }
19
+
20
+ setHeader(name: string, value: string) {
21
+ this.headers[name.toLowerCase()] = value;
22
+ }
23
+
24
+ send(body?: any) {
25
+ this.body = body;
26
+ return this;
27
+ }
28
+ }
29
+
30
+ const requestAvatar = async (userId: string): Promise<MockResponse> => {
31
+ const response = new MockResponse();
32
+ await avatar.onRequest({ params: { userId } } as any, response as any);
33
+ return response;
34
+ };
35
+
36
+ /**
37
+ * The shared-visibility door for avatar photos: logged-in users can render ANY user's avatar
38
+ * (File rows are ScopedRecords, so /file/:id can't serve them cross-user); logged-out requests
39
+ * get 401; emoji/no-avatar users get 404 (clients render emoji/initials from the user record).
40
+ * Only the file id stored on the user row is ever served.
41
+ */
42
+ describe('GET /avatar/:userId', () => {
43
+ beforeAll(async () => {
44
+ await testEnv.beforeAll();
45
+ });
46
+
47
+ afterAll(async () => {
48
+ await testEnv.afterAll();
49
+ });
50
+
51
+ it("serves another user's avatar photo to any logged-in (non-admin) user, cacheable by fileId", async () => {
52
+ const owner = await testEnv.createUser({ name: 'Owner', email: 'owner@test.local' });
53
+ testEnv.actAs(owner);
54
+ const png = await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 5, g: 150, b: 90 } } })
55
+ .png()
56
+ .toBuffer();
57
+ const updated = await new UpdateUserInfo().updateAvatarPhoto(png.toString('base64'), 'image/png');
58
+
59
+ // A DIFFERENT, non-admin user fetches it — the file row is scoped to the owner, so this
60
+ // only works through the route's narrow system read.
61
+ const viewer = await testEnv.createUser({ name: 'Viewer', email: 'viewer@test.local', roles: [] });
62
+ testEnv.actAs(viewer);
63
+ const response = await requestAvatar(owner.id);
64
+
65
+ expect(response.statusCode).toBe(200);
66
+ expect(response.headers['content-type']).toBe('image/jpeg');
67
+ expect(response.headers['cache-control']).toBe('public, max-age=86400, immutable');
68
+ const bytes = response.body as Buffer;
69
+ expect(Buffer.isBuffer(bytes)).toBe(true);
70
+ expect(bytes[0]).toBe(0xff); // JPEG SOI
71
+ expect(bytes[1]).toBe(0xd8);
72
+ const metadata = await sharp(bytes).metadata();
73
+ expect(metadata.width).toBe(512);
74
+ expect(metadata.height).toBe(512);
75
+
76
+ // The client-side URL contract carries the cache-buster.
77
+ expect(avatarRoute.path(owner.id, updated.avatarFileId!)).toBe(`/avatar/${owner.id}?v=${updated.avatarFileId}`);
78
+ });
79
+
80
+ it('404s for an emoji-avatar user and a no-avatar user (photos only)', async () => {
81
+ const emojiUser = await testEnv.createUser({ name: 'Emoji', email: 'emoji-route@test.local' });
82
+ testEnv.actAs(emojiUser);
83
+ await new UpdateUserInfo().updateAvatarEmoji('🦀');
84
+ const bareUser = await testEnv.createUser({ name: 'Bare', email: 'bare-route@test.local' });
85
+
86
+ testEnv.actAs(bareUser);
87
+ expect((await requestAvatar(emojiUser.id)).statusCode).toBe(404);
88
+ expect((await requestAvatar(bareUser.id)).statusCode).toBe(404);
89
+ });
90
+
91
+ it('404s for unknown ids — :userId is a keyed lookup, never a path', async () => {
92
+ const viewer = await testEnv.createUser({ name: 'Curious', email: 'curious@test.local' });
93
+ testEnv.actAs(viewer);
94
+ expect((await requestAvatar('no-such-user')).statusCode).toBe(404);
95
+ expect((await requestAvatar('../../etc/passwd')).statusCode).toBe(404);
96
+ });
97
+
98
+ it('401s without a logged-in session', async () => {
99
+ const owner = await testEnv.createUser({ name: 'Private', email: 'private@test.local' });
100
+ testEnv.actAs(owner);
101
+ const png = await sharp({ create: { width: 32, height: 32, channels: 3, background: { r: 9, g: 9, b: 9 } } })
102
+ .png()
103
+ .toBuffer();
104
+ await new UpdateUserInfo().updateAvatarPhoto(png.toString('base64'), 'image/png');
105
+
106
+ testEnv.actAs(guestUser as any);
107
+ const response = await requestAvatar(owner.id);
108
+ expect(response.statusCode).toBe(401);
109
+ expect(response.body).not.toBeInstanceOf(Buffer);
110
+ });
111
+ });
@@ -0,0 +1,11 @@
1
+ 'use strict';
2
+ // jest runs this in the main jest process before any test workers (and before setupFiles), so one
3
+ // whole `jest --runInBand` run holds the emulator exclusively — concurrent suites from other
4
+ // packages wait instead of corrupting each other. globalSetup runs before setupFiles, so require
5
+ // ./setup here to compute the same SPANNER_EMULATOR_HOST the tests will use.
6
+ require('./setup');
7
+ const { acquire } = require('./emulatorLock');
8
+
9
+ module.exports = async () => {
10
+ await acquire(process.env.SPANNER_EMULATOR_HOST, require('../package.json').name);
11
+ };
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+ // Releases the emulator lock acquired by ./emulatorLock.globalSetup.js (same jest parent process).
3
+ require('./setup');
4
+ const { release } = require('./emulatorLock');
5
+
6
+ module.exports = async () => {
7
+ release(process.env.SPANNER_EMULATOR_HOST, require('../package.json').name);
8
+ };
@@ -0,0 +1,124 @@
1
+ 'use strict';
2
+ /**
3
+ * Cross-process mutual exclusion around a Spanner emulator.
4
+ *
5
+ * `--runInBand` serializes tests within one package; nothing serializes across packages, and two
6
+ * jest runs hitting the same emulator corrupt each other (orphaned transactions, DDL
7
+ * FAILED_PRECONDITION). This lock closes that gap for this package: a lockfile in os.tmpdir()
8
+ * keyed by the emulator host string, held for the whole jest run via globalSetup/globalTeardown.
9
+ *
10
+ * The lockfile PATH FORMULA must stay byte-identical to the fleet's canonical implementation
11
+ * (`EmulatorLock.lockFilePathFor` in @n3xah/thought-common/test) so suites from every repo
12
+ * pointed at the same emulator actually contend on the same file. The logic is duplicated here
13
+ * because this repo sits BELOW @n3xah in the dependency layering (thought-common itself depends
14
+ * on @proteinjs/user) — future extraction target: @proteinjs/db-driver-spanner/test, beside
15
+ * SpannerEmulatorProvisioner.
16
+ */
17
+ const crypto = require('crypto');
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+
22
+ const POLL_MS = 1000;
23
+ const WAIT_LOG_EVERY_MS = 30_000;
24
+ const TIMEOUT_MS = 60 * 60_000; // full DB suites elsewhere legitimately run 30+ minutes
25
+
26
+ /** Deterministic lockfile path for an emulator host — sanitized slug plus short hash. */
27
+ function lockFilePathFor(host) {
28
+ const slug = host
29
+ .replace(/[^a-zA-Z0-9]+/g, '-')
30
+ .replace(/^-+|-+$/g, '')
31
+ .slice(0, 64);
32
+ const hash = crypto.createHash('sha1').update(host).digest('hex').slice(0, 8);
33
+ return path.join(os.tmpdir(), `n3xa-spanner-emulator-${slug}-${hash}.lock`);
34
+ }
35
+
36
+ function isPidAlive(pid) {
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch (error) {
41
+ return error.code === 'EPERM'; // alive but not ours; ESRCH = gone
42
+ }
43
+ }
44
+
45
+ function tryCreate(lockFilePath, packageName) {
46
+ const info = {
47
+ pid: process.pid,
48
+ packageName,
49
+ timestamp: new Date().toISOString(),
50
+ nonce: crypto.randomBytes(8).toString('hex'),
51
+ };
52
+ try {
53
+ fs.writeFileSync(lockFilePath, JSON.stringify(info), { flag: 'wx' });
54
+ return true;
55
+ } catch (error) {
56
+ if (error.code === 'EEXIST') {
57
+ return false;
58
+ }
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ function readLockInfo(lockFilePath) {
64
+ try {
65
+ return JSON.parse(fs.readFileSync(lockFilePath, 'utf8'));
66
+ } catch {
67
+ return undefined; // gone, or unparseable (treated as stale)
68
+ }
69
+ }
70
+
71
+ async function acquire(host, packageName) {
72
+ const lockFilePath = lockFilePathFor(host);
73
+ const start = Date.now();
74
+ let lastWaitLog = 0;
75
+ for (;;) {
76
+ if (tryCreate(lockFilePath, packageName)) {
77
+ return;
78
+ }
79
+ const info = readLockInfo(lockFilePath);
80
+ if (!info || !isPidAlive(info.pid)) {
81
+ // Stale (crashed run) — remove and retry; the O_EXCL create above decides the winner.
82
+ try {
83
+ fs.unlinkSync(lockFilePath);
84
+ } catch (error) {
85
+ if (error.code !== 'ENOENT') {
86
+ throw error;
87
+ }
88
+ }
89
+ } else {
90
+ const now = Date.now();
91
+ if (now - start >= TIMEOUT_MS) {
92
+ throw new Error(
93
+ `timed out waiting for emulator lock (${host}) held by pid ${info.pid} (${info.packageName}); ` +
94
+ `lockfile: ${lockFilePath}`
95
+ );
96
+ }
97
+ if (lastWaitLog === 0 || now - lastWaitLog >= WAIT_LOG_EVERY_MS) {
98
+ console.log(
99
+ `[${packageName}] waiting for emulator lock (${host}) held by pid ${info.pid} ` +
100
+ `(${info.packageName}, since ${info.timestamp}) — waited ${Math.round((now - start) / 1000)}s`
101
+ );
102
+ lastWaitLog = now;
103
+ }
104
+ }
105
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
106
+ }
107
+ }
108
+
109
+ function release(host, packageName) {
110
+ const lockFilePath = lockFilePathFor(host);
111
+ const info = readLockInfo(lockFilePath);
112
+ if (!info) {
113
+ console.log(`[${packageName}] emulator lock (${host}) was already gone at release`);
114
+ return;
115
+ }
116
+ if (info.pid !== process.pid) {
117
+ throw new Error(
118
+ `refusing to release emulator lock (${host}) owned by pid ${info.pid} (${info.packageName}), not us (${process.pid})`
119
+ );
120
+ }
121
+ fs.unlinkSync(lockFilePath);
122
+ }
123
+
124
+ module.exports = { acquire, release, lockFilePathFor };
Binary file
package/tsconfig.json CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "noEmitOnError": true,
3
4
  "rootDir": "./",
4
5
  "target": "es5",
5
6
  "module": "commonjs",