@oxyhq/core 20.1.0 → 21.0.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/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +8 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +75 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/models/session.ts +11 -0
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
-
import { ACCOUNT_KINDS } from '@oxyhq/contracts';
|
|
3
|
-
import type { User } from '../../models/interfaces';
|
|
4
|
-
import type { AccountNode } from '../../mixins/OxyServices.accounts';
|
|
5
|
-
import {
|
|
6
|
-
canSwitchIntoAccount,
|
|
7
|
-
isSwitchTargetAccount,
|
|
8
|
-
projectSwitchableAccounts,
|
|
9
|
-
switchableAccountIds,
|
|
10
|
-
} from '../accountProjection';
|
|
11
|
-
|
|
12
|
-
function user(id: string, over: Partial<User> = {}): User {
|
|
13
|
-
return {
|
|
14
|
-
id,
|
|
15
|
-
publicKey: `pk_${id}`,
|
|
16
|
-
username: `user_${id}`,
|
|
17
|
-
name: { displayName: `User ${id}` },
|
|
18
|
-
...over,
|
|
19
|
-
} as User;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function state(
|
|
23
|
-
accounts: Array<{ accountId: string; sessionId: string; authuser?: number }>,
|
|
24
|
-
activeAccountId: string | null,
|
|
25
|
-
): DeviceSessionState {
|
|
26
|
-
return {
|
|
27
|
-
deviceId: 'device-1',
|
|
28
|
-
accounts: accounts.map((a) => ({ accountId: a.accountId, sessionId: a.sessionId, authuser: a.authuser ?? 0 })),
|
|
29
|
-
activeAccountId,
|
|
30
|
-
revision: 1,
|
|
31
|
-
updatedAt: 1_720_000_000_000,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function graphNode(id: string, over: Partial<AccountNode> = {}): AccountNode {
|
|
36
|
-
return {
|
|
37
|
-
accountId: id,
|
|
38
|
-
kind: 'organization',
|
|
39
|
-
parentAccountId: null,
|
|
40
|
-
account: user(id),
|
|
41
|
-
relationship: 'owner',
|
|
42
|
-
callerMembership: null,
|
|
43
|
-
...over,
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const mapOf = (...users: User[]): Map<string, User> => {
|
|
48
|
-
const map = new Map<string, User>();
|
|
49
|
-
for (const u of users) map.set(u.id, u);
|
|
50
|
-
return map;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const noAvatar = (): undefined => undefined;
|
|
54
|
-
|
|
55
|
-
describe('isSwitchTargetAccount', () => {
|
|
56
|
-
/**
|
|
57
|
-
* EXHAUSTIVE over `ACCOUNT_KINDS`, as one object equality rather than a
|
|
58
|
-
* per-kind assertion, for two reasons that a `channel → false` spot-check
|
|
59
|
-
* cannot give:
|
|
60
|
-
*
|
|
61
|
-
* - It distinguishes "excludes channels" from "excludes everything". Three
|
|
62
|
-
* kinds must come back `true` here, so a predicate that answered `false`
|
|
63
|
-
* unconditionally — the shape that empties a switcher instead of filtering
|
|
64
|
-
* it — fails on those three, not on the channel.
|
|
65
|
-
* - Adding a sixth kind fails this test with a missing key, forcing the
|
|
66
|
-
* decision to be made HERE rather than inherited silently from whichever
|
|
67
|
-
* literal comparison happened to be written first.
|
|
68
|
-
*/
|
|
69
|
-
it('answers by kind for an account the caller merely owns or is a member of', () => {
|
|
70
|
-
expect(
|
|
71
|
-
Object.fromEntries(
|
|
72
|
-
ACCOUNT_KINDS.map((kind) => [kind, isSwitchTargetAccount({ kind, relationship: 'owner' })]),
|
|
73
|
-
),
|
|
74
|
-
).toEqual({
|
|
75
|
-
personal: false,
|
|
76
|
-
organization: true,
|
|
77
|
-
project: true,
|
|
78
|
-
bot: true,
|
|
79
|
-
channel: false,
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* The `self` ground, and the reason this predicate is not `isActAsEligibleKind`.
|
|
85
|
-
*
|
|
86
|
-
* `personal` is act-as INELIGIBLE — assuming somebody's human login would be
|
|
87
|
-
* impersonation — so a switcher gated on that predicate alone would drop the
|
|
88
|
-
* caller's OWN account and render an empty list. `GET /accounts` resolves its
|
|
89
|
-
* caller through `resolveOperatorId`, so a `self` node is always the human
|
|
90
|
-
* operator's own personal account, even while they operate an org.
|
|
91
|
-
*/
|
|
92
|
-
it('admits the caller’s own personal account, which is act-as ineligible', () => {
|
|
93
|
-
expect(isSwitchTargetAccount({ kind: 'personal', relationship: 'self' })).toBe(true);
|
|
94
|
-
// Same kind, not the caller's own → refused. The `relationship` is doing the
|
|
95
|
-
// work, so neither half of the predicate can be deleted without a failure.
|
|
96
|
-
expect(isSwitchTargetAccount({ kind: 'personal', relationship: 'member' })).toBe(false);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('refuses an account with no kind information rather than assuming', () => {
|
|
100
|
-
expect(isSwitchTargetAccount({})).toBe(false);
|
|
101
|
-
expect(isSwitchTargetAccount({ kind: null })).toBe(false);
|
|
102
|
-
expect(isSwitchTargetAccount({ kind: undefined, relationship: 'owner' })).toBe(false);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe('canSwitchIntoAccount', () => {
|
|
107
|
-
it('admits self without membership permissions', () => {
|
|
108
|
-
expect(canSwitchIntoAccount({ kind: 'personal', relationship: 'self' })).toBe(true);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('admits an owned switch target when membership is absent (owner baseline)', () => {
|
|
112
|
-
expect(canSwitchIntoAccount({ kind: 'organization', relationship: 'owner' })).toBe(true);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('requires account:act_as for member relationships', () => {
|
|
116
|
-
expect(
|
|
117
|
-
canSwitchIntoAccount({
|
|
118
|
-
kind: 'organization',
|
|
119
|
-
relationship: 'member',
|
|
120
|
-
callerMembership: {
|
|
121
|
-
_id: 'm1',
|
|
122
|
-
accountId: 'org1',
|
|
123
|
-
memberUserId: 'u1',
|
|
124
|
-
role: 'billing',
|
|
125
|
-
status: 'active',
|
|
126
|
-
permissions: ['account:read', 'billing:manage'],
|
|
127
|
-
inherit: true,
|
|
128
|
-
source: 'direct',
|
|
129
|
-
},
|
|
130
|
-
}),
|
|
131
|
-
).toBe(false);
|
|
132
|
-
|
|
133
|
-
expect(
|
|
134
|
-
canSwitchIntoAccount({
|
|
135
|
-
kind: 'organization',
|
|
136
|
-
relationship: 'member',
|
|
137
|
-
callerMembership: {
|
|
138
|
-
_id: 'm2',
|
|
139
|
-
accountId: 'org1',
|
|
140
|
-
memberUserId: 'u1',
|
|
141
|
-
role: 'admin',
|
|
142
|
-
status: 'active',
|
|
143
|
-
permissions: ['account:act_as', 'account:read'],
|
|
144
|
-
inherit: true,
|
|
145
|
-
source: 'direct',
|
|
146
|
-
},
|
|
147
|
-
}),
|
|
148
|
-
).toBe(true);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('refuses channels even with act_as permission', () => {
|
|
152
|
-
expect(
|
|
153
|
-
canSwitchIntoAccount({
|
|
154
|
-
kind: 'channel',
|
|
155
|
-
relationship: 'owner',
|
|
156
|
-
callerMembership: {
|
|
157
|
-
_id: 'm3',
|
|
158
|
-
accountId: 'chan1',
|
|
159
|
-
memberUserId: 'u1',
|
|
160
|
-
role: 'owner',
|
|
161
|
-
status: 'active',
|
|
162
|
-
permissions: ['account:act_as'],
|
|
163
|
-
inherit: true,
|
|
164
|
-
source: 'direct',
|
|
165
|
-
},
|
|
166
|
-
}),
|
|
167
|
-
).toBe(false);
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe('projectSwitchableAccounts', () => {
|
|
172
|
-
it('returns [] for null state and empty graph', () => {
|
|
173
|
-
expect(
|
|
174
|
-
projectSwitchableAccounts({ state: null, graph: [], profilesById: new Map(), resolveAvatarUrl: noAvatar }),
|
|
175
|
-
).toEqual([]);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it('projects device rows and flags the active account current', () => {
|
|
179
|
-
const rows = projectSwitchableAccounts({
|
|
180
|
-
state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }, { accountId: 'a2', sessionId: 's2', authuser: 1 }], 'a2'),
|
|
181
|
-
graph: [],
|
|
182
|
-
profilesById: mapOf(user('a1'), user('a2')),
|
|
183
|
-
resolveAvatarUrl: noAvatar,
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
expect(rows.map((r) => r.accountId)).toEqual(['a1', 'a2']);
|
|
187
|
-
expect(rows.map((r) => r.isCurrent)).toEqual([false, true]);
|
|
188
|
-
expect(rows.every((r) => r.onDevice)).toBe(true);
|
|
189
|
-
expect(rows[1].sessionId).toBe('s2');
|
|
190
|
-
expect(rows[1].authuser).toBe(1);
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('omits device accounts whose profile is not resolved (except the active one via activeUser)', () => {
|
|
194
|
-
const rows = projectSwitchableAccounts({
|
|
195
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }, { accountId: 'a2', sessionId: 's2' }], 'a1'),
|
|
196
|
-
graph: [],
|
|
197
|
-
// a2 has no resolved profile; a1 is active and provided via activeUser.
|
|
198
|
-
profilesById: new Map(),
|
|
199
|
-
activeUser: user('a1', { name: { displayName: 'Fresh A1' } }),
|
|
200
|
-
resolveAvatarUrl: noAvatar,
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
expect(rows.map((r) => r.accountId)).toEqual(['a1']);
|
|
204
|
-
expect(rows[0].displayName).toBe('Fresh A1');
|
|
205
|
-
expect(rows[0].isCurrent).toBe(true);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('prefers activeUser over profilesById for the active row (freshness)', () => {
|
|
209
|
-
const rows = projectSwitchableAccounts({
|
|
210
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
211
|
-
graph: [],
|
|
212
|
-
profilesById: mapOf(user('a1', { name: { displayName: 'Stale' } })),
|
|
213
|
-
activeUser: user('a1', { name: { displayName: 'Fresh' } }),
|
|
214
|
-
resolveAvatarUrl: noAvatar,
|
|
215
|
-
});
|
|
216
|
-
expect(rows[0].displayName).toBe('Fresh');
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
it('merges graph-only accounts after device rows, carrying graph metadata', () => {
|
|
220
|
-
const rows = projectSwitchableAccounts({
|
|
221
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
222
|
-
graph: [graphNode('org1', { kind: 'organization', relationship: 'owner', parentAccountId: 'a1' })],
|
|
223
|
-
profilesById: mapOf(user('a1')),
|
|
224
|
-
resolveAvatarUrl: noAvatar,
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org1']);
|
|
228
|
-
const org = rows[1];
|
|
229
|
-
expect(org.onDevice).toBe(false);
|
|
230
|
-
expect(org.isCurrent).toBe(false);
|
|
231
|
-
expect(org.sessionId).toBeUndefined();
|
|
232
|
-
expect(org.kind).toBe('organization');
|
|
233
|
-
expect(org.relationship).toBe('owner');
|
|
234
|
-
expect(org.parentAccountId).toBe('a1');
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* A channel is a content identity nobody acts as, so it must never be offered
|
|
239
|
-
* as a switch target. It reaches the projection through the GRAPH lane, not
|
|
240
|
-
* the device lane — a channel has no credentials and so can never be a device
|
|
241
|
-
* session, which is exactly why "no-login accounts can't appear here by
|
|
242
|
-
* construction" is false: `listAccounts()` contributes credential-less
|
|
243
|
-
* accounts on purpose (that is how an org first becomes switchable).
|
|
244
|
-
*/
|
|
245
|
-
it('omits a graph-only channel account from the switcher', () => {
|
|
246
|
-
const rows = projectSwitchableAccounts({
|
|
247
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
248
|
-
graph: [
|
|
249
|
-
graphNode('org1', { kind: 'organization' }),
|
|
250
|
-
graphNode('chan1', { kind: 'channel' }),
|
|
251
|
-
],
|
|
252
|
-
profilesById: mapOf(user('a1')),
|
|
253
|
-
resolveAvatarUrl: noAvatar,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org1']);
|
|
257
|
-
expect(rows.some((r) => r.kind === 'channel')).toBe(false);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* The same rule as the `isSwitchTargetAccount` matrix above, asserted through
|
|
262
|
-
* the projection so the WIRING is covered and not just the predicate.
|
|
263
|
-
*
|
|
264
|
-
* The fixture deliberately carries one graph-only node of every kind, and
|
|
265
|
-
* FOUR of the five must survive: a fixture list of channels alone could not
|
|
266
|
-
* tell "omits channels" from "omits every graph-only row", which is the
|
|
267
|
-
* failure mode that would silently empty an operator's switcher of the orgs
|
|
268
|
-
* they actually work in. `a1` is a device row and is asserted separately, so
|
|
269
|
-
* the graph lane's output is never confused with the device lane's.
|
|
270
|
-
*/
|
|
271
|
-
it('keeps every switchable kind while dropping the channel (graph lane)', () => {
|
|
272
|
-
const rows = projectSwitchableAccounts({
|
|
273
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
274
|
-
graph: [
|
|
275
|
-
graphNode('self1', { kind: 'personal', relationship: 'self' }),
|
|
276
|
-
graphNode('org1', { kind: 'organization' }),
|
|
277
|
-
graphNode('proj1', { kind: 'project' }),
|
|
278
|
-
graphNode('bot1', { kind: 'bot' }),
|
|
279
|
-
graphNode('chan1', { kind: 'channel' }),
|
|
280
|
-
],
|
|
281
|
-
profilesById: mapOf(
|
|
282
|
-
user('a1'),
|
|
283
|
-
user('self1'),
|
|
284
|
-
user('org1'),
|
|
285
|
-
user('proj1'),
|
|
286
|
-
user('bot1'),
|
|
287
|
-
user('chan1'),
|
|
288
|
-
),
|
|
289
|
-
resolveAvatarUrl: noAvatar,
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
expect(rows.map((r) => r.accountId)).toEqual(['a1', 'self1', 'org1', 'proj1', 'bot1']);
|
|
293
|
-
// Stated the other way round too, so a fixture that stopped reaching the
|
|
294
|
-
// graph lane at all could not pass this as a vacuous "no channels found".
|
|
295
|
-
expect(rows.map((r) => r.kind)).toEqual([
|
|
296
|
-
undefined,
|
|
297
|
-
'personal',
|
|
298
|
-
'organization',
|
|
299
|
-
'project',
|
|
300
|
-
'bot',
|
|
301
|
-
]);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('omits a graph-only member without account:act_as', () => {
|
|
305
|
-
const rows = projectSwitchableAccounts({
|
|
306
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
307
|
-
graph: [
|
|
308
|
-
graphNode('org1', { kind: 'organization', relationship: 'member', callerMembership: {
|
|
309
|
-
_id: 'm1',
|
|
310
|
-
accountId: 'org1',
|
|
311
|
-
memberUserId: 'a1',
|
|
312
|
-
role: 'billing',
|
|
313
|
-
status: 'active',
|
|
314
|
-
permissions: ['account:read', 'billing:manage'],
|
|
315
|
-
inherit: true,
|
|
316
|
-
source: 'direct',
|
|
317
|
-
} }),
|
|
318
|
-
graphNode('org2', { kind: 'organization', relationship: 'member', callerMembership: {
|
|
319
|
-
_id: 'm2',
|
|
320
|
-
accountId: 'org2',
|
|
321
|
-
memberUserId: 'a1',
|
|
322
|
-
role: 'admin',
|
|
323
|
-
status: 'active',
|
|
324
|
-
permissions: ['account:act_as', 'account:read'],
|
|
325
|
-
inherit: true,
|
|
326
|
-
source: 'direct',
|
|
327
|
-
} }),
|
|
328
|
-
],
|
|
329
|
-
profilesById: mapOf(user('a1'), user('org1'), user('org2')),
|
|
330
|
-
resolveAvatarUrl: noAvatar,
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
expect(rows.map((r) => r.accountId)).toEqual(['a1', 'org2']);
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
it('dedups an account present as BOTH device session and graph node into ONE enriched row', () => {
|
|
337
|
-
const rows = projectSwitchableAccounts({
|
|
338
|
-
state: state([{ accountId: 'a1', sessionId: 's1', authuser: 0 }], 'a1'),
|
|
339
|
-
graph: [graphNode('a1', { kind: 'personal', relationship: 'self', callerMembership: null })],
|
|
340
|
-
profilesById: mapOf(user('a1')),
|
|
341
|
-
resolveAvatarUrl: noAvatar,
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
expect(rows).toHaveLength(1);
|
|
345
|
-
const row = rows[0];
|
|
346
|
-
// Keeps the device sessionId + active flag, gains the graph metadata.
|
|
347
|
-
expect(row.sessionId).toBe('s1');
|
|
348
|
-
expect(row.onDevice).toBe(true);
|
|
349
|
-
expect(row.isCurrent).toBe(true);
|
|
350
|
-
expect(row.kind).toBe('personal');
|
|
351
|
-
expect(row.relationship).toBe('self');
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
it('resolves avatar url via the injected resolver and falls back email to @handle', () => {
|
|
355
|
-
const rows = projectSwitchableAccounts({
|
|
356
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
357
|
-
graph: [],
|
|
358
|
-
profilesById: mapOf(user('a1', { avatar: 'file123', email: undefined, username: 'nate' })),
|
|
359
|
-
resolveAvatarUrl: (avatar) => (avatar ? `https://cdn/${avatar}` : undefined),
|
|
360
|
-
});
|
|
361
|
-
expect(rows[0].avatarUrl).toBe('https://cdn/file123');
|
|
362
|
-
// No real email → `@handle` secondary line, never synthesized.
|
|
363
|
-
expect(rows[0].email).toBe('@nate');
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it('uses a real email when present', () => {
|
|
367
|
-
const rows = projectSwitchableAccounts({
|
|
368
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }], 'a1'),
|
|
369
|
-
graph: [],
|
|
370
|
-
profilesById: mapOf(user('a1', { email: 'real@oxy.so' })),
|
|
371
|
-
resolveAvatarUrl: noAvatar,
|
|
372
|
-
});
|
|
373
|
-
expect(rows[0].email).toBe('real@oxy.so');
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
it('marks no row current when activeAccountId is null', () => {
|
|
377
|
-
const rows = projectSwitchableAccounts({
|
|
378
|
-
state: state([{ accountId: 'a1', sessionId: 's1' }], null),
|
|
379
|
-
graph: [],
|
|
380
|
-
profilesById: mapOf(user('a1')),
|
|
381
|
-
resolveAvatarUrl: noAvatar,
|
|
382
|
-
});
|
|
383
|
-
expect(rows.every((r) => !r.isCurrent)).toBe(true);
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
it('keeps the OPERATOR full set when acting-as a sub-account (no collapse to the active account)', () => {
|
|
387
|
-
// nate operates albert: albert is the active on-device account, nate is also
|
|
388
|
-
// on-device, and the graph is OPERATOR-anchored (the server returns nate's
|
|
389
|
-
// full forest regardless of which account is active). The projection must
|
|
390
|
-
// faithfully union — switching in only changes which row is `isCurrent`,
|
|
391
|
-
// never which accounts are listed. This is the switcher-collapse regression.
|
|
392
|
-
const rows = projectSwitchableAccounts({
|
|
393
|
-
state: state([{ accountId: 'nate', sessionId: 's-nate' }, { accountId: 'albert', sessionId: 's-albert' }], 'albert'),
|
|
394
|
-
graph: [
|
|
395
|
-
graphNode('nate', { kind: 'personal', relationship: 'self' }),
|
|
396
|
-
graphNode('albert', { relationship: 'owner' }),
|
|
397
|
-
graphNode('oxy', { relationship: 'owner' }),
|
|
398
|
-
graphNode('faircoin', { relationship: 'owner' }),
|
|
399
|
-
],
|
|
400
|
-
profilesById: mapOf(user('nate'), user('albert'), user('oxy'), user('faircoin')),
|
|
401
|
-
resolveAvatarUrl: noAvatar,
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
// The full operable set is present even though a leaf sub-account is active.
|
|
405
|
-
expect(rows.map((r) => r.accountId).sort()).toEqual(['albert', 'faircoin', 'nate', 'oxy']);
|
|
406
|
-
// Exactly the active sub-account is flagged current; operator + siblings stay.
|
|
407
|
-
expect(rows.find((r) => r.accountId === 'albert')?.isCurrent).toBe(true);
|
|
408
|
-
expect(rows.filter((r) => r.isCurrent)).toHaveLength(1);
|
|
409
|
-
// The operator's own personal account is not dropped by acting-as.
|
|
410
|
-
expect(rows.find((r) => r.accountId === 'nate')).toBeDefined();
|
|
411
|
-
// Graph-only siblings (operable but not yet on this device) still appear.
|
|
412
|
-
expect(rows.find((r) => r.accountId === 'oxy')?.onDevice).toBe(false);
|
|
413
|
-
expect(rows.find((r) => r.accountId === 'faircoin')?.onDevice).toBe(false);
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
describe('switchableAccountIds', () => {
|
|
418
|
-
it('unions device + graph ids, deduped and sorted', () => {
|
|
419
|
-
const ids = switchableAccountIds(
|
|
420
|
-
state([{ accountId: 'b', sessionId: 's1' }, { accountId: 'a', sessionId: 's2' }], 'a'),
|
|
421
|
-
[graphNode('c'), graphNode('a')],
|
|
422
|
-
);
|
|
423
|
-
expect(ids).toEqual(['a', 'b', 'c']);
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it('returns [] for null state and empty graph', () => {
|
|
427
|
-
expect(switchableAccountIds(null, [])).toEqual([]);
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Must stay in lockstep with the projection's own filter in BOTH directions:
|
|
432
|
-
* an id fetched for a dropped row is wasted work, but an id NOT fetched for a
|
|
433
|
-
* row the projection keeps is worse — that row has no profile, so the
|
|
434
|
-
* projection's device lane skips it and it silently never renders.
|
|
435
|
-
*/
|
|
436
|
-
it('applies the same switch-target filter as the projection', () => {
|
|
437
|
-
const ids = switchableAccountIds(null, [
|
|
438
|
-
graphNode('self1', { kind: 'personal', relationship: 'self' }),
|
|
439
|
-
graphNode('org1', { kind: 'organization' }),
|
|
440
|
-
graphNode('proj1', { kind: 'project' }),
|
|
441
|
-
graphNode('bot1', { kind: 'bot' }),
|
|
442
|
-
graphNode('chan1', { kind: 'channel' }),
|
|
443
|
-
graphNode('other1', { kind: 'personal', relationship: 'member' }),
|
|
444
|
-
]);
|
|
445
|
-
expect(ids).toEqual(['bot1', 'org1', 'proj1', 'self1']);
|
|
446
|
-
});
|
|
447
|
-
});
|