Skip to content

Commit 2dba053

Browse files
feat(auth): include user.complementary in /token response
Generalizes the downstream patch applied by trawl_node: the `token` handler now projects `complementary` alongside the other user fields so per-user UI prefs / extras rehydrate across full-page refresh without local override. Adds regression test (auth.token.controller.unit.tests.js) to lock the field in the projection going forward. Closes #3738
1 parent de86c6b commit 2dba053

2 files changed

Lines changed: 174 additions & 0 deletions

File tree

modules/auth/controllers/auth.controller.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ const token = async (req, res) => {
307307
emailVerified: req.user.emailVerified,
308308
currentOrganization: req.user.currentOrganization,
309309
lastLoginAt: req.user.lastLoginAt,
310+
complementary: req.user.complementary,
310311
};
311312
}
312313

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* Module dependencies.
3+
*
4+
* Unit tests for auth.controller `token` handler.
5+
* Regression guard: /api/auth/token must include `complementary` in the user
6+
* projection so per-user UI prefs / extras rehydrate correctly across refresh.
7+
*/
8+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
9+
10+
describe('auth.controller.token — user projection:', () => {
11+
beforeEach(() => {
12+
jest.resetModules();
13+
14+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
15+
default: { warn: jest.fn(), error: jest.fn(), info: jest.fn() },
16+
}));
17+
18+
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
19+
default: { getBrut: jest.fn(), update: jest.fn(), create: jest.fn(), remove: jest.fn() },
20+
}));
21+
22+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
23+
default: { handleSignupOrganization: jest.fn() },
24+
}));
25+
26+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.crud.service.js', () => ({
27+
default: { autoSetCurrentOrganization: jest.fn().mockResolvedValue(undefined) },
28+
}));
29+
30+
jest.unstable_mockModule('../../../modules/organizations/services/organizations.membership.service.js', () => ({
31+
default: {
32+
findByUserAndOrganization: jest.fn().mockResolvedValue(null),
33+
listPendingByUser: jest.fn().mockResolvedValue([]),
34+
},
35+
}));
36+
37+
jest.unstable_mockModule('../../../config/index.js', () => ({
38+
default: {
39+
sign: { up: true, in: true },
40+
jwt: { secret: 'test-secret', expiresIn: 3600 },
41+
cookie: { secure: false, sameSite: 'lax' },
42+
organizations: { enabled: false },
43+
app: { title: 'Test', contact: 'test@test.com' },
44+
},
45+
}));
46+
47+
jest.unstable_mockModule('../../../lib/middlewares/model.js', () => ({
48+
default: { getResultFromZod: jest.fn(), checkError: jest.fn() },
49+
}));
50+
51+
jest.unstable_mockModule('../../../lib/helpers/mailer/index.js', () => ({
52+
default: { isConfigured: jest.fn().mockReturnValue(false), sendMail: jest.fn() },
53+
}));
54+
55+
jest.unstable_mockModule('../../../lib/helpers/responses.js', () => ({
56+
default: {
57+
success: jest.fn().mockReturnValue(jest.fn()),
58+
error: jest.fn().mockReturnValue(jest.fn()),
59+
},
60+
}));
61+
62+
jest.unstable_mockModule('../../../lib/helpers/errors.js', () => ({
63+
default: { getMessage: jest.fn().mockReturnValue('error') },
64+
}));
65+
66+
jest.unstable_mockModule('../../../lib/helpers/AppError.js', () => ({
67+
default: class AppError extends Error {
68+
constructor(msg, opts) {
69+
super(msg);
70+
this.code = opts?.code;
71+
this.details = opts?.details;
72+
}
73+
},
74+
}));
75+
76+
jest.unstable_mockModule('../../../modules/users/models/users.schema.js', () => ({
77+
default: { User: {} },
78+
}));
79+
80+
jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
81+
default: { defineAbilityFor: jest.fn().mockResolvedValue({}) },
82+
}));
83+
84+
jest.unstable_mockModule('../../../lib/helpers/abilities.js', () => ({
85+
default: jest.fn().mockReturnValue([]),
86+
}));
87+
88+
jest.unstable_mockModule('../../../lib/helpers/getBaseUrl.js', () => ({
89+
default: jest.fn().mockReturnValue('http://localhost:3000'),
90+
}));
91+
92+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
93+
default: { identify: jest.fn(), groupIdentify: jest.fn() },
94+
}));
95+
96+
// Mock invite-only gate (auth.controller imports InvitationService)
97+
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
98+
default: {
99+
validateInviteToken: jest.fn().mockResolvedValue(null),
100+
consumeInviteToken: jest.fn().mockResolvedValue(undefined),
101+
},
102+
}));
103+
});
104+
105+
test('should include `complementary` in the returned user object (regression guard: per-user UI prefs / extras must rehydrate across refresh)', async () => {
106+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
107+
108+
const req = {
109+
user: {
110+
id: 'u1',
111+
provider: 'local',
112+
roles: ['user'],
113+
avatar: '',
114+
email: 'a@b.com',
115+
lastName: 'Doe',
116+
firstName: 'Jane',
117+
additionalProvidersData: {},
118+
emailVerified: true,
119+
currentOrganization: null,
120+
lastLoginAt: new Date(0),
121+
complementary: { ui: { layout: 'grid' } },
122+
},
123+
};
124+
const res = {
125+
status: jest.fn().mockReturnThis(),
126+
cookie: jest.fn().mockReturnThis(),
127+
json: jest.fn().mockReturnThis(),
128+
};
129+
130+
await AuthController.token(req, res);
131+
132+
expect(res.json).toHaveBeenCalledTimes(1);
133+
const payload = res.json.mock.calls[0][0];
134+
expect(payload.user).toBeDefined();
135+
expect(payload.user.complementary).toEqual({ ui: { layout: 'grid' } });
136+
// Sanity — existing projection keys still present
137+
expect(payload.user.id).toBe('u1');
138+
expect(payload.user.email).toBe('a@b.com');
139+
});
140+
141+
test('should preserve a missing/undefined `complementary` as undefined (no crash)', async () => {
142+
const { default: AuthController } = await import('../../../modules/auth/controllers/auth.controller.js');
143+
144+
const req = {
145+
user: {
146+
id: 'u2',
147+
provider: 'local',
148+
roles: ['user'],
149+
avatar: '',
150+
email: 'c@d.com',
151+
lastName: 'Smith',
152+
firstName: 'John',
153+
additionalProvidersData: {},
154+
emailVerified: true,
155+
currentOrganization: null,
156+
lastLoginAt: new Date(0),
157+
// complementary intentionally omitted
158+
},
159+
};
160+
const res = {
161+
status: jest.fn().mockReturnThis(),
162+
cookie: jest.fn().mockReturnThis(),
163+
json: jest.fn().mockReturnThis(),
164+
};
165+
166+
await AuthController.token(req, res);
167+
168+
const payload = res.json.mock.calls[0][0];
169+
expect(payload.user).toBeDefined();
170+
expect('complementary' in payload.user).toBe(true);
171+
expect(payload.user.complementary).toBeUndefined();
172+
});
173+
});

0 commit comments

Comments
 (0)