@oxyhq/core 3.2.0 → 3.4.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +3 -1
- package/dist/cjs/HttpService.js +89 -0
- package/dist/cjs/OxyServices.js +1 -1
- package/dist/cjs/constants/version.js +1 -1
- package/dist/cjs/i18n/locales/en-US.json +44 -44
- package/dist/cjs/i18n/locales/es-ES.json +44 -44
- package/dist/cjs/i18n/locales/locales/en-US.json +44 -44
- package/dist/cjs/i18n/locales/locales/es-ES.json +44 -44
- package/dist/cjs/index.js +4 -0
- package/dist/cjs/mixins/OxyServices.applications.js +3 -1
- package/dist/cjs/mixins/OxyServices.reputation.js +244 -0
- package/dist/cjs/mixins/OxyServices.workspaces.js +3 -1
- package/dist/cjs/mixins/index.js +2 -2
- package/dist/cjs/utils/accountUtils.js +12 -5
- package/dist/cjs/utils/ssoReturn.js +80 -33
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +3 -1
- package/dist/esm/HttpService.js +89 -0
- package/dist/esm/OxyServices.js +1 -1
- package/dist/esm/constants/version.js +1 -1
- package/dist/esm/i18n/locales/en-US.json +44 -44
- package/dist/esm/i18n/locales/es-ES.json +44 -44
- package/dist/esm/i18n/locales/locales/en-US.json +44 -44
- package/dist/esm/i18n/locales/locales/es-ES.json +44 -44
- package/dist/esm/index.js +4 -0
- package/dist/esm/mixins/OxyServices.applications.js +3 -1
- package/dist/esm/mixins/OxyServices.reputation.js +241 -0
- package/dist/esm/mixins/OxyServices.workspaces.js +3 -1
- package/dist/esm/mixins/index.js +2 -2
- package/dist/esm/utils/accountUtils.js +12 -5
- package/dist/esm/utils/ssoReturn.js +80 -33
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +57 -0
- package/dist/types/OxyServices.d.ts +1 -1
- package/dist/types/constants/version.d.ts +2 -2
- package/dist/types/index.d.ts +2 -1
- package/dist/types/mixins/OxyServices.applications.d.ts +8 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -1
- package/dist/types/mixins/OxyServices.reputation.d.ts +436 -0
- package/dist/types/mixins/OxyServices.workspaces.d.ts +8 -2
- package/dist/types/mixins/index.d.ts +2 -2
- package/dist/types/models/interfaces.d.ts +15 -26
- package/dist/types/utils/accountUtils.d.ts +17 -4
- package/dist/types/utils/ssoReturn.d.ts +30 -9
- package/package.json +2 -1
- package/src/AuthManager.ts +3 -1
- package/src/HttpService.ts +91 -0
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/httpServiceCache.test.ts +198 -0
- package/src/constants/version.ts +1 -1
- package/src/i18n/locales/en-US.json +44 -44
- package/src/i18n/locales/es-ES.json +44 -44
- package/src/index.ts +32 -4
- package/src/mixins/OxyServices.applications.ts +8 -2
- package/src/mixins/OxyServices.auth.ts +2 -1
- package/src/mixins/OxyServices.features.ts +0 -1
- package/src/mixins/OxyServices.reputation.ts +674 -0
- package/src/mixins/OxyServices.workspaces.ts +8 -2
- package/src/mixins/__tests__/reputation.test.ts +408 -0
- package/src/mixins/index.ts +3 -3
- package/src/models/interfaces.ts +16 -32
- package/src/utils/__tests__/accountUtils.test.ts +142 -0
- package/src/utils/__tests__/consumeSsoReturn.test.ts +229 -37
- package/src/utils/accountUtils.ts +20 -5
- package/src/utils/ssoReturn.ts +98 -37
- package/dist/cjs/mixins/OxyServices.developer.js +0 -97
- package/dist/cjs/mixins/OxyServices.karma.js +0 -108
- package/dist/esm/mixins/OxyServices.developer.js +0 -94
- package/dist/esm/mixins/OxyServices.karma.js +0 -105
- package/dist/types/mixins/OxyServices.developer.d.ts +0 -106
- package/dist/types/mixins/OxyServices.karma.d.ts +0 -92
- package/src/mixins/OxyServices.karma.ts +0 -111
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reputation Mixin Tests (Oxy Trust)
|
|
3
|
+
*
|
|
4
|
+
* Exercises the typed helpers around `/reputation/...`. We stub `makeRequest`
|
|
5
|
+
* so the tests run without a network or a database — what we care about here is
|
|
6
|
+
* request shape (method, URL, query params, body, cache options), the response
|
|
7
|
+
* envelope handling (direct object vs `{ data }` paginated vs `{ rules }` /
|
|
8
|
+
* `{ transaction }` / `{ dispute }` wrappers), default fallbacks on missing
|
|
9
|
+
* fields, path-segment URL-encoding, and cache invalidation on writes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { OxyServices } from '../../OxyServices';
|
|
13
|
+
import type {
|
|
14
|
+
ReputationBalance,
|
|
15
|
+
ReputationTransaction,
|
|
16
|
+
ReputationDispute,
|
|
17
|
+
ReputationRule,
|
|
18
|
+
ReputationLeaderboardEntry,
|
|
19
|
+
ReputationInfluenceResult,
|
|
20
|
+
} from '../OxyServices.reputation';
|
|
21
|
+
|
|
22
|
+
const setAccessTokenForTest = (oxy: OxyServices): void => {
|
|
23
|
+
oxy.httpService.setTokens('test-token', '');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const balanceFixture: ReputationBalance = {
|
|
27
|
+
userId: 'u1',
|
|
28
|
+
total: 120,
|
|
29
|
+
positive: 150,
|
|
30
|
+
negative: -30,
|
|
31
|
+
breakdown: {
|
|
32
|
+
content: 80,
|
|
33
|
+
social: 40,
|
|
34
|
+
trust: 0,
|
|
35
|
+
moderation: 0,
|
|
36
|
+
physical: 0,
|
|
37
|
+
penalties: 30,
|
|
38
|
+
},
|
|
39
|
+
trustTier: 'trusted',
|
|
40
|
+
influence: {
|
|
41
|
+
defaultWeight: 1.0,
|
|
42
|
+
reportWeight: 1.0,
|
|
43
|
+
moderationWeight: 1.0,
|
|
44
|
+
rankingFeedbackWeight: 0.8,
|
|
45
|
+
},
|
|
46
|
+
reliability: {
|
|
47
|
+
accurateReports: 2,
|
|
48
|
+
rejectedReports: 0,
|
|
49
|
+
reportAccuracyScore: 1,
|
|
50
|
+
abuseScore: 0,
|
|
51
|
+
},
|
|
52
|
+
recalculatedAt: '2026-06-16T00:00:00.000Z',
|
|
53
|
+
updatedAt: '2026-06-16T00:00:00.000Z',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const transactionFixture: ReputationTransaction = {
|
|
57
|
+
id: 't1',
|
|
58
|
+
userId: 'u1',
|
|
59
|
+
points: 10,
|
|
60
|
+
actionType: 'post_created',
|
|
61
|
+
category: 'content',
|
|
62
|
+
status: 'active',
|
|
63
|
+
createdAt: '2026-06-16T00:00:00.000Z',
|
|
64
|
+
updatedAt: '2026-06-16T00:00:00.000Z',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const disputeFixture: ReputationDispute = {
|
|
68
|
+
id: 'd1',
|
|
69
|
+
transactionId: 't1',
|
|
70
|
+
userId: 'u1',
|
|
71
|
+
reason: 'I did not do this',
|
|
72
|
+
status: 'open',
|
|
73
|
+
createdAt: '2026-06-16T00:00:00.000Z',
|
|
74
|
+
updatedAt: '2026-06-16T00:00:00.000Z',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const ruleFixture: ReputationRule = {
|
|
78
|
+
id: 'r1',
|
|
79
|
+
actionType: 'post_created',
|
|
80
|
+
points: 10,
|
|
81
|
+
category: 'content',
|
|
82
|
+
description: 'Created a post',
|
|
83
|
+
cooldownInMinutes: 0,
|
|
84
|
+
isEnabled: true,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
describe('OxyServices.reputation', () => {
|
|
88
|
+
let oxy: OxyServices;
|
|
89
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
90
|
+
let clearCacheSpy: jest.SpyInstance;
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
94
|
+
setAccessTokenForTest(oxy);
|
|
95
|
+
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
96
|
+
clearCacheSpy = jest.spyOn(oxy, 'clearCacheByPrefix');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
makeRequestSpy.mockRestore();
|
|
101
|
+
clearCacheSpy.mockRestore();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('getReputationBalance', () => {
|
|
105
|
+
it('returns the balance object directly and caches the read', async () => {
|
|
106
|
+
makeRequestSpy.mockResolvedValue(balanceFixture);
|
|
107
|
+
|
|
108
|
+
const result = await oxy.getReputationBalance('u1');
|
|
109
|
+
|
|
110
|
+
expect(result).toEqual(balanceFixture);
|
|
111
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
112
|
+
'GET',
|
|
113
|
+
'/reputation/u1/balance',
|
|
114
|
+
undefined,
|
|
115
|
+
expect.objectContaining({ cache: true }),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('URL-encodes the userId path segment', async () => {
|
|
120
|
+
makeRequestSpy.mockResolvedValue(balanceFixture);
|
|
121
|
+
await oxy.getReputationBalance('a b/c');
|
|
122
|
+
expect(makeRequestSpy.mock.calls[0][1]).toBe('/reputation/a%20b%2Fc/balance');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('getReputationLeaderboard', () => {
|
|
127
|
+
it('unwraps the paginated `data` array and omits empty query params', async () => {
|
|
128
|
+
const entries: ReputationLeaderboardEntry[] = [
|
|
129
|
+
{
|
|
130
|
+
user: { id: 'u1', username: 'alice', name: { full: 'Alice' }, avatar: 'a', publicKey: 'pk1' },
|
|
131
|
+
total: 120,
|
|
132
|
+
trustTier: 'trusted',
|
|
133
|
+
rank: 1,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
makeRequestSpy.mockResolvedValue({ data: entries });
|
|
137
|
+
|
|
138
|
+
const result = await oxy.getReputationLeaderboard();
|
|
139
|
+
|
|
140
|
+
expect(result).toEqual(entries);
|
|
141
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
142
|
+
'GET',
|
|
143
|
+
'/reputation/leaderboard',
|
|
144
|
+
undefined,
|
|
145
|
+
expect.objectContaining({ cache: true }),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('passes limit/offset as query params when provided', async () => {
|
|
150
|
+
makeRequestSpy.mockResolvedValue({ data: [] });
|
|
151
|
+
await oxy.getReputationLeaderboard(25, 50);
|
|
152
|
+
expect(makeRequestSpy.mock.calls[0][2]).toEqual({ limit: 25, offset: 50 });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns an empty array when `data` is absent', async () => {
|
|
156
|
+
makeRequestSpy.mockResolvedValue({});
|
|
157
|
+
const result = await oxy.getReputationLeaderboard();
|
|
158
|
+
expect(result).toEqual([]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('getReputationRules', () => {
|
|
163
|
+
it('unwraps the `rules` array and uses a long-lived cache', async () => {
|
|
164
|
+
makeRequestSpy.mockResolvedValue({ rules: [ruleFixture] });
|
|
165
|
+
|
|
166
|
+
const result = await oxy.getReputationRules();
|
|
167
|
+
|
|
168
|
+
expect(result).toEqual([ruleFixture]);
|
|
169
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
170
|
+
'GET',
|
|
171
|
+
'/reputation/rules',
|
|
172
|
+
undefined,
|
|
173
|
+
expect.objectContaining({ cache: true }),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns an empty array when `rules` is absent', async () => {
|
|
178
|
+
makeRequestSpy.mockResolvedValue({});
|
|
179
|
+
expect(await oxy.getReputationRules()).toEqual([]);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('getReputationTransactions', () => {
|
|
184
|
+
it('unwraps the paginated `data` array', async () => {
|
|
185
|
+
makeRequestSpy.mockResolvedValue({ data: [transactionFixture] });
|
|
186
|
+
|
|
187
|
+
const result = await oxy.getReputationTransactions('u1', 10);
|
|
188
|
+
|
|
189
|
+
expect(result).toEqual([transactionFixture]);
|
|
190
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
191
|
+
'GET',
|
|
192
|
+
'/reputation/u1/transactions',
|
|
193
|
+
{ limit: 10 },
|
|
194
|
+
expect.objectContaining({ cache: true }),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns an empty array when `data` is absent', async () => {
|
|
199
|
+
makeRequestSpy.mockResolvedValue({});
|
|
200
|
+
expect(await oxy.getReputationTransactions('u1')).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('getReputationInfluence', () => {
|
|
205
|
+
it('returns the influence result directly and passes the context query', async () => {
|
|
206
|
+
const influence: ReputationInfluenceResult = {
|
|
207
|
+
context: 'report',
|
|
208
|
+
weight: 1.5,
|
|
209
|
+
influence: balanceFixture.influence,
|
|
210
|
+
};
|
|
211
|
+
makeRequestSpy.mockResolvedValue(influence);
|
|
212
|
+
|
|
213
|
+
const result = await oxy.getReputationInfluence('u1', 'report');
|
|
214
|
+
|
|
215
|
+
expect(result).toEqual(influence);
|
|
216
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
217
|
+
'GET',
|
|
218
|
+
'/reputation/u1/influence',
|
|
219
|
+
{ context: 'report' },
|
|
220
|
+
expect.objectContaining({ cache: true }),
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('omits the context query when not provided', async () => {
|
|
225
|
+
makeRequestSpy.mockResolvedValue({
|
|
226
|
+
context: 'default',
|
|
227
|
+
weight: 1,
|
|
228
|
+
influence: balanceFixture.influence,
|
|
229
|
+
});
|
|
230
|
+
await oxy.getReputationInfluence('u1');
|
|
231
|
+
expect(makeRequestSpy.mock.calls[0][2]).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('awardReputation', () => {
|
|
236
|
+
it('posts the payload, unwraps `transaction`, and invalidates the cache', async () => {
|
|
237
|
+
makeRequestSpy.mockResolvedValue({ transaction: transactionFixture });
|
|
238
|
+
|
|
239
|
+
const result = await oxy.awardReputation({ userId: 'u1', actionType: 'post_created' });
|
|
240
|
+
|
|
241
|
+
expect(result).toEqual(transactionFixture);
|
|
242
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
243
|
+
'POST',
|
|
244
|
+
'/reputation/award',
|
|
245
|
+
{ userId: 'u1', actionType: 'post_created' },
|
|
246
|
+
expect.objectContaining({ cache: false }),
|
|
247
|
+
);
|
|
248
|
+
expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('createReputationDispute', () => {
|
|
253
|
+
it('posts the payload, unwraps `dispute`, and invalidates the cache', async () => {
|
|
254
|
+
makeRequestSpy.mockResolvedValue({ dispute: disputeFixture });
|
|
255
|
+
|
|
256
|
+
const result = await oxy.createReputationDispute({
|
|
257
|
+
transactionId: 't1',
|
|
258
|
+
reason: 'I did not do this',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(result).toEqual(disputeFixture);
|
|
262
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
263
|
+
'POST',
|
|
264
|
+
'/reputation/disputes',
|
|
265
|
+
{ transactionId: 't1', reason: 'I did not do this' },
|
|
266
|
+
expect.objectContaining({ cache: false }),
|
|
267
|
+
);
|
|
268
|
+
expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('getUserReputationDisputes', () => {
|
|
273
|
+
it('unwraps the paginated `data` array', async () => {
|
|
274
|
+
makeRequestSpy.mockResolvedValue({ data: [disputeFixture] });
|
|
275
|
+
const result = await oxy.getUserReputationDisputes('u1', 5, 10);
|
|
276
|
+
expect(result).toEqual([disputeFixture]);
|
|
277
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
278
|
+
'GET',
|
|
279
|
+
'/reputation/u1/disputes',
|
|
280
|
+
{ limit: 5, offset: 10 },
|
|
281
|
+
expect.objectContaining({ cache: true }),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('upsertReputationRule', () => {
|
|
287
|
+
it('posts the rule, unwraps `rule`, and invalidates the cache', async () => {
|
|
288
|
+
makeRequestSpy.mockResolvedValue({ rule: ruleFixture });
|
|
289
|
+
|
|
290
|
+
const result = await oxy.upsertReputationRule({
|
|
291
|
+
actionType: 'post_created',
|
|
292
|
+
points: 10,
|
|
293
|
+
category: 'content',
|
|
294
|
+
description: 'Created a post',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(result).toEqual(ruleFixture);
|
|
298
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
299
|
+
'POST',
|
|
300
|
+
'/reputation/rules',
|
|
301
|
+
expect.objectContaining({ actionType: 'post_created', points: 10 }),
|
|
302
|
+
expect.objectContaining({ cache: false }),
|
|
303
|
+
);
|
|
304
|
+
expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('reverseReputationTransaction', () => {
|
|
309
|
+
it('returns the { original, reversal } pair and invalidates the cache', async () => {
|
|
310
|
+
const reversal = { ...transactionFixture, id: 't2', points: -10, reversedTransactionId: 't1' };
|
|
311
|
+
const original = { ...transactionFixture, status: 'reversed' as const };
|
|
312
|
+
makeRequestSpy.mockResolvedValue({ original, reversal });
|
|
313
|
+
|
|
314
|
+
const result = await oxy.reverseReputationTransaction('t1', { reason: 'mistake' });
|
|
315
|
+
|
|
316
|
+
expect(result).toEqual({ original, reversal });
|
|
317
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
318
|
+
'POST',
|
|
319
|
+
'/reputation/transactions/t1/reverse',
|
|
320
|
+
{ reason: 'mistake' },
|
|
321
|
+
expect.objectContaining({ cache: false }),
|
|
322
|
+
);
|
|
323
|
+
expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('sends an empty body when no input is given', async () => {
|
|
327
|
+
makeRequestSpy.mockResolvedValue({ original: transactionFixture, reversal: transactionFixture });
|
|
328
|
+
await oxy.reverseReputationTransaction('t1');
|
|
329
|
+
expect(makeRequestSpy.mock.calls[0][2]).toEqual({});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe('voidReputationTransaction', () => {
|
|
334
|
+
it('unwraps `transaction` and invalidates the cache', async () => {
|
|
335
|
+
const voided = { ...transactionFixture, status: 'voided' as const };
|
|
336
|
+
makeRequestSpy.mockResolvedValue({ transaction: voided });
|
|
337
|
+
|
|
338
|
+
const result = await oxy.voidReputationTransaction('t1');
|
|
339
|
+
|
|
340
|
+
expect(result).toEqual(voided);
|
|
341
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
342
|
+
'POST',
|
|
343
|
+
'/reputation/transactions/t1/void',
|
|
344
|
+
{},
|
|
345
|
+
expect.objectContaining({ cache: false }),
|
|
346
|
+
);
|
|
347
|
+
expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('recalculateReputation', () => {
|
|
352
|
+
it('returns the recomputed balance directly and invalidates the cache', async () => {
|
|
353
|
+
makeRequestSpy.mockResolvedValue(balanceFixture);
|
|
354
|
+
|
|
355
|
+
const result = await oxy.recalculateReputation('u1');
|
|
356
|
+
|
|
357
|
+
expect(result).toEqual(balanceFixture);
|
|
358
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
359
|
+
'POST',
|
|
360
|
+
'/reputation/u1/recalculate',
|
|
361
|
+
undefined,
|
|
362
|
+
expect.objectContaining({ cache: false }),
|
|
363
|
+
);
|
|
364
|
+
expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe('getReputationDisputeQueue', () => {
|
|
369
|
+
it('unwraps the paginated `data` array', async () => {
|
|
370
|
+
makeRequestSpy.mockResolvedValue({ data: [disputeFixture] });
|
|
371
|
+
const result = await oxy.getReputationDisputeQueue(20);
|
|
372
|
+
expect(result).toEqual([disputeFixture]);
|
|
373
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
374
|
+
'GET',
|
|
375
|
+
'/reputation/disputes',
|
|
376
|
+
{ limit: 20 },
|
|
377
|
+
expect.objectContaining({ cache: true }),
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe('resolveReputationDispute', () => {
|
|
383
|
+
it('posts the resolution, unwraps `dispute`, and invalidates the cache', async () => {
|
|
384
|
+
const resolved = { ...disputeFixture, status: 'accepted' as const };
|
|
385
|
+
makeRequestSpy.mockResolvedValue({ dispute: resolved });
|
|
386
|
+
|
|
387
|
+
const result = await oxy.resolveReputationDispute('d1', { status: 'accepted' });
|
|
388
|
+
|
|
389
|
+
expect(result).toEqual(resolved);
|
|
390
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
391
|
+
'POST',
|
|
392
|
+
'/reputation/disputes/d1/resolve',
|
|
393
|
+
{ status: 'accepted' },
|
|
394
|
+
expect.objectContaining({ cache: false }),
|
|
395
|
+
);
|
|
396
|
+
expect(clearCacheSpy).toHaveBeenCalledWith('GET:/reputation/');
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
describe('error handling', () => {
|
|
401
|
+
it('surfaces API errors via handleError', async () => {
|
|
402
|
+
makeRequestSpy.mockRejectedValue(
|
|
403
|
+
Object.assign(new Error('boom'), { response: { status: 500 } }),
|
|
404
|
+
);
|
|
405
|
+
await expect(oxy.getReputationBalance('u1')).rejects.toThrow();
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { OxyServicesUserMixin } from './OxyServices.user';
|
|
|
15
15
|
import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
|
|
16
16
|
import { OxyServicesLanguageMixin } from './OxyServices.language';
|
|
17
17
|
import { OxyServicesPaymentMixin } from './OxyServices.payment';
|
|
18
|
-
import {
|
|
18
|
+
import { OxyServicesReputationMixin } from './OxyServices.reputation';
|
|
19
19
|
import { OxyServicesAssetsMixin } from './OxyServices.assets';
|
|
20
20
|
import { OxyServicesApplicationsMixin } from './OxyServices.applications';
|
|
21
21
|
import { OxyServicesWorkspacesMixin } from './OxyServices.workspaces';
|
|
@@ -49,7 +49,7 @@ type AllMixinInstances =
|
|
|
49
49
|
& InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
|
|
50
50
|
& InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>>
|
|
51
51
|
& InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>>
|
|
52
|
-
& InstanceType<ReturnType<typeof
|
|
52
|
+
& InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>>
|
|
53
53
|
& InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>>
|
|
54
54
|
& InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>>
|
|
55
55
|
& InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<typeof OxyServicesBase>>>
|
|
@@ -114,7 +114,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
114
114
|
// Feature mixins
|
|
115
115
|
OxyServicesLanguageMixin,
|
|
116
116
|
OxyServicesPaymentMixin,
|
|
117
|
-
|
|
117
|
+
OxyServicesReputationMixin,
|
|
118
118
|
OxyServicesAssetsMixin,
|
|
119
119
|
OxyServicesApplicationsMixin,
|
|
120
120
|
OxyServicesWorkspacesMixin,
|
package/src/models/interfaces.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { UserNameResponse } from '@oxyhq/contracts';
|
|
2
|
+
|
|
1
3
|
export interface OxyConfig {
|
|
2
4
|
baseURL: string;
|
|
3
5
|
cloudURL?: string;
|
|
@@ -92,14 +94,14 @@ export interface User {
|
|
|
92
94
|
color?: string;
|
|
93
95
|
// Privacy and security settings
|
|
94
96
|
privacySettings?: PrivacySettings;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Structured human name. The canonical wire shape ({@link UserNameResponse}):
|
|
99
|
+
* `{ first?, last?, full? }` where `full` is a Mongoose virtual (present only
|
|
100
|
+
* when the query materialised virtuals). The single source of truth lives in
|
|
101
|
+
* `@oxyhq/contracts` — do NOT re-declare a bare `string` here.
|
|
102
|
+
*/
|
|
103
|
+
name?: UserNameResponse;
|
|
101
104
|
bio?: string;
|
|
102
|
-
karma?: number;
|
|
103
105
|
location?: string;
|
|
104
106
|
website?: string;
|
|
105
107
|
createdAt?: string;
|
|
@@ -261,30 +263,6 @@ export interface SearchProfilesResponse {
|
|
|
261
263
|
pagination: PaginationInfo;
|
|
262
264
|
}
|
|
263
265
|
|
|
264
|
-
export interface KarmaRule {
|
|
265
|
-
id: string;
|
|
266
|
-
description: string;
|
|
267
|
-
// Add other karma rule fields as needed
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export interface KarmaHistory {
|
|
271
|
-
id: string;
|
|
272
|
-
userId: string;
|
|
273
|
-
points: number;
|
|
274
|
-
// Add other karma history fields as needed
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export interface KarmaLeaderboardEntry {
|
|
278
|
-
userId: string;
|
|
279
|
-
total: number;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
export interface KarmaAwardRequest {
|
|
283
|
-
userId: string;
|
|
284
|
-
points: number;
|
|
285
|
-
reason?: string;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
266
|
export interface ApiError {
|
|
289
267
|
message: string;
|
|
290
268
|
code: string;
|
|
@@ -669,7 +647,13 @@ export interface UpdateDeviceNameResponse {
|
|
|
669
647
|
export interface RefreshAllAccountUser {
|
|
670
648
|
id: string;
|
|
671
649
|
username: string;
|
|
672
|
-
|
|
650
|
+
/**
|
|
651
|
+
* Structured human name as emitted by `formatUserResponse` (the canonical
|
|
652
|
+
* {@link UserNameResponse} `{ first?, last?, full? }` subdocument), NOT a bare
|
|
653
|
+
* string. The server projects `name` verbatim from the user document. The
|
|
654
|
+
* single source of truth is `@oxyhq/contracts`.
|
|
655
|
+
*/
|
|
656
|
+
name?: UserNameResponse;
|
|
673
657
|
avatar?: string | null;
|
|
674
658
|
email?: string;
|
|
675
659
|
color?: string | null;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getAccountDisplayName,
|
|
3
|
+
getAccountFallbackHandle,
|
|
4
|
+
formatPublicKeyHandle,
|
|
5
|
+
} from '../accountUtils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Regression coverage for the auth-app display-name drift (Phase 1 of the
|
|
9
|
+
* contract-centralisation refactor).
|
|
10
|
+
*
|
|
11
|
+
* The auth app previously required BOTH `name.first` AND `name.last` to compose
|
|
12
|
+
* a display name, falling back to the lowercase `username` for first-name-only
|
|
13
|
+
* accounts. The canonical resolver in core MUST be first-name-only safe: a user
|
|
14
|
+
* with only a first name resolves to that first name, never to the username.
|
|
15
|
+
*/
|
|
16
|
+
describe('getAccountDisplayName', () => {
|
|
17
|
+
it('returns first name for first-name-only accounts (NOT the username)', () => {
|
|
18
|
+
const result = getAccountDisplayName({
|
|
19
|
+
name: { first: 'Nate' },
|
|
20
|
+
username: 'nateus',
|
|
21
|
+
});
|
|
22
|
+
expect(result).toBe('Nate');
|
|
23
|
+
expect(result).not.toBe('nateus');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('composes first + last when both are present', () => {
|
|
27
|
+
expect(
|
|
28
|
+
getAccountDisplayName({
|
|
29
|
+
name: { first: 'Nate', last: 'Isern' },
|
|
30
|
+
username: 'nateus',
|
|
31
|
+
}),
|
|
32
|
+
).toBe('Nate Isern');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns last name only when first is missing', () => {
|
|
36
|
+
expect(
|
|
37
|
+
getAccountDisplayName({
|
|
38
|
+
name: { last: 'Isern' },
|
|
39
|
+
username: 'nateus',
|
|
40
|
+
}),
|
|
41
|
+
).toBe('Isern');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('prefers name.full when present', () => {
|
|
45
|
+
expect(
|
|
46
|
+
getAccountDisplayName({
|
|
47
|
+
name: { first: 'Nate', last: 'Isern', full: 'Nathaniel Isern' },
|
|
48
|
+
username: 'nateus',
|
|
49
|
+
}),
|
|
50
|
+
).toBe('Nathaniel Isern');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses the structured name over a server displayName virtual', () => {
|
|
54
|
+
// Server `displayName` virtual = `username || truncatedKey`; it ignores
|
|
55
|
+
// the structured name. A real name must win so a first-only account is
|
|
56
|
+
// never collapsed to its username.
|
|
57
|
+
expect(
|
|
58
|
+
getAccountDisplayName({
|
|
59
|
+
name: { first: 'Nate' },
|
|
60
|
+
displayName: 'nateus',
|
|
61
|
+
username: 'nateus',
|
|
62
|
+
}),
|
|
63
|
+
).toBe('Nate');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('uses displayName when there is no structured name', () => {
|
|
67
|
+
expect(
|
|
68
|
+
getAccountDisplayName({
|
|
69
|
+
displayName: 'Cool Display',
|
|
70
|
+
username: 'nateus',
|
|
71
|
+
}),
|
|
72
|
+
).toBe('Cool Display');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('supports name stored as a plain string', () => {
|
|
76
|
+
expect(getAccountDisplayName({ name: 'Legacy Name', username: 'nateus' })).toBe(
|
|
77
|
+
'Legacy Name',
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('falls back to username when there is no name', () => {
|
|
82
|
+
expect(getAccountDisplayName({ username: 'nateus' })).toBe('nateus');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to the public-key handle when there is no name or username', () => {
|
|
86
|
+
const result = getAccountDisplayName({
|
|
87
|
+
publicKey: '0x1234567890abcdef',
|
|
88
|
+
});
|
|
89
|
+
// common.accountFallback = "Account {{handle}}"; handle truncated to 0x12345678…
|
|
90
|
+
expect(result).toBe('Account 0x12345678…');
|
|
91
|
+
expect(result).not.toContain('Unknown');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('returns the translated unnamed fallback when nothing is available', () => {
|
|
95
|
+
expect(getAccountDisplayName({})).toBe('Unnamed');
|
|
96
|
+
expect(getAccountDisplayName(null)).toBe('Unnamed');
|
|
97
|
+
expect(getAccountDisplayName(undefined)).toBe('Unnamed');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('ignores whitespace-only name fields', () => {
|
|
101
|
+
expect(
|
|
102
|
+
getAccountDisplayName({
|
|
103
|
+
name: { first: ' ', last: ' ' },
|
|
104
|
+
username: 'nateus',
|
|
105
|
+
}),
|
|
106
|
+
).toBe('nateus');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('honours the locale for the fallback strings', () => {
|
|
110
|
+
expect(getAccountDisplayName({}, 'es-ES')).toBe('Sin nombre');
|
|
111
|
+
expect(getAccountDisplayName({ publicKey: '0x1234567890abcdef' }, 'es-ES')).toBe(
|
|
112
|
+
'Cuenta 0x12345678…',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('getAccountFallbackHandle', () => {
|
|
118
|
+
it('returns the bare username when present', () => {
|
|
119
|
+
expect(getAccountFallbackHandle({ username: 'nateus' })).toBe('nateus');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('falls back to a truncated public-key handle', () => {
|
|
123
|
+
expect(getAccountFallbackHandle({ publicKey: '0x1234567890abcdef' })).toBe('0x12345678…');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('returns undefined when neither username nor publicKey is present', () => {
|
|
127
|
+
expect(getAccountFallbackHandle({})).toBeUndefined();
|
|
128
|
+
expect(getAccountFallbackHandle(null)).toBeUndefined();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('formatPublicKeyHandle', () => {
|
|
133
|
+
it('truncates a long key and strips the 0x prefix before re-adding it', () => {
|
|
134
|
+
expect(formatPublicKeyHandle('0x1234567890abcdef')).toBe('0x12345678…');
|
|
135
|
+
expect(formatPublicKeyHandle('1234567890abcdef')).toBe('0x12345678…');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns the raw (prefixed) key when too short to truncate', () => {
|
|
139
|
+
expect(formatPublicKeyHandle('0xabcd')).toBe('0xabcd');
|
|
140
|
+
expect(formatPublicKeyHandle('abcd')).toBe('0xabcd');
|
|
141
|
+
});
|
|
142
|
+
});
|