@jskit-ai/users-core 0.1.197 → 0.1.199
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/package.json +10 -10
- package/patterns/user-administration-server/example/packages/users/package.json +2 -2
- package/patterns/user-administration-server/example/packages/users-workspace/package.json +3 -3
- package/src/server/UsersIdentityProvider.js +3 -4
- package/src/server/accountNotifications/accountNotificationsActions.js +7 -0
- package/src/server/accountPreferences/accountPreferencesActions.js +7 -0
- package/src/server/accountProfile/accountProfileActions.js +27 -0
- package/src/server/accountSecurity/accountSecurityActions.js +31 -0
- package/src/server/common/repositories/repositoryUtils.js +4 -5
- package/src/server/common/repositories/userProfilesRepository.js +97 -112
- package/src/server/common/repositories/userSettingsRepository.js +15 -28
- package/test/authProfileSyncService.test.js +28 -0
- package/test/featureRuntime.test.js +0 -1
- package/test/repositoryContracts.test.js +197 -57
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/users-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.199",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"./shared/resources/userSettingsResource": "./src/shared/resources/userSettingsResource.js"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
17
|
-
"@jskit-ai/json-rest-api-core": "0.1.
|
|
18
|
-
"@jskit-ai/resource-core": "0.1.
|
|
19
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
20
|
-
"@jskit-ai/storage-runtime": "0.1.
|
|
21
|
-
"@jskit-ai/uploads-runtime": "0.1.
|
|
16
|
+
"@jskit-ai/database-runtime": "0.1.186",
|
|
17
|
+
"@jskit-ai/json-rest-api-core": "0.1.130",
|
|
18
|
+
"@jskit-ai/resource-core": "0.1.128",
|
|
19
|
+
"@jskit-ai/resource-crud-core": "0.1.128",
|
|
20
|
+
"@jskit-ai/storage-runtime": "0.1.183",
|
|
21
|
+
"@jskit-ai/uploads-runtime": "0.1.161",
|
|
22
22
|
"json-rest-schema": "^1.0.17"
|
|
23
23
|
},
|
|
24
24
|
"description": "Users/account runtime plus HTTP routes for account features.",
|
|
@@ -166,8 +166,8 @@
|
|
|
166
166
|
}
|
|
167
167
|
},
|
|
168
168
|
"peerDependencies": {
|
|
169
|
-
"@jskit-ai/auth-core": "0.1.
|
|
170
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
171
|
-
"@jskit-ai/kernel": "0.1.
|
|
169
|
+
"@jskit-ai/auth-core": "0.1.184",
|
|
170
|
+
"@jskit-ai/http-runtime": "0.1.184",
|
|
171
|
+
"@jskit-ai/kernel": "0.1.186"
|
|
172
172
|
}
|
|
173
173
|
}
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"./shared": "./src/shared/index.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@jskit-ai/crud-core": "0.1.
|
|
13
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
12
|
+
"@jskit-ai/crud-core": "0.1.197",
|
|
13
|
+
"@jskit-ai/resource-crud-core": "0.1.128"
|
|
14
14
|
},
|
|
15
15
|
"jskit": {
|
|
16
16
|
"kind": "runtime",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"./shared": "./src/shared/index.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@jskit-ai/crud-core": "0.1.
|
|
13
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
14
|
-
"@jskit-ai/workspaces-core": "0.1.
|
|
12
|
+
"@jskit-ai/crud-core": "0.1.197",
|
|
13
|
+
"@jskit-ai/resource-crud-core": "0.1.128",
|
|
14
|
+
"@jskit-ai/workspaces-core": "0.1.164"
|
|
15
15
|
},
|
|
16
16
|
"jskit": {
|
|
17
17
|
"kind": "runtime",
|
|
@@ -14,14 +14,13 @@ const UsersIdentityProvider = defineProvider({
|
|
|
14
14
|
id: "users.identity",
|
|
15
15
|
requires: {
|
|
16
16
|
authExtensions: "auth.extensions",
|
|
17
|
-
database: "runtime.database",
|
|
18
17
|
extensions: "users.extensions",
|
|
19
18
|
jsonRestApi: "runtime.json-rest-api"
|
|
20
19
|
},
|
|
21
20
|
provides: {
|
|
22
21
|
identity: "users.identity"
|
|
23
22
|
},
|
|
24
|
-
async setup({ authExtensions,
|
|
23
|
+
async setup({ authExtensions, extensions, jsonRestApi }) {
|
|
25
24
|
const scopeOptions = {
|
|
26
25
|
writeSerializers: { "datetime-utc": toDatabaseDateTimeUtc }
|
|
27
26
|
};
|
|
@@ -36,8 +35,8 @@ const UsersIdentityProvider = defineProvider({
|
|
|
36
35
|
createJsonRestResourceScopeOptions(userSettingsResource, scopeOptions)
|
|
37
36
|
);
|
|
38
37
|
const repositories = Object.freeze({
|
|
39
|
-
userProfiles: createUserProfilesRepository({ api: jsonRestApi
|
|
40
|
-
userSettings: createUserSettingsRepository({ api: jsonRestApi
|
|
38
|
+
userProfiles: createUserProfilesRepository({ api: jsonRestApi }),
|
|
39
|
+
userSettings: createUserSettingsRepository({ api: jsonRestApi })
|
|
41
40
|
});
|
|
42
41
|
const profileProjector = createAuthProfileSyncService({
|
|
43
42
|
userProfilesRepository: repositories.userProfiles,
|
|
@@ -17,6 +17,13 @@ const accountNotificationsActionSpecifications = Object.freeze([
|
|
|
17
17
|
},
|
|
18
18
|
input: userSettingsResource.operations.notificationsUpdate.body,
|
|
19
19
|
output: null,
|
|
20
|
+
extensions: {
|
|
21
|
+
assistant: {
|
|
22
|
+
description: "Update the signed-in user’s notification preferences.",
|
|
23
|
+
output: userSettingsResource.operations.notificationsUpdate.output,
|
|
24
|
+
transformResult: (result) => result.value
|
|
25
|
+
}
|
|
26
|
+
},
|
|
20
27
|
idempotency: "optional",
|
|
21
28
|
audit: {
|
|
22
29
|
actionName: "settings.notifications.update"
|
|
@@ -17,6 +17,13 @@ const accountPreferencesActionSpecifications = Object.freeze([
|
|
|
17
17
|
},
|
|
18
18
|
input: userSettingsResource.operations.preferencesUpdate.body,
|
|
19
19
|
output: null,
|
|
20
|
+
extensions: {
|
|
21
|
+
assistant: {
|
|
22
|
+
description: "Update the signed-in user’s display and regional preferences.",
|
|
23
|
+
output: userSettingsResource.operations.preferencesUpdate.output,
|
|
24
|
+
transformResult: (result) => result.value
|
|
25
|
+
}
|
|
26
|
+
},
|
|
20
27
|
idempotency: "optional",
|
|
21
28
|
audit: {
|
|
22
29
|
actionName: "settings.preferences.update"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { userSettingsResource } from "../../shared/resources/userSettingsResource.js";
|
|
1
2
|
import {
|
|
2
3
|
emptyInputValidator,
|
|
3
4
|
resolveRequest
|
|
@@ -24,6 +25,13 @@ const accountProfileActionSpecifications = deepFreeze([
|
|
|
24
25
|
},
|
|
25
26
|
input: emptyInputValidator,
|
|
26
27
|
output: null,
|
|
28
|
+
extensions: {
|
|
29
|
+
assistant: {
|
|
30
|
+
description: "Read the signed-in user’s profile, preferences, notifications and security status.",
|
|
31
|
+
output: userSettingsResource.operations.view.output,
|
|
32
|
+
transformResult: (result) => result.value
|
|
33
|
+
}
|
|
34
|
+
},
|
|
27
35
|
idempotency: "none",
|
|
28
36
|
audit: {
|
|
29
37
|
actionName: "settings.read"
|
|
@@ -46,6 +54,13 @@ const accountProfileActionSpecifications = deepFreeze([
|
|
|
46
54
|
},
|
|
47
55
|
input: settingsProfileUpdateInputValidator,
|
|
48
56
|
output: null,
|
|
57
|
+
extensions: {
|
|
58
|
+
assistant: {
|
|
59
|
+
description: "Update the signed-in user’s display name.",
|
|
60
|
+
output: userSettingsResource.operations.view.output,
|
|
61
|
+
transformResult: (result) => result.response.value
|
|
62
|
+
}
|
|
63
|
+
},
|
|
49
64
|
idempotency: "optional",
|
|
50
65
|
audit: {
|
|
51
66
|
actionName: "settings.profile.update"
|
|
@@ -74,6 +89,11 @@ const accountProfileActionSpecifications = deepFreeze([
|
|
|
74
89
|
},
|
|
75
90
|
input: userProfileResource.operations.avatarUpload.body,
|
|
76
91
|
output: null,
|
|
92
|
+
extensions: {
|
|
93
|
+
assistant: {
|
|
94
|
+
exclude: "Use the authenticated account profile file picker; upload streams must not enter assistant arguments."
|
|
95
|
+
}
|
|
96
|
+
},
|
|
77
97
|
idempotency: "none",
|
|
78
98
|
audit: {
|
|
79
99
|
actionName: "settings.profile.avatar.upload"
|
|
@@ -109,6 +129,13 @@ const accountProfileActionSpecifications = deepFreeze([
|
|
|
109
129
|
},
|
|
110
130
|
input: userProfileResource.operations.avatarDelete.body,
|
|
111
131
|
output: null,
|
|
132
|
+
extensions: {
|
|
133
|
+
assistant: {
|
|
134
|
+
description: "Remove the signed-in user’s uploaded avatar.",
|
|
135
|
+
output: userSettingsResource.operations.view.output,
|
|
136
|
+
transformResult: (result) => result.value
|
|
137
|
+
}
|
|
138
|
+
},
|
|
112
139
|
idempotency: "none",
|
|
113
140
|
audit: {
|
|
114
141
|
actionName: "settings.profile.avatar.delete"
|
|
@@ -25,6 +25,11 @@ const accountSecurityActionSpecifications = Object.freeze([
|
|
|
25
25
|
},
|
|
26
26
|
input: userSettingsResource.operations.passwordChange.body,
|
|
27
27
|
output: null,
|
|
28
|
+
extensions: {
|
|
29
|
+
assistant: {
|
|
30
|
+
exclude: "Use the authenticated account security form; passwords must not enter assistant arguments."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
28
33
|
idempotency: "none",
|
|
29
34
|
audit: {
|
|
30
35
|
actionName: "settings.security.password.change"
|
|
@@ -52,6 +57,13 @@ const accountSecurityActionSpecifications = Object.freeze([
|
|
|
52
57
|
},
|
|
53
58
|
input: userSettingsResource.operations.passwordMethodToggle.body,
|
|
54
59
|
output: null,
|
|
60
|
+
extensions: {
|
|
61
|
+
assistant: {
|
|
62
|
+
description: "Enable or disable password sign-in for the signed-in user, subject to the account authentication policy.",
|
|
63
|
+
output: userSettingsResource.operations.passwordMethodToggle.output,
|
|
64
|
+
transformResult: (result) => ({ securityStatus: result.value.securityStatus, settings: result.value.settings })
|
|
65
|
+
}
|
|
66
|
+
},
|
|
55
67
|
idempotency: "none",
|
|
56
68
|
audit: {
|
|
57
69
|
actionName: "settings.security.password_method.toggle"
|
|
@@ -79,6 +91,11 @@ const accountSecurityActionSpecifications = Object.freeze([
|
|
|
79
91
|
},
|
|
80
92
|
input: oauthLinkStartInputValidator,
|
|
81
93
|
output: null,
|
|
94
|
+
extensions: {
|
|
95
|
+
assistant: {
|
|
96
|
+
exclude: "Use the authenticated account security screen; OAuth authorization URLs contain transient credentials."
|
|
97
|
+
}
|
|
98
|
+
},
|
|
82
99
|
idempotency: "none",
|
|
83
100
|
audit: {
|
|
84
101
|
actionName: "settings.security.oauth.link.start"
|
|
@@ -106,6 +123,13 @@ const accountSecurityActionSpecifications = Object.freeze([
|
|
|
106
123
|
},
|
|
107
124
|
input: userSettingsResource.operations.oauthUnlink.params,
|
|
108
125
|
output: null,
|
|
126
|
+
extensions: {
|
|
127
|
+
assistant: {
|
|
128
|
+
description: "Unlink an OAuth sign-in method from the signed-in account, subject to the account authentication policy.",
|
|
129
|
+
output: userSettingsResource.operations.oauthUnlink.output,
|
|
130
|
+
transformResult: (result) => ({ securityStatus: result.value.securityStatus })
|
|
131
|
+
}
|
|
132
|
+
},
|
|
109
133
|
idempotency: "none",
|
|
110
134
|
audit: {
|
|
111
135
|
actionName: "settings.security.oauth.unlink"
|
|
@@ -133,6 +157,13 @@ const accountSecurityActionSpecifications = Object.freeze([
|
|
|
133
157
|
},
|
|
134
158
|
input: userSettingsResource.operations.logoutOtherSessions.body,
|
|
135
159
|
output: null,
|
|
160
|
+
extensions: {
|
|
161
|
+
assistant: {
|
|
162
|
+
description: "Sign the current user out of other devices.",
|
|
163
|
+
output: userSettingsResource.operations.logoutOtherSessions.output,
|
|
164
|
+
transformResult: () => ({ ok: true })
|
|
165
|
+
}
|
|
166
|
+
},
|
|
136
167
|
idempotency: "none",
|
|
137
168
|
audit: {
|
|
138
169
|
actionName: "settings.security.sessions.logout_others"
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
normalizeDbRecordId,
|
|
3
|
-
toIsoString
|
|
4
|
-
createWithTransaction
|
|
3
|
+
toIsoString
|
|
5
4
|
} from "@jskit-ai/database-runtime/shared";
|
|
6
|
-
import { isDuplicateEntryError } from "@jskit-ai/database-runtime/shared/duplicateEntry";
|
|
5
|
+
import { findDuplicateEntryError, isDuplicateEntryError } from "@jskit-ai/database-runtime/shared/duplicateEntry";
|
|
7
6
|
import { normalizeLowerText, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
8
7
|
|
|
9
8
|
function uniqueSorted(values) {
|
|
@@ -32,12 +31,12 @@ function toDbJson(value, fallback = {}) {
|
|
|
32
31
|
export {
|
|
33
32
|
toIsoString,
|
|
34
33
|
isDuplicateEntryError,
|
|
34
|
+
findDuplicateEntryError,
|
|
35
35
|
normalizeText,
|
|
36
36
|
normalizeLowerText,
|
|
37
37
|
normalizeRecordId,
|
|
38
38
|
normalizeDbRecordId,
|
|
39
39
|
uniqueSorted,
|
|
40
40
|
parseJson,
|
|
41
|
-
toDbJson
|
|
42
|
-
createWithTransaction
|
|
41
|
+
toDbJson
|
|
43
42
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
findDuplicateEntryError,
|
|
3
3
|
isDuplicateEntryError,
|
|
4
4
|
normalizeDbRecordId,
|
|
5
5
|
normalizeLowerText,
|
|
@@ -8,13 +8,11 @@ import {
|
|
|
8
8
|
toIsoString
|
|
9
9
|
} from "./repositoryUtils.js";
|
|
10
10
|
import {
|
|
11
|
-
createJsonApiInputRecord,
|
|
12
11
|
createJsonRestContext,
|
|
13
12
|
extractJsonRestCollectionRows
|
|
14
13
|
} from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
|
|
15
14
|
import { normalizeIdentity } from "../support/identity.js";
|
|
16
15
|
|
|
17
|
-
const RESOURCE_TYPE = "userProfiles";
|
|
18
16
|
const USERNAME_MAX_LENGTH = 120;
|
|
19
17
|
|
|
20
18
|
function normalizeUsername(value) {
|
|
@@ -114,25 +112,28 @@ function buildUsernameCandidate(baseUsername, suffix) {
|
|
|
114
112
|
}
|
|
115
113
|
|
|
116
114
|
function duplicateTargetsEmail(error) {
|
|
117
|
-
|
|
115
|
+
const duplicate = findDuplicateEntryError(error);
|
|
116
|
+
if (!duplicate) {
|
|
118
117
|
return false;
|
|
119
118
|
}
|
|
120
119
|
|
|
121
|
-
const message = normalizeLowerText(
|
|
120
|
+
const message = normalizeLowerText(duplicate.sqlMessage || duplicate.message);
|
|
122
121
|
return message.includes("email");
|
|
123
122
|
}
|
|
124
123
|
|
|
125
124
|
function duplicateTargetsUsername(error) {
|
|
126
|
-
|
|
125
|
+
const duplicate = findDuplicateEntryError(error);
|
|
126
|
+
if (!duplicate) {
|
|
127
127
|
return false;
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
const message = normalizeLowerText(
|
|
130
|
+
const message = normalizeLowerText(duplicate.sqlMessage || duplicate.message);
|
|
131
131
|
return message.includes("username");
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
function createDuplicateEmailConflictError() {
|
|
135
|
-
const error = new Error("Email is already linked to a different profile.");
|
|
134
|
+
function createDuplicateEmailConflictError(cause) {
|
|
135
|
+
const error = new Error("Email is already linked to a different profile.", { cause });
|
|
136
|
+
error.transactionOutcome = cause.transactionOutcome;
|
|
136
137
|
error.code = "USER_PROFILE_EMAIL_CONFLICT";
|
|
137
138
|
return error;
|
|
138
139
|
}
|
|
@@ -150,7 +151,7 @@ async function resolveUniqueUsername(api, baseUsername, { excludeUserId = null,
|
|
|
150
151
|
}
|
|
151
152
|
},
|
|
152
153
|
transaction,
|
|
153
|
-
|
|
154
|
+
format: "plain"
|
|
154
155
|
})
|
|
155
156
|
);
|
|
156
157
|
const existing = existingRows[0] || null;
|
|
@@ -163,15 +164,12 @@ async function resolveUniqueUsername(api, baseUsername, { excludeUserId = null,
|
|
|
163
164
|
throw new Error("Unable to generate unique username.");
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
function createRepository({ api
|
|
167
|
+
function createRepository({ api } = {}) {
|
|
167
168
|
if (!api?.resources?.userProfiles) {
|
|
168
169
|
throw new TypeError("internal.repository.user-profiles requires json-rest-api userProfiles resource.");
|
|
169
170
|
}
|
|
170
|
-
if (typeof knex !== "function") {
|
|
171
|
-
throw new TypeError("internal.repository.user-profiles requires knex.");
|
|
172
|
-
}
|
|
173
171
|
|
|
174
|
-
const withTransaction =
|
|
172
|
+
const withTransaction = (work) => api.transaction(work);
|
|
175
173
|
|
|
176
174
|
async function queryFirst(filters = {}, options = {}) {
|
|
177
175
|
const rows = extractJsonRestCollectionRows(
|
|
@@ -181,7 +179,7 @@ function createRepository({ api, knex } = {}) {
|
|
|
181
179
|
filters
|
|
182
180
|
},
|
|
183
181
|
transaction: options?.trx || null,
|
|
184
|
-
|
|
182
|
+
format: "plain"
|
|
185
183
|
},
|
|
186
184
|
createJsonRestContext(options?.context || null)
|
|
187
185
|
)
|
|
@@ -233,16 +231,13 @@ function createRepository({ api, knex } = {}) {
|
|
|
233
231
|
|
|
234
232
|
const updated = await api.resources.userProfiles.patch(
|
|
235
233
|
{
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
id: normalizedUserId
|
|
244
|
-
}
|
|
245
|
-
),
|
|
234
|
+
id: normalizedUserId,
|
|
235
|
+
data: {
|
|
236
|
+
displayName,
|
|
237
|
+
updatedAt: new Date().toISOString()
|
|
238
|
+
},
|
|
239
|
+
format: "plain",
|
|
240
|
+
returning: "full",
|
|
246
241
|
transaction: options?.trx || null
|
|
247
242
|
},
|
|
248
243
|
createJsonRestContext(options?.context || null)
|
|
@@ -259,18 +254,15 @@ function createRepository({ api, knex } = {}) {
|
|
|
259
254
|
|
|
260
255
|
const updated = await api.resources.userProfiles.patch(
|
|
261
256
|
{
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
id: normalizedUserId
|
|
272
|
-
}
|
|
273
|
-
),
|
|
257
|
+
id: normalizedUserId,
|
|
258
|
+
data: {
|
|
259
|
+
avatarStorageKey: avatar.avatarStorageKey ?? null,
|
|
260
|
+
avatarVersion: avatar.avatarVersion ?? null,
|
|
261
|
+
avatarUpdatedAt: toIsoString(avatar.avatarUpdatedAt ?? new Date()),
|
|
262
|
+
updatedAt: new Date().toISOString()
|
|
263
|
+
},
|
|
264
|
+
format: "plain",
|
|
265
|
+
returning: "full",
|
|
274
266
|
transaction: options?.trx || null
|
|
275
267
|
},
|
|
276
268
|
createJsonRestContext(options?.context || null)
|
|
@@ -287,18 +279,15 @@ function createRepository({ api, knex } = {}) {
|
|
|
287
279
|
|
|
288
280
|
const updated = await api.resources.userProfiles.patch(
|
|
289
281
|
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
id: normalizedUserId
|
|
300
|
-
}
|
|
301
|
-
),
|
|
282
|
+
id: normalizedUserId,
|
|
283
|
+
data: {
|
|
284
|
+
avatarStorageKey: null,
|
|
285
|
+
avatarVersion: null,
|
|
286
|
+
avatarUpdatedAt: null,
|
|
287
|
+
updatedAt: new Date().toISOString()
|
|
288
|
+
},
|
|
289
|
+
format: "plain",
|
|
290
|
+
returning: "full",
|
|
302
291
|
transaction: options?.trx || null
|
|
303
292
|
},
|
|
304
293
|
createJsonRestContext(options?.context || null)
|
|
@@ -330,84 +319,80 @@ function createRepository({ api, knex } = {}) {
|
|
|
330
319
|
context: options?.context || null
|
|
331
320
|
});
|
|
332
321
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
)
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
const updated = await api.resources.userProfiles.patch(
|
|
345
|
-
{
|
|
346
|
-
inputRecord: createJsonApiInputRecord(
|
|
347
|
-
RESOURCE_TYPE,
|
|
348
|
-
{
|
|
349
|
-
email,
|
|
350
|
-
displayName,
|
|
351
|
-
username,
|
|
352
|
-
updatedAt: new Date().toISOString()
|
|
353
|
-
},
|
|
354
|
-
{
|
|
355
|
-
id: normalizeDbRecordId(existing.id, { fallback: null })
|
|
356
|
-
}
|
|
357
|
-
),
|
|
358
|
-
transaction: trx
|
|
359
|
-
},
|
|
360
|
-
createJsonRestContext(options?.context || null)
|
|
361
|
-
);
|
|
362
|
-
|
|
363
|
-
return normalizeProfileRecord(updated);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const username = await resolveUniqueUsername(
|
|
367
|
-
api,
|
|
368
|
-
requestedUsername || usernameBaseFromEmail(email),
|
|
369
|
-
{ transaction: trx }
|
|
322
|
+
if (existing) {
|
|
323
|
+
const existingUsername = normalizeUsername(existing.username);
|
|
324
|
+
const username = existingUsername || (
|
|
325
|
+
await resolveUniqueUsername(
|
|
326
|
+
api,
|
|
327
|
+
requestedUsername || usernameBaseFromEmail(email),
|
|
328
|
+
{ excludeUserId: existing.id, transaction: trx }
|
|
329
|
+
)
|
|
370
330
|
);
|
|
371
331
|
|
|
372
|
-
const
|
|
332
|
+
const updated = await api.resources.userProfiles.patch(
|
|
373
333
|
{
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
authProviderUserSid: identity.providerUserId,
|
|
334
|
+
id: normalizeDbRecordId(existing.id, { fallback: null }),
|
|
335
|
+
data: {
|
|
377
336
|
email,
|
|
378
337
|
displayName,
|
|
379
338
|
username,
|
|
380
|
-
|
|
381
|
-
}
|
|
339
|
+
updatedAt: new Date().toISOString()
|
|
340
|
+
},
|
|
341
|
+
format: "plain",
|
|
342
|
+
returning: "full",
|
|
382
343
|
transaction: trx
|
|
383
344
|
},
|
|
384
345
|
createJsonRestContext(options?.context || null)
|
|
385
346
|
);
|
|
386
347
|
|
|
387
|
-
return normalizeProfileRecord(
|
|
388
|
-
} catch (error) {
|
|
389
|
-
if (duplicateTargetsEmail(error)) {
|
|
390
|
-
throw createDuplicateEmailConflictError();
|
|
391
|
-
}
|
|
392
|
-
if (duplicateTargetsUsername(error)) {
|
|
393
|
-
throw error;
|
|
394
|
-
}
|
|
395
|
-
if (!isDuplicateEntryError(error)) {
|
|
396
|
-
throw error;
|
|
397
|
-
}
|
|
348
|
+
return normalizeProfileRecord(updated);
|
|
398
349
|
}
|
|
399
350
|
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
351
|
+
const username = await resolveUniqueUsername(
|
|
352
|
+
api,
|
|
353
|
+
requestedUsername || usernameBaseFromEmail(email),
|
|
354
|
+
{ transaction: trx }
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const created = await api.resources.userProfiles.post(
|
|
358
|
+
{
|
|
359
|
+
data: {
|
|
360
|
+
authProvider: identity.provider,
|
|
361
|
+
authProviderUserSid: identity.providerUserId,
|
|
362
|
+
email,
|
|
363
|
+
displayName,
|
|
364
|
+
username,
|
|
365
|
+
createdAt: new Date().toISOString()
|
|
366
|
+
},
|
|
367
|
+
format: "plain",
|
|
368
|
+
returning: "full",
|
|
369
|
+
transaction: trx
|
|
370
|
+
},
|
|
371
|
+
createJsonRestContext(options?.context || null)
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
return normalizeProfileRecord(created);
|
|
404
375
|
};
|
|
405
376
|
|
|
406
|
-
|
|
407
|
-
return executeUpsert(options.trx);
|
|
377
|
+
try {
|
|
378
|
+
return await (options?.trx ? executeUpsert(options.trx) : withTransaction(executeUpsert));
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (["pending", "rolledBack"].includes(error?.transactionOutcome) && duplicateTargetsEmail(error)) {
|
|
381
|
+
throw createDuplicateEmailConflictError(error);
|
|
382
|
+
}
|
|
383
|
+
// A failed participant cannot recover inside the caller's transaction.
|
|
384
|
+
if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
if (duplicateTargetsUsername(error)) {
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
const existing = await findByIdentity(identity, { context: options?.context || null });
|
|
391
|
+
if (existing) {
|
|
392
|
+
return existing;
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
408
395
|
}
|
|
409
|
-
|
|
410
|
-
return withTransaction(executeUpsert);
|
|
411
396
|
}
|
|
412
397
|
|
|
413
398
|
return Object.freeze({
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
normalizeRecordId,
|
|
3
|
-
isDuplicateEntryError
|
|
4
|
-
createWithTransaction
|
|
3
|
+
isDuplicateEntryError
|
|
5
4
|
} from "./repositoryUtils.js";
|
|
6
5
|
import {
|
|
7
|
-
createJsonApiInputRecord,
|
|
8
6
|
createJsonRestContext,
|
|
9
7
|
extractJsonRestCollectionRows
|
|
10
8
|
} from "@jskit-ai/json-rest-api-core/server/jsonRestApiHost";
|
|
11
9
|
import { DEFAULT_USER_SETTINGS } from "../../../shared/settings.js";
|
|
12
10
|
|
|
13
|
-
const RESOURCE_TYPE = "userSettings";
|
|
14
11
|
const USER_SETTINGS_PATCH_FIELDS = Object.freeze([
|
|
15
12
|
"theme",
|
|
16
13
|
"locale",
|
|
@@ -56,14 +53,11 @@ function createDefaultUserSettingsCreatePayload(userId) {
|
|
|
56
53
|
};
|
|
57
54
|
}
|
|
58
55
|
|
|
59
|
-
function createRepository({ api
|
|
56
|
+
function createRepository({ api } = {}) {
|
|
60
57
|
if (!api?.resources?.userSettings) {
|
|
61
58
|
throw new TypeError("userSettingsRepository requires json-rest-api userSettings resource.");
|
|
62
59
|
}
|
|
63
|
-
|
|
64
|
-
throw new TypeError("userSettingsRepository requires knex.");
|
|
65
|
-
}
|
|
66
|
-
const withTransaction = createWithTransaction(knex);
|
|
60
|
+
const withTransaction = (work) => api.transaction(work);
|
|
67
61
|
|
|
68
62
|
async function queryFirst(filters = {}, options = {}) {
|
|
69
63
|
const rows = extractJsonRestCollectionRows(
|
|
@@ -73,7 +67,7 @@ function createRepository({ api, knex } = {}) {
|
|
|
73
67
|
filters
|
|
74
68
|
},
|
|
75
69
|
transaction: options?.trx || null,
|
|
76
|
-
|
|
70
|
+
format: "plain"
|
|
77
71
|
},
|
|
78
72
|
createJsonRestContext(options?.context || null)
|
|
79
73
|
)
|
|
@@ -105,19 +99,15 @@ function createRepository({ api, knex } = {}) {
|
|
|
105
99
|
try {
|
|
106
100
|
await api.resources.userSettings.post(
|
|
107
101
|
{
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
{
|
|
112
|
-
id: normalizedUserId
|
|
113
|
-
}
|
|
114
|
-
),
|
|
102
|
+
data: createDefaultUserSettingsCreatePayload(normalizedUserId),
|
|
103
|
+
format: "plain",
|
|
104
|
+
returning: "full",
|
|
115
105
|
transaction: options?.trx || null
|
|
116
106
|
},
|
|
117
107
|
createJsonRestContext(options?.context || null)
|
|
118
108
|
);
|
|
119
109
|
} catch (error) {
|
|
120
|
-
if (!isDuplicateEntryError(error)) {
|
|
110
|
+
if (options?.trx || error?.transactionOutcome !== "rolledBack" || !isDuplicateEntryError(error)) {
|
|
121
111
|
throw error;
|
|
122
112
|
}
|
|
123
113
|
}
|
|
@@ -141,16 +131,13 @@ function createRepository({ api, knex } = {}) {
|
|
|
141
131
|
|
|
142
132
|
await api.resources.userSettings.patch(
|
|
143
133
|
{
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
id: normalizedUserId
|
|
152
|
-
}
|
|
153
|
-
),
|
|
134
|
+
id: normalizedUserId,
|
|
135
|
+
data: {
|
|
136
|
+
...updatePayload,
|
|
137
|
+
updatedAt: new Date().toISOString()
|
|
138
|
+
},
|
|
139
|
+
format: "plain",
|
|
140
|
+
returning: "full",
|
|
154
141
|
transaction: options?.trx || null
|
|
155
142
|
},
|
|
156
143
|
createJsonRestContext(options?.context || null)
|
|
@@ -147,3 +147,31 @@ test("authProfileSyncService.findByIdentity normalizes provider identity input",
|
|
|
147
147
|
providerUserId: "user-1"
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
test("auth profile sync joins the supplied managed transaction and propagates contributor failure", async () => {
|
|
152
|
+
const trx = { trxId: "managed-profile-sync" };
|
|
153
|
+
const failure = new Error("Lifecycle participant failed");
|
|
154
|
+
const calls = [];
|
|
155
|
+
const profile = {
|
|
156
|
+
id: "7", authProvider: "local", authProviderUserSid: "local-7",
|
|
157
|
+
email: "ada@example.com", displayName: "Ada"
|
|
158
|
+
};
|
|
159
|
+
const service = createService({
|
|
160
|
+
userProfilesRepository: {
|
|
161
|
+
async findByIdentity(_identity, options) { calls.push(options.trx); return null; },
|
|
162
|
+
async upsert(_data, options) { calls.push(options.trx); return profile; },
|
|
163
|
+
async withTransaction() { throw new Error("Must join the caller's owner"); }
|
|
164
|
+
},
|
|
165
|
+
userSettingsRepository: {
|
|
166
|
+
async ensureForUserId(_id, options) { calls.push(options.trx); }
|
|
167
|
+
},
|
|
168
|
+
resolveLifecycleContributors: () => [{
|
|
169
|
+
async afterIdentityProfileSynced({ options }) {
|
|
170
|
+
calls.push(options.trx);
|
|
171
|
+
throw failure;
|
|
172
|
+
}
|
|
173
|
+
}]
|
|
174
|
+
});
|
|
175
|
+
await assert.rejects(service.syncIdentityProfile(profile, { trx }), error => error === failure);
|
|
176
|
+
assert.deepEqual(calls, [trx, trx, trx, trx]);
|
|
177
|
+
});
|
|
@@ -46,7 +46,6 @@ test("UsersFeature assembles account behavior through explicit capabilities", as
|
|
|
46
46
|
"auth.extensions": authExtensions,
|
|
47
47
|
"auth.service": {},
|
|
48
48
|
"runtime.bootstrap": bootstrap,
|
|
49
|
-
"runtime.database": { knex() {} },
|
|
50
49
|
"runtime.http": {
|
|
51
50
|
router: {
|
|
52
51
|
register(method, path) {
|
|
@@ -5,27 +5,17 @@ import { userProfileResource } from "../src/shared/resources/userProfileResource
|
|
|
5
5
|
import { createRepository as createUserProfilesRepository } from "../src/server/common/repositories/userProfilesRepository.js";
|
|
6
6
|
import { createRepository as createUserSettingsRepository } from "../src/server/common/repositories/userSettingsRepository.js";
|
|
7
7
|
|
|
8
|
-
function createKnexStub() {
|
|
9
|
-
const knex = Object.assign(() => {
|
|
10
|
-
throw new Error("query execution not expected");
|
|
11
|
-
}, {
|
|
12
|
-
async transaction(work) {
|
|
13
|
-
return work({ trxId: "trx-1" });
|
|
14
|
-
}
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
return knex;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
8
|
function asCollectionDocument(rows = []) {
|
|
21
9
|
return {
|
|
22
10
|
data: Array.isArray(rows) ? rows : []
|
|
23
11
|
};
|
|
24
12
|
}
|
|
25
13
|
|
|
26
|
-
test("users-core repositories
|
|
27
|
-
const knex = createKnexStub();
|
|
14
|
+
test("users-core repositories delegate transaction ownership and failures to the API", async () => {
|
|
28
15
|
const api = {
|
|
16
|
+
async transaction(work) {
|
|
17
|
+
return work({ trxId: "managed-1" });
|
|
18
|
+
},
|
|
29
19
|
resources: {
|
|
30
20
|
userProfiles: {
|
|
31
21
|
async query() {
|
|
@@ -40,14 +30,16 @@ test("users-core repositories expose withTransaction", async () => {
|
|
|
40
30
|
}
|
|
41
31
|
};
|
|
42
32
|
const repositories = [
|
|
43
|
-
createUserProfilesRepository({ api
|
|
44
|
-
createUserSettingsRepository({ api
|
|
33
|
+
createUserProfilesRepository({ api }),
|
|
34
|
+
createUserSettingsRepository({ api })
|
|
45
35
|
];
|
|
46
36
|
|
|
47
37
|
for (const repository of repositories) {
|
|
48
38
|
assert.equal(typeof repository.withTransaction, "function");
|
|
49
39
|
const result = await repository.withTransaction(async (trx) => ({ id: trx.trxId }));
|
|
50
|
-
assert.deepEqual(result, { id: "
|
|
40
|
+
assert.deepEqual(result, { id: "managed-1" });
|
|
41
|
+
const failure = new Error("Participant failed");
|
|
42
|
+
await assert.rejects(repository.withTransaction(async () => { throw failure; }), error => error === failure);
|
|
51
43
|
}
|
|
52
44
|
});
|
|
53
45
|
|
|
@@ -59,7 +51,8 @@ function createUserProfilesApiStub(expectedRecord) {
|
|
|
59
51
|
api: {
|
|
60
52
|
resources: {
|
|
61
53
|
userProfiles: {
|
|
62
|
-
async query({ queryParams }) {
|
|
54
|
+
async query({ queryParams, format }) {
|
|
55
|
+
assert.equal(format, "plain");
|
|
63
56
|
calls.push(queryParams?.filters || {});
|
|
64
57
|
return asCollectionDocument(
|
|
65
58
|
expectedRecord ? [{
|
|
@@ -74,12 +67,11 @@ function createUserProfilesApiStub(expectedRecord) {
|
|
|
74
67
|
};
|
|
75
68
|
}
|
|
76
69
|
|
|
77
|
-
test("userSettingsRepository.ensureForUserId sends transaction outside
|
|
70
|
+
test("userSettingsRepository.ensureForUserId sends transaction outside plain data", async () => {
|
|
78
71
|
const trx = { trxId: "trx-1" };
|
|
79
72
|
let queryCount = 0;
|
|
80
73
|
let postParams = null;
|
|
81
74
|
const repository = createUserSettingsRepository({
|
|
82
|
-
knex: createKnexStub(),
|
|
83
75
|
api: {
|
|
84
76
|
resources: {
|
|
85
77
|
userSettings: {
|
|
@@ -108,28 +100,25 @@ test("userSettingsRepository.ensureForUserId sends transaction outside simplifie
|
|
|
108
100
|
|
|
109
101
|
const record = await repository.ensureForUserId("7", { trx });
|
|
110
102
|
|
|
111
|
-
assert.equal(postParams?.
|
|
103
|
+
assert.equal(postParams?.format, "plain");
|
|
104
|
+
assert.equal(postParams?.returning, "full");
|
|
112
105
|
assert.equal(postParams?.transaction, trx);
|
|
113
|
-
assert.deepEqual(postParams?.
|
|
114
|
-
type: "userSettings",
|
|
106
|
+
assert.deepEqual(postParams?.data, {
|
|
115
107
|
id: "7",
|
|
116
|
-
|
|
117
|
-
id: "7",
|
|
118
|
-
...DEFAULT_USER_SETTINGS
|
|
119
|
-
}
|
|
108
|
+
...DEFAULT_USER_SETTINGS
|
|
120
109
|
});
|
|
121
110
|
assert.equal(record?.id, "7");
|
|
122
111
|
});
|
|
123
112
|
|
|
124
|
-
test("userProfilesRepository.upsert sends
|
|
113
|
+
test("userProfilesRepository.upsert sends plain data with transaction outside the record body", async () => {
|
|
125
114
|
const trx = { trxId: "trx-1" };
|
|
126
115
|
let postParams = null;
|
|
127
116
|
const repository = createUserProfilesRepository({
|
|
128
|
-
knex: createKnexStub(),
|
|
129
117
|
api: {
|
|
130
118
|
resources: {
|
|
131
119
|
userProfiles: {
|
|
132
|
-
async query({ queryParams }) {
|
|
120
|
+
async query({ queryParams, format }) {
|
|
121
|
+
assert.equal(format, "plain");
|
|
133
122
|
const filters = queryParams?.filters || {};
|
|
134
123
|
if (Object.hasOwn(filters, "authProvider") || Object.hasOwn(filters, "authProviderUserSid")) {
|
|
135
124
|
return asCollectionDocument([]);
|
|
@@ -141,7 +130,7 @@ test("userProfilesRepository.upsert sends native JSON:API write documents with t
|
|
|
141
130
|
},
|
|
142
131
|
async post(params) {
|
|
143
132
|
postParams = params;
|
|
144
|
-
const attributes = params.
|
|
133
|
+
const attributes = params.data || {};
|
|
145
134
|
return {
|
|
146
135
|
id: "11",
|
|
147
136
|
authProvider: attributes.authProvider,
|
|
@@ -167,11 +156,13 @@ test("userProfilesRepository.upsert sends native JSON:API write documents with t
|
|
|
167
156
|
displayName: "Ada Example"
|
|
168
157
|
}, { trx });
|
|
169
158
|
|
|
170
|
-
assert.equal(postParams?.
|
|
159
|
+
assert.equal(postParams?.format, "plain");
|
|
160
|
+
assert.equal(postParams?.returning, "full");
|
|
171
161
|
assert.equal(postParams?.transaction, trx);
|
|
172
|
-
assert.equal(postParams?.
|
|
173
|
-
assert.equal(postParams?.
|
|
174
|
-
assert.equal(postParams?.inputRecord
|
|
162
|
+
assert.equal(postParams?.data?.transaction, undefined);
|
|
163
|
+
assert.equal(postParams?.document, undefined);
|
|
164
|
+
assert.equal(postParams?.inputRecord, undefined);
|
|
165
|
+
assert.equal(postParams?.data?.authProvider, "supabase");
|
|
175
166
|
assert.equal(record?.id, "11");
|
|
176
167
|
});
|
|
177
168
|
|
|
@@ -192,11 +183,11 @@ test("userProfilesRepository.upsert patches existing profiles with resource-back
|
|
|
192
183
|
};
|
|
193
184
|
let patchParams = null;
|
|
194
185
|
const repository = createUserProfilesRepository({
|
|
195
|
-
knex: createKnexStub(),
|
|
196
186
|
api: {
|
|
197
187
|
resources: {
|
|
198
188
|
userProfiles: {
|
|
199
|
-
async query({ queryParams }) {
|
|
189
|
+
async query({ queryParams, format }) {
|
|
190
|
+
assert.equal(format, "plain");
|
|
200
191
|
const filters = queryParams?.filters || {};
|
|
201
192
|
if (Object.hasOwn(filters, "authProvider") || Object.hasOwn(filters, "authProviderUserSid")) {
|
|
202
193
|
return asCollectionDocument([existingRecord]);
|
|
@@ -208,7 +199,7 @@ test("userProfilesRepository.upsert patches existing profiles with resource-back
|
|
|
208
199
|
},
|
|
209
200
|
async patch(params) {
|
|
210
201
|
patchParams = params;
|
|
211
|
-
const attributes = params.
|
|
202
|
+
const attributes = params.data || {};
|
|
212
203
|
return {
|
|
213
204
|
...existingRecord,
|
|
214
205
|
...attributes
|
|
@@ -226,7 +217,7 @@ test("userProfilesRepository.upsert patches existing profiles with resource-back
|
|
|
226
217
|
displayName: "Ada Renamed"
|
|
227
218
|
}, { trx });
|
|
228
219
|
|
|
229
|
-
const attributes = patchParams?.
|
|
220
|
+
const attributes = patchParams?.data || {};
|
|
230
221
|
assert.equal(userProfileResource.schema.updatedAt?.type, "dateTime");
|
|
231
222
|
assert.equal(userProfileResource.schema.updatedAt?.storage?.column, "updated_at");
|
|
232
223
|
assert.equal(userProfileResource.schema.updatedAt?.storage?.writeSerializer, "datetime-utc");
|
|
@@ -244,7 +235,6 @@ test("userProfilesRepository profile patch helpers stamp updatedAt through the r
|
|
|
244
235
|
const trx = { trxId: "trx-1" };
|
|
245
236
|
const calls = [];
|
|
246
237
|
const repository = createUserProfilesRepository({
|
|
247
|
-
knex: createKnexStub(),
|
|
248
238
|
api: {
|
|
249
239
|
resources: {
|
|
250
240
|
userProfiles: {
|
|
@@ -253,9 +243,9 @@ test("userProfilesRepository profile patch helpers stamp updatedAt through the r
|
|
|
253
243
|
},
|
|
254
244
|
async patch(params) {
|
|
255
245
|
calls.push(params);
|
|
256
|
-
const attributes = params.
|
|
246
|
+
const attributes = params.data || {};
|
|
257
247
|
return {
|
|
258
|
-
id: params.
|
|
248
|
+
id: params.id || "7",
|
|
259
249
|
authProvider: "local",
|
|
260
250
|
authProviderUserSid: "local-user-7",
|
|
261
251
|
email: "ada@example.com",
|
|
@@ -285,21 +275,26 @@ test("userProfilesRepository profile patch helpers stamp updatedAt through the r
|
|
|
285
275
|
assert.equal(calls.length, 3);
|
|
286
276
|
for (const call of calls) {
|
|
287
277
|
assert.equal(call.transaction, trx);
|
|
288
|
-
assert.equal(
|
|
278
|
+
assert.equal(call.id, "7");
|
|
279
|
+
assert.equal(call.format, "plain");
|
|
280
|
+
assert.equal(call.returning, "full");
|
|
281
|
+
assert.equal(call.document, undefined);
|
|
282
|
+
assert.equal(call.inputRecord, undefined);
|
|
283
|
+
assert.equal(typeof call.data?.updatedAt, "string");
|
|
289
284
|
}
|
|
290
|
-
assert.deepEqual(Object.keys(calls[0].
|
|
291
|
-
assert.deepEqual(Object.keys(calls[1].
|
|
285
|
+
assert.deepEqual(Object.keys(calls[0].data).sort(), ["displayName", "updatedAt"]);
|
|
286
|
+
assert.deepEqual(Object.keys(calls[1].data).sort(), [
|
|
292
287
|
"avatarStorageKey",
|
|
293
288
|
"avatarUpdatedAt",
|
|
294
289
|
"avatarVersion",
|
|
295
290
|
"updatedAt"
|
|
296
291
|
]);
|
|
297
|
-
assert.equal(calls[1].
|
|
298
|
-
assert.equal(calls[1].
|
|
299
|
-
assert.equal(calls[1].
|
|
300
|
-
assert.deepEqual(calls[2].
|
|
301
|
-
assert.deepEqual(calls[2].
|
|
302
|
-
assert.deepEqual(calls[2].
|
|
292
|
+
assert.equal(calls[1].data.avatarStorageKey, "avatars/7.png");
|
|
293
|
+
assert.equal(calls[1].data.avatarVersion, "v1");
|
|
294
|
+
assert.equal(calls[1].data.avatarUpdatedAt, avatarUpdatedAt.toISOString());
|
|
295
|
+
assert.deepEqual(calls[2].data.avatarStorageKey, null);
|
|
296
|
+
assert.deepEqual(calls[2].data.avatarVersion, null);
|
|
297
|
+
assert.deepEqual(calls[2].data.avatarUpdatedAt, null);
|
|
303
298
|
});
|
|
304
299
|
|
|
305
300
|
test("userProfilesRepository.findByEmail normalizes email lookup", async () => {
|
|
@@ -316,8 +311,7 @@ test("userProfilesRepository.findByEmail normalizes email lookup", async () => {
|
|
|
316
311
|
createdAt: "2026-04-20T00:00:00.000Z"
|
|
317
312
|
});
|
|
318
313
|
const repository = createUserProfilesRepository({
|
|
319
|
-
api
|
|
320
|
-
knex: createKnexStub()
|
|
314
|
+
api
|
|
321
315
|
});
|
|
322
316
|
|
|
323
317
|
const profile = await repository.findByEmail(" ADA@EXAMPLE.COM ");
|
|
@@ -342,8 +336,7 @@ test("userProfilesRepository.findByIdentity reads existing profiles from collect
|
|
|
342
336
|
createdAt: "2026-04-20T00:00:00.000Z"
|
|
343
337
|
});
|
|
344
338
|
const repository = createUserProfilesRepository({
|
|
345
|
-
api
|
|
346
|
-
knex: createKnexStub()
|
|
339
|
+
api
|
|
347
340
|
});
|
|
348
341
|
|
|
349
342
|
const profile = await repository.findByIdentity({
|
|
@@ -362,11 +355,158 @@ test("userProfilesRepository.findByIdentity reads existing profiles from collect
|
|
|
362
355
|
test("userProfilesRepository.findByEmail returns null when the row is missing", async () => {
|
|
363
356
|
const { api } = createUserProfilesApiStub(undefined);
|
|
364
357
|
const repository = createUserProfilesRepository({
|
|
365
|
-
api
|
|
366
|
-
knex: createKnexStub()
|
|
358
|
+
api
|
|
367
359
|
});
|
|
368
360
|
|
|
369
361
|
const profile = await repository.findByEmail("missing@example.com");
|
|
370
362
|
|
|
371
363
|
assert.equal(profile, null);
|
|
372
364
|
});
|
|
365
|
+
|
|
366
|
+
for (const outcome of ["pending", "committed", "unknown"]) {
|
|
367
|
+
test(`user settings preserves a ${outcome} write failure instead of recovering as a duplicate`, async () => {
|
|
368
|
+
let reads = 0;
|
|
369
|
+
const failure = Object.assign(new Error("Write failed"), {
|
|
370
|
+
transactionOutcome: outcome,
|
|
371
|
+
cause: { code: "23505" }
|
|
372
|
+
});
|
|
373
|
+
const api = {
|
|
374
|
+
resources: {
|
|
375
|
+
userSettings: {
|
|
376
|
+
async query() { reads += 1; return asCollectionDocument([]); },
|
|
377
|
+
async post() { throw failure; }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const repository = createUserSettingsRepository({ api });
|
|
382
|
+
await assert.rejects(repository.ensureForUserId("7"), error => error === failure);
|
|
383
|
+
assert.equal(reads, 1);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
test("user settings recovers a duplicate only after its standalone write rolled back", async () => {
|
|
388
|
+
let reads = 0;
|
|
389
|
+
const existing = { id: "7", ...DEFAULT_USER_SETTINGS };
|
|
390
|
+
const api = {
|
|
391
|
+
resources: {
|
|
392
|
+
userSettings: {
|
|
393
|
+
async query() { reads += 1; return asCollectionDocument(reads === 1 ? [] : [existing]); },
|
|
394
|
+
async post() {
|
|
395
|
+
throw Object.assign(new Error("Write failed"), {
|
|
396
|
+
transactionOutcome: "rolledBack", cause: { code: "23505" }
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
const repository = createUserSettingsRepository({ api });
|
|
403
|
+
assert.deepEqual(await repository.ensureForUserId("7"), existing);
|
|
404
|
+
assert.equal(reads, 2);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("profile upsert performs duplicate recovery after its API transaction has finished", async () => {
|
|
408
|
+
const trx = { trxId: "managed-upsert" };
|
|
409
|
+
const profile = {
|
|
410
|
+
id: "7", authProvider: "local", authProviderUserSid: "local-7",
|
|
411
|
+
email: "ada@example.com", username: "ada", displayName: "Ada"
|
|
412
|
+
};
|
|
413
|
+
let active = false;
|
|
414
|
+
let rolledBack = false;
|
|
415
|
+
const api = {
|
|
416
|
+
async transaction(work) {
|
|
417
|
+
active = true;
|
|
418
|
+
try {
|
|
419
|
+
return await work(trx);
|
|
420
|
+
} catch (cause) {
|
|
421
|
+
active = false;
|
|
422
|
+
rolledBack = true;
|
|
423
|
+
throw Object.assign(new Error("Transaction failed", { cause }), { transactionOutcome: "rolledBack" });
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
resources: {
|
|
427
|
+
userProfiles: {
|
|
428
|
+
async query({ transaction, queryParams, format }) {
|
|
429
|
+
assert.equal(format, "plain");
|
|
430
|
+
if (active) {
|
|
431
|
+
assert.equal(transaction, trx);
|
|
432
|
+
return asCollectionDocument([]);
|
|
433
|
+
}
|
|
434
|
+
assert.equal(rolledBack, true);
|
|
435
|
+
assert.equal(transaction, null);
|
|
436
|
+
assert.equal(queryParams.filters.authProviderUserSid, "local-7");
|
|
437
|
+
return asCollectionDocument([profile]);
|
|
438
|
+
},
|
|
439
|
+
async post({ transaction }) {
|
|
440
|
+
assert.equal(transaction, trx);
|
|
441
|
+
throw Object.assign(new Error("Duplicate identity"), {
|
|
442
|
+
transactionOutcome: "pending", cause: { code: "23505", message: "unique provider identity" }
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
const repository = createUserProfilesRepository({ api });
|
|
449
|
+
assert.equal((await repository.upsert(profile)).id, "7");
|
|
450
|
+
assert.equal(active, false);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("profile upsert never recovers inside a caller-owned failed transaction", async () => {
|
|
454
|
+
const trx = { trxId: "caller-managed" };
|
|
455
|
+
let reads = 0;
|
|
456
|
+
const failure = Object.assign(new Error("Duplicate identity"), {
|
|
457
|
+
transactionOutcome: "pending", cause: { code: "23505" }
|
|
458
|
+
});
|
|
459
|
+
const api = {
|
|
460
|
+
async transaction() { throw new Error("Must join the caller's owner"); },
|
|
461
|
+
resources: {
|
|
462
|
+
userProfiles: {
|
|
463
|
+
async query({ transaction }) {
|
|
464
|
+
assert.equal(transaction, trx);
|
|
465
|
+
reads += 1;
|
|
466
|
+
return asCollectionDocument([]);
|
|
467
|
+
},
|
|
468
|
+
async post() { throw failure; }
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
const repository = createUserProfilesRepository({ api });
|
|
473
|
+
await assert.rejects(repository.upsert({
|
|
474
|
+
authProvider: "local", authProviderUserSid: "local-7",
|
|
475
|
+
email: "ada@example.com", displayName: "Ada"
|
|
476
|
+
}, { trx }), error => error === failure);
|
|
477
|
+
assert.equal(reads, 2);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
for (const outcome of ["pending", "committed", "unknown"]) {
|
|
481
|
+
test(`profile email conflict preserves the ${outcome} outcome from a caller-owned write`, async () => {
|
|
482
|
+
const trx = { trxId: "caller-managed" };
|
|
483
|
+
let reads = 0;
|
|
484
|
+
const failure = Object.assign(new Error("Resource write failed"), {
|
|
485
|
+
transactionOutcome: outcome,
|
|
486
|
+
cause: { code: "23505", message: "duplicate key in users_email_unique" }
|
|
487
|
+
});
|
|
488
|
+
const api = {
|
|
489
|
+
resources: {
|
|
490
|
+
userProfiles: {
|
|
491
|
+
async query() { reads += 1; return asCollectionDocument([]); },
|
|
492
|
+
async post() { throw failure; }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const repository = createUserProfilesRepository({ api });
|
|
497
|
+
await assert.rejects(repository.upsert({
|
|
498
|
+
authProvider: "local", authProviderUserSid: "local-7",
|
|
499
|
+
email: "ada@example.com", displayName: "Ada"
|
|
500
|
+
}, { trx }), error => {
|
|
501
|
+
if (outcome === "pending") {
|
|
502
|
+
assert.equal(error.code, "USER_PROFILE_EMAIL_CONFLICT");
|
|
503
|
+
assert.equal(error.cause, failure);
|
|
504
|
+
} else {
|
|
505
|
+
assert.equal(error, failure);
|
|
506
|
+
}
|
|
507
|
+
assert.equal(error.transactionOutcome, outcome);
|
|
508
|
+
return true;
|
|
509
|
+
});
|
|
510
|
+
assert.equal(reads, 2);
|
|
511
|
+
});
|
|
512
|
+
}
|