abmp-npm 2.0.73 → 2.0.74
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/.claude/worktrees/fix-save-button-disable/.husky/pre-commit +19 -0
- package/.claude/worktrees/fix-save-button-disable/.prettierignore +7 -0
- package/.claude/worktrees/fix-save-button-disable/.prettierrc.json +16 -0
- package/.claude/worktrees/fix-save-button-disable/README.md +1 -0
- package/.claude/worktrees/fix-save-button-disable/backend/README-member-contact-flows.md +136 -0
- package/.claude/worktrees/fix-save-button-disable/backend/__tests__/daily-pull-execution-check.test.js +124 -0
- package/.claude/worktrees/fix-save-button-disable/backend/__tests__/ensure-unique-urls-in-batch.test.js +129 -0
- package/.claude/worktrees/fix-save-button-disable/backend/__tests__/login-email-sync.test.js +49 -0
- package/.claude/worktrees/fix-save-button-disable/backend/__tests__/transient-retry-and-bulk-lookup.test.js +129 -0
- package/.claude/worktrees/fix-save-button-disable/backend/__tests__/url-uniqueness.test.js +194 -0
- package/.claude/worktrees/fix-save-button-disable/backend/automations-methods.js +13 -0
- package/.claude/worktrees/fix-save-button-disable/backend/cms-data-methods.js +251 -0
- package/.claude/worktrees/fix-save-button-disable/backend/consts.js +58 -0
- package/.claude/worktrees/fix-save-button-disable/backend/contacts-methods.js +67 -0
- package/.claude/worktrees/fix-save-button-disable/backend/daily-pull/bulk-process-methods.js +222 -0
- package/.claude/worktrees/fix-save-button-disable/backend/daily-pull/consts.js +39 -0
- package/.claude/worktrees/fix-save-button-disable/backend/daily-pull/index.js +5 -0
- package/.claude/worktrees/fix-save-button-disable/backend/daily-pull/process-member-methods.js +213 -0
- package/.claude/worktrees/fix-save-button-disable/backend/daily-pull/schedule-methods.js +41 -0
- package/.claude/worktrees/fix-save-button-disable/backend/daily-pull/sync-to-cms-methods.js +161 -0
- package/.claude/worktrees/fix-save-button-disable/backend/daily-pull/utils.js +151 -0
- package/.claude/worktrees/fix-save-button-disable/backend/data-hooks.js +29 -0
- package/.claude/worktrees/fix-save-button-disable/backend/dev-only-methods.js +58 -0
- package/.claude/worktrees/fix-save-button-disable/backend/elevated-modules.js +18 -0
- package/.claude/worktrees/fix-save-button-disable/backend/forms-methods.js +52 -0
- package/.claude/worktrees/fix-save-button-disable/backend/http-functions/httpFunctions.js +86 -0
- package/.claude/worktrees/fix-save-button-disable/backend/http-functions/index.js +3 -0
- package/.claude/worktrees/fix-save-button-disable/backend/http-functions/interests.js +35 -0
- package/.claude/worktrees/fix-save-button-disable/backend/index.js +19 -0
- package/.claude/worktrees/fix-save-button-disable/backend/jobs.js +158 -0
- package/.claude/worktrees/fix-save-button-disable/backend/login/generate-member-session-token.js +27 -0
- package/.claude/worktrees/fix-save-button-disable/backend/login/index.js +8 -0
- package/.claude/worktrees/fix-save-button-disable/backend/login/qa-login-methods.js +54 -0
- package/.claude/worktrees/fix-save-button-disable/backend/login/sso-methods.js +159 -0
- package/.claude/worktrees/fix-save-button-disable/backend/member-contact-orchestration.js +111 -0
- package/.claude/worktrees/fix-save-button-disable/backend/members-area-methods.js +88 -0
- package/.claude/worktrees/fix-save-button-disable/backend/members-data-methods.js +789 -0
- package/.claude/worktrees/fix-save-button-disable/backend/pac-api-methods.js +54 -0
- package/.claude/worktrees/fix-save-button-disable/backend/routers/index.js +3 -0
- package/.claude/worktrees/fix-save-button-disable/backend/routers/methods.js +191 -0
- package/.claude/worktrees/fix-save-button-disable/backend/routers/utils.js +128 -0
- package/.claude/worktrees/fix-save-button-disable/backend/search-filters-methods.js +124 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/address-primary-methods.js +256 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/address-visibility-methods.js +160 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/consts.js +34 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/daily-pull-check-methods.js +78 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/email-normalize-methods.js +134 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/index.js +10 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/migration-methods.js +46 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/tasks-configs.js +251 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/tasks-helpers-methods.js +433 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/tasks-process-methods.js +606 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/url-migration-methods.js +378 -0
- package/.claude/worktrees/fix-save-button-disable/backend/tasks/url-space-fix-methods.js +174 -0
- package/.claude/worktrees/fix-save-button-disable/backend/utils.js +277 -0
- package/.claude/worktrees/fix-save-button-disable/dev-only-scripts/extract-duplicate-url-groups.js +201 -0
- package/.claude/worktrees/fix-save-button-disable/dev-only-scripts/find-duplicate-ids.js +159 -0
- package/.claude/worktrees/fix-save-button-disable/dev-only-scripts/find-duplicate-urls.js +201 -0
- package/.claude/worktrees/fix-save-button-disable/eslint.config.js +120 -0
- package/.claude/worktrees/fix-save-button-disable/index.js +5 -0
- package/.claude/worktrees/fix-save-button-disable/package-lock.json +12160 -0
- package/.claude/worktrees/fix-save-button-disable/package.json +60 -0
- package/.claude/worktrees/fix-save-button-disable/pages/ContactUs.js +129 -0
- package/.claude/worktrees/fix-save-button-disable/pages/Home.js +779 -0
- package/.claude/worktrees/fix-save-button-disable/pages/LearnMore.js +27 -0
- package/.claude/worktrees/fix-save-button-disable/pages/LoadingPage.js +29 -0
- package/.claude/worktrees/fix-save-button-disable/pages/Profile.js +354 -0
- package/.claude/worktrees/fix-save-button-disable/pages/QAPage.js +39 -0
- package/.claude/worktrees/fix-save-button-disable/pages/SaveAlerts.js +13 -0
- package/.claude/worktrees/fix-save-button-disable/pages/SelectBannerImages.js +46 -0
- package/.claude/worktrees/fix-save-button-disable/pages/deleteConfirm.js +19 -0
- package/.claude/worktrees/fix-save-button-disable/pages/index.js +12 -0
- package/.claude/worktrees/fix-save-button-disable/pages/personalDetails.js +2486 -0
- package/.claude/worktrees/fix-save-button-disable/public/Utils/homePage.js +798 -0
- package/.claude/worktrees/fix-save-button-disable/public/Utils/personalDetailsUtils.js +25 -0
- package/.claude/worktrees/fix-save-button-disable/public/Utils/sharedUtils.js +235 -0
- package/.claude/worktrees/fix-save-button-disable/public/consts.js +113 -0
- package/.claude/worktrees/fix-save-button-disable/public/index.js +5 -0
- package/.claude/worktrees/fix-save-button-disable/public/messages.js +16 -0
- package/.claude/worktrees/fix-save-button-disable/public/sso-auth-methods.js +43 -0
- package/package.json +1 -1
- package/pages/personalDetails.js +8 -3
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
echo "Running ESLint check..."
|
|
2
|
+
npm run lint
|
|
3
|
+
|
|
4
|
+
if [ $? -ne 0 ]; then
|
|
5
|
+
echo "❌ ESLint check failed. Please fix the linting errors before committing."
|
|
6
|
+
exit 1
|
|
7
|
+
fi
|
|
8
|
+
|
|
9
|
+
echo "Running Prettier check..."
|
|
10
|
+
npm run format:check
|
|
11
|
+
|
|
12
|
+
if [ $? -ne 0 ]; then
|
|
13
|
+
echo "❌ Prettier check failed. Please format your code before committing."
|
|
14
|
+
echo "Run 'npm run format' to automatically format your files."
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
echo "✅ All checks passed!"
|
|
19
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"singleQuote": true,
|
|
3
|
+
"trailingComma": "es5",
|
|
4
|
+
"printWidth": 100,
|
|
5
|
+
"tabWidth": 2,
|
|
6
|
+
"semi": true,
|
|
7
|
+
"bracketSpacing": true,
|
|
8
|
+
"bracketSameLine": false,
|
|
9
|
+
"arrowParens": "avoid",
|
|
10
|
+
"endOfLine": "lf",
|
|
11
|
+
"quoteProps": "as-needed",
|
|
12
|
+
"jsxSingleQuote": false,
|
|
13
|
+
"requirePragma": false,
|
|
14
|
+
"insertPragma": false,
|
|
15
|
+
"proseWrap": "preserve"
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# abmp-npm
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Member–Contact Flows
|
|
2
|
+
|
|
3
|
+
How Wix **Members** (login identity) and **CRM Contacts** (form/submission identity) stay in sync in ABMP.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
| Concept | Meaning |
|
|
8
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| **Single entity** | Member and contact use the same Wix ID (`wixContactId === wixMemberId`). One person, one record. |
|
|
10
|
+
| **Separate contact** | Member has a login email; contact has a different “form” email. Two Wix entities: one Member, one Contact. |
|
|
11
|
+
| **Login email** | `memberData.email` — used to sign in (Wix Member). |
|
|
12
|
+
| **Contact form email** | `memberData.contactFormEmail` — email used on forms; can differ from login. |
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 1. Login / first-time flow: `createContactAndMemberIfNew`
|
|
17
|
+
|
|
18
|
+
**Used when:** A user logs in or is created and we need to ensure they have both a Wix Member and (if needed) a CRM Contact.
|
|
19
|
+
|
|
20
|
+
**File:** `members-data-methods.js` → `createContactAndMemberIfNew(memberData)`
|
|
21
|
+
|
|
22
|
+
### Logic in short
|
|
23
|
+
|
|
24
|
+
- If no **Wix Member** → create one (login identity).
|
|
25
|
+
- If no **Wix Contact** and contact form email **differs** from login email → create a separate Contact.
|
|
26
|
+
- If contact form email **equals** login email → no separate contact; we use the member ID as the contact ID (single entity).
|
|
27
|
+
- Persist `wixMemberId` and `wixContactId` on the member record.
|
|
28
|
+
|
|
29
|
+
### Flow diagram
|
|
30
|
+
|
|
31
|
+
```mermaid
|
|
32
|
+
flowchart TD
|
|
33
|
+
A[createContactAndMemberIfNew] --> B{Has wixMemberId?}
|
|
34
|
+
B -->|No| C[Create Wix Member]
|
|
35
|
+
B -->|Yes| D{Has wixContactId?}
|
|
36
|
+
C --> D
|
|
37
|
+
D -->|No| E{contactFormEmail !== email?}
|
|
38
|
+
D -->|Yes| K[Keep existing IDs]
|
|
39
|
+
E -->|Yes| F[Create Wix Contact]
|
|
40
|
+
E -->|No| G[Use member ID as contact ID]
|
|
41
|
+
F --> H[Resolve IDs]
|
|
42
|
+
G --> H
|
|
43
|
+
K --> H
|
|
44
|
+
H[newWixContactId = createdContact OR memberId] --> I[Set wixMemberId, wixContactId on member]
|
|
45
|
+
I --> J[updateMember + return]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Parallel work
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
┌─────────────────────────────────────┐
|
|
52
|
+
│ createContactAndMemberIfNew │
|
|
53
|
+
└─────────────────────────────────────┘
|
|
54
|
+
│
|
|
55
|
+
┌───────────────────┴───────────────────┐
|
|
56
|
+
│ Promise.all([ │
|
|
57
|
+
│ runIf(needsWixMember, createMember), │
|
|
58
|
+
│ runIf(needsContact && differentEmail, │
|
|
59
|
+
│ createContact) │
|
|
60
|
+
│ ]) │
|
|
61
|
+
└───────────────────┬───────────────────┘
|
|
62
|
+
│
|
|
63
|
+
newWixContactId = createdWixContactId || newWixMemberId
|
|
64
|
+
(if no separate contact, member ID is the contact ID)
|
|
65
|
+
│
|
|
66
|
+
┌───────────────────┴───────────────────┐
|
|
67
|
+
│ updateMember({ ...memberData, │
|
|
68
|
+
│ wixMemberId, wixContactId }) │
|
|
69
|
+
└───────────────────────────────────────┘
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Outcome
|
|
73
|
+
|
|
74
|
+
| Scenario | wixMemberId | wixContactId |
|
|
75
|
+
| --------------------------------------- | ------------- | ----------------------------------- |
|
|
76
|
+
| New user, same email for login and form | New member ID | Same as wixMemberId (single entity) |
|
|
77
|
+
| New user, form email ≠ login email | New member ID | New contact ID (separate contact) |
|
|
78
|
+
| Existing user (already has both IDs) | Unchanged | Unchanged |
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 2. Update contact email flow: `updateContactEmail`
|
|
83
|
+
|
|
84
|
+
**Used when:** User changes their contact/form email in the app and we need to update CRM and optionally the member record.
|
|
85
|
+
|
|
86
|
+
**File:** `member-contact-orchestration.js` → `updateContactEmail(newContactEmail, existingMemberData)` (used by `updateMemberContactInfo`).
|
|
87
|
+
|
|
88
|
+
### Logic in short
|
|
89
|
+
|
|
90
|
+
- **Single entity** (`wixContactId === wixMemberId`):
|
|
91
|
+
- If new email **equals** login → no change.
|
|
92
|
+
- If new email **differs** → create a new Contact with the new email and point the member to it (we now have a separate contact).
|
|
93
|
+
- **Separate contact** (different IDs):
|
|
94
|
+
- If new email **equals** login → delete the extra contact and set `wixContactId = wixMemberId` (collapse to single entity).
|
|
95
|
+
- If new email **differs** → update the existing contact’s email in CRM.
|
|
96
|
+
|
|
97
|
+
### Flow diagram
|
|
98
|
+
|
|
99
|
+
```mermaid
|
|
100
|
+
flowchart TD
|
|
101
|
+
A[updateContactEmail] --> B{contactEmail === loginEmail?}
|
|
102
|
+
B -->|Yes| C{Single entity?}
|
|
103
|
+
B -->|No| D{Single entity?}
|
|
104
|
+
C -->|Yes| E[No-op: already in sync]
|
|
105
|
+
C -->|No| F[Delete contact\nSet wixContactId = wixMemberId]
|
|
106
|
+
D -->|Yes| G[Create new Contact with new email\nUpdate member wixContactId]
|
|
107
|
+
D -->|No| H[Update existing contact email in CRM]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Decision table
|
|
111
|
+
|
|
112
|
+
| Single entity? | New email vs login | Action |
|
|
113
|
+
| -------------- | ------------------ | ----------------------------------------------------------------- |
|
|
114
|
+
| Yes | Same | No-op |
|
|
115
|
+
| Yes | Different | Create new Contact; set member’s `wixContactId` to new contact ID |
|
|
116
|
+
| No | Same | Delete Contact; set member’s `wixContactId = wixMemberId` |
|
|
117
|
+
| No | Different | Update existing Contact’s email in CRM (no member change) |
|
|
118
|
+
|
|
119
|
+
### Where it’s used
|
|
120
|
+
|
|
121
|
+
`updateContactEmail` is called from `updateMemberContactInfo` when the **contactFormEmail** field changes (e.g. profile or form update). So:
|
|
122
|
+
|
|
123
|
+
- **Login flow** → `createContactAndMemberIfNew` (ensure member + contact exist and IDs stored).
|
|
124
|
+
- **Profile/form email change** → `updateMemberContactInfo` → `updateContactEmail` (keep contact and member in sync).
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## File roles
|
|
129
|
+
|
|
130
|
+
| File | Role |
|
|
131
|
+
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
132
|
+
| `contacts-methods.js` | Contact CRUD only: create/update/delete contact in Wix CRM. |
|
|
133
|
+
| `member-contact-orchestration.js` | Orchestration: when to create/update/delete contact and when to update member’s `wixContactId`. Uses injected `updateMember`. |
|
|
134
|
+
| `members-data-methods.js` | Member CRUD, `createContactAndMemberIfNew`; requires `updateMemberContactInfo` and calls `updateMember` once with its return value. |
|
|
135
|
+
|
|
136
|
+
Dependency direction: **members-data** → **member-contact-orchestration** → **contacts** (no cycle).
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
jest.mock('../elevated-modules', () => ({
|
|
2
|
+
wixData: { query: jest.fn() },
|
|
3
|
+
}));
|
|
4
|
+
jest.mock('psdev-task-manager', () => ({ taskManager: jest.fn() }));
|
|
5
|
+
|
|
6
|
+
const { taskManager } = require('psdev-task-manager');
|
|
7
|
+
|
|
8
|
+
const { buildDailyPullTasks } = require('../daily-pull/schedule-methods');
|
|
9
|
+
const { wixData } = require('../elevated-modules');
|
|
10
|
+
const { TASKS_NAMES } = require('../tasks/consts');
|
|
11
|
+
const { dailyPullExecutionCheck } = require('../tasks/daily-pull-check-methods');
|
|
12
|
+
|
|
13
|
+
const makeQueryResult = items => ({ items, hasNext: () => false });
|
|
14
|
+
|
|
15
|
+
const mockTasksQuery = items => {
|
|
16
|
+
const query = {
|
|
17
|
+
hasSome: jest.fn().mockReturnThis(),
|
|
18
|
+
ge: jest.fn().mockReturnThis(),
|
|
19
|
+
find: jest.fn().mockResolvedValue(makeQueryResult(items)),
|
|
20
|
+
};
|
|
21
|
+
wixData.query.mockReturnValue(query);
|
|
22
|
+
return query;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('buildDailyPullTasks', () => {
|
|
26
|
+
test('schedules one ScheduleMembersDataPerAction task per action, excluding none by default', () => {
|
|
27
|
+
const tasks = buildDailyPullTasks();
|
|
28
|
+
|
|
29
|
+
expect(tasks.map(task => task.data.action).sort()).toEqual(['drop', 'new', 'update']);
|
|
30
|
+
tasks.forEach(task => {
|
|
31
|
+
expect(task.name).toBe(TASKS_NAMES.ScheduleMembersDataPerAction);
|
|
32
|
+
expect(task.type).toBe('scheduled');
|
|
33
|
+
expect(task.data).toEqual({ action: task.data.action });
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('includes the none action and flag when includeNone is set', () => {
|
|
38
|
+
const tasks = buildDailyPullTasks({ includeNone: true });
|
|
39
|
+
|
|
40
|
+
expect(tasks.map(task => task.data.action).sort()).toEqual(['drop', 'new', 'none', 'update']);
|
|
41
|
+
tasks.forEach(task => expect(task.data.includeNone).toBe(true));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('propagates isTestEnvironment and backupDate to every task', () => {
|
|
45
|
+
const tasks = buildDailyPullTasks({ isTestEnvironment: true, backupDate: '2026-06-01' });
|
|
46
|
+
|
|
47
|
+
tasks.forEach(task => {
|
|
48
|
+
expect(task.data.isTestEnvironment).toBe(true);
|
|
49
|
+
expect(task.data.backupDate).toBe('2026-06-01');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('dailyPullExecutionCheck', () => {
|
|
55
|
+
let scheduleInBulk;
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
wixData.query.mockReset();
|
|
59
|
+
scheduleInBulk = jest.fn().mockResolvedValue(undefined);
|
|
60
|
+
taskManager.mockReturnValue({ scheduleInBulk });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('counts the per-action tasks the cron creates as evidence the pull ran', async () => {
|
|
64
|
+
const query = mockTasksQuery([{ name: TASKS_NAMES.ScheduleMembersDataPerAction }]);
|
|
65
|
+
|
|
66
|
+
const result = await dailyPullExecutionCheck({});
|
|
67
|
+
|
|
68
|
+
expect(query.hasSome).toHaveBeenCalledWith('name', [
|
|
69
|
+
TASKS_NAMES.ScheduleMembersDataPerAction,
|
|
70
|
+
TASKS_NAMES.ScheduleDailyMembersDataSync,
|
|
71
|
+
]);
|
|
72
|
+
expect(result.success).toBe(true);
|
|
73
|
+
expect(result.fallbackScheduled).toBeUndefined();
|
|
74
|
+
expect(scheduleInBulk).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('schedules the per-action fallback with environment flags when no pull is found', async () => {
|
|
78
|
+
mockTasksQuery([]);
|
|
79
|
+
|
|
80
|
+
const result = await dailyPullExecutionCheck({ isTestEnvironment: true, includeNone: true });
|
|
81
|
+
|
|
82
|
+
expect(result.success).toBe(false);
|
|
83
|
+
expect(result.fallbackScheduled).toBe(true);
|
|
84
|
+
expect(scheduleInBulk).toHaveBeenCalledTimes(1);
|
|
85
|
+
const scheduledTasks = scheduleInBulk.mock.calls[0][0];
|
|
86
|
+
expect(scheduledTasks.map(task => task.data.action).sort()).toEqual([
|
|
87
|
+
'drop',
|
|
88
|
+
'new',
|
|
89
|
+
'none',
|
|
90
|
+
'update',
|
|
91
|
+
]);
|
|
92
|
+
scheduledTasks.forEach(task => {
|
|
93
|
+
expect(task.name).toBe(TASKS_NAMES.ScheduleMembersDataPerAction);
|
|
94
|
+
expect(task.data.isTestEnvironment).toBe(true);
|
|
95
|
+
expect(task.data.includeNone).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('fallback defaults to production flags when no task data is provided', async () => {
|
|
100
|
+
mockTasksQuery([]);
|
|
101
|
+
|
|
102
|
+
await dailyPullExecutionCheck(undefined);
|
|
103
|
+
|
|
104
|
+
const scheduledTasks = scheduleInBulk.mock.calls[0][0];
|
|
105
|
+
expect(scheduledTasks.map(task => task.data.action).sort()).toEqual(['drop', 'new', 'update']);
|
|
106
|
+
scheduledTasks.forEach(task => {
|
|
107
|
+
expect(task.data.isTestEnvironment).toBeUndefined();
|
|
108
|
+
expect(task.data.includeNone).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('uses the provided lookback window', async () => {
|
|
113
|
+
const query = mockTasksQuery([{ name: TASKS_NAMES.ScheduleDailyMembersDataSync }]);
|
|
114
|
+
const before = Date.now();
|
|
115
|
+
|
|
116
|
+
const result = await dailyPullExecutionCheck({ hoursBack: 12 });
|
|
117
|
+
|
|
118
|
+
const sinceDate = query.ge.mock.calls[0][1];
|
|
119
|
+
expect(query.ge).toHaveBeenCalledWith('_createdDate', expect.any(Date));
|
|
120
|
+
const expectedSince = before - 12 * 60 * 60 * 1000;
|
|
121
|
+
expect(Math.abs(sinceDate.getTime() - expectedSince)).toBeLessThan(5000);
|
|
122
|
+
expect(result.success).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const { ensureUniqueUrlsInBatch } = require('../daily-pull/bulk-process-methods');
|
|
2
|
+
const membersDataMethods = require('../members-data-methods');
|
|
3
|
+
jest.mock('../members-data-methods');
|
|
4
|
+
|
|
5
|
+
describe('ensureUniqueUrlsInBatch', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
membersDataMethods.getMemberBySlug.mockReset();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('case-insensitivity (regression: avoid creating John-12 AND john-12)', () => {
|
|
11
|
+
test('merges John-11 and john-11 into one group and assigns unique sequential URLs', async () => {
|
|
12
|
+
membersDataMethods.getMemberBySlug.mockResolvedValue({ url: 'John-11', memberId: 1 });
|
|
13
|
+
|
|
14
|
+
const members = [
|
|
15
|
+
{ memberId: 101, url: 'John-11', fullName: 'John Doe' },
|
|
16
|
+
{ memberId: 102, url: 'john-11', fullName: 'John Doe' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const result = await ensureUniqueUrlsInBatch(members);
|
|
20
|
+
|
|
21
|
+
const urls = result.map(m => m.url);
|
|
22
|
+
|
|
23
|
+
expect(urls).toHaveLength(2);
|
|
24
|
+
expect(new Set(urls).size).toBe(2);
|
|
25
|
+
expect(urls.some(u => u.endsWith('-12'))).toBe(true);
|
|
26
|
+
expect(urls.some(u => u.endsWith('-13'))).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('does NOT create both John-12 and john-12 (case duplicate)', async () => {
|
|
30
|
+
membersDataMethods.getMemberBySlug.mockResolvedValue({ url: 'John-11', memberId: 1 });
|
|
31
|
+
|
|
32
|
+
const members = [
|
|
33
|
+
{ memberId: 101, url: 'John-11' },
|
|
34
|
+
{ memberId: 102, url: 'john-11' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const result = await ensureUniqueUrlsInBatch(members);
|
|
38
|
+
const urls = result.map(m => m.url);
|
|
39
|
+
|
|
40
|
+
const hasJohn12 = urls.some(u => u === 'John-12');
|
|
41
|
+
const hasjohn12 = urls.some(u => u === 'john-12');
|
|
42
|
+
expect(hasJohn12 && hasjohn12).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('handles JOHN, John, john variants - all merge into single group', async () => {
|
|
46
|
+
membersDataMethods.getMemberBySlug.mockResolvedValue(null);
|
|
47
|
+
|
|
48
|
+
const members = [
|
|
49
|
+
{ memberId: 101, url: 'JOHN-5' },
|
|
50
|
+
{ memberId: 102, url: 'John-5' },
|
|
51
|
+
{ memberId: 103, url: 'john-5' },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const result = await ensureUniqueUrlsInBatch(members);
|
|
55
|
+
const urls = result.map(m => m.url);
|
|
56
|
+
|
|
57
|
+
expect(urls).toHaveLength(3);
|
|
58
|
+
expect(new Set(urls).size).toBe(3);
|
|
59
|
+
const bases = urls.map(u => u.replace(/-\d+$/, '').toLowerCase());
|
|
60
|
+
expect(new Set(bases).size).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('multiple members with same base URL', () => {
|
|
65
|
+
test('assigns sequential counters starting after batch max and DB max', async () => {
|
|
66
|
+
membersDataMethods.getMemberBySlug.mockResolvedValue({
|
|
67
|
+
url: 'firstNameLastName-11',
|
|
68
|
+
memberId: 1,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const members = [
|
|
72
|
+
{ memberId: 201, url: 'firstNameLastName-11' },
|
|
73
|
+
{ memberId: 202, url: 'firstNameLastName-11' },
|
|
74
|
+
{ memberId: 203, url: 'firstNameLastName-11' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const result = await ensureUniqueUrlsInBatch(members);
|
|
78
|
+
const urls = result.map(m => m.url).sort();
|
|
79
|
+
|
|
80
|
+
expect(urls).toEqual([
|
|
81
|
+
'firstNameLastName-12',
|
|
82
|
+
'firstNameLastName-13',
|
|
83
|
+
'firstNameLastName-14',
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('uses batch max when DB has no matches', async () => {
|
|
88
|
+
membersDataMethods.getMemberBySlug.mockResolvedValue(null);
|
|
89
|
+
|
|
90
|
+
const members = [
|
|
91
|
+
{ memberId: 301, url: 'testUser-5' },
|
|
92
|
+
{ memberId: 302, url: 'testUser-5' },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const result = await ensureUniqueUrlsInBatch(members);
|
|
96
|
+
const urls = result.map(m => m.url).sort();
|
|
97
|
+
|
|
98
|
+
expect(urls).toEqual(['testUser-6', 'testUser-7']);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('edge cases', () => {
|
|
103
|
+
test('returns empty array unchanged', async () => {
|
|
104
|
+
const result = await ensureUniqueUrlsInBatch([]);
|
|
105
|
+
expect(result).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('returns non-array unchanged', async () => {
|
|
109
|
+
const input = { not: 'array' };
|
|
110
|
+
const result = await ensureUniqueUrlsInBatch(input);
|
|
111
|
+
expect(result).toBe(input);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('skips members without url', async () => {
|
|
115
|
+
membersDataMethods.getMemberBySlug.mockResolvedValue(null);
|
|
116
|
+
|
|
117
|
+
const members = [
|
|
118
|
+
{ memberId: 401, url: 'validUrl' },
|
|
119
|
+
{ memberId: 402, url: null },
|
|
120
|
+
{ memberId: 403 },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const result = await ensureUniqueUrlsInBatch(members);
|
|
124
|
+
expect(result).toHaveLength(3);
|
|
125
|
+
expect(result[0].url).toBe('validUrl');
|
|
126
|
+
expect(result[1].url).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const { LOGIN_EMAIL_SYNC_STATUS } = require('../consts');
|
|
2
|
+
const { summarizeLoginEmailOutcomes } = require('../daily-pull/utils');
|
|
3
|
+
|
|
4
|
+
const { UPDATED, FAILED, SKIPPED } = LOGIN_EMAIL_SYNC_STATUS;
|
|
5
|
+
|
|
6
|
+
describe('summarizeLoginEmailOutcomes', () => {
|
|
7
|
+
test('collects only FAILED outcomes as failures and failed ids', () => {
|
|
8
|
+
const outcomes = [
|
|
9
|
+
{ memberId: 1, wixMemberId: 'w1', desiredEmail: 'a@x.com', status: UPDATED },
|
|
10
|
+
{
|
|
11
|
+
memberId: 2,
|
|
12
|
+
wixMemberId: 'w2',
|
|
13
|
+
desiredEmail: 'b@x.com',
|
|
14
|
+
status: FAILED,
|
|
15
|
+
error: 'duplicate',
|
|
16
|
+
},
|
|
17
|
+
{ memberId: 3, status: SKIPPED, desiredEmail: 'c@x.com' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const { failedMemberIds, failures } = summarizeLoginEmailOutcomes(outcomes);
|
|
21
|
+
|
|
22
|
+
expect([...failedMemberIds]).toEqual([2]);
|
|
23
|
+
expect(failures).toEqual([
|
|
24
|
+
{ memberId: 2, wixMemberId: 'w2', desiredEmail: 'b@x.com', error: 'duplicate' },
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('UPDATED and SKIPPED never produce failures (CMS email is left to advance/no-op)', () => {
|
|
29
|
+
const outcomes = [
|
|
30
|
+
{ memberId: 1, status: UPDATED, desiredEmail: 'a@x.com' },
|
|
31
|
+
{ memberId: 2, status: SKIPPED, desiredEmail: 'b@x.com' },
|
|
32
|
+
];
|
|
33
|
+
const { failedMemberIds, failures } = summarizeLoginEmailOutcomes(outcomes);
|
|
34
|
+
expect(failedMemberIds.size).toBe(0);
|
|
35
|
+
expect(failures).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('defaults a missing error message', () => {
|
|
39
|
+
const { failures } = summarizeLoginEmailOutcomes([
|
|
40
|
+
{ memberId: 9, wixMemberId: 'w9', desiredEmail: 'z@x.com', status: FAILED },
|
|
41
|
+
]);
|
|
42
|
+
expect(failures[0].error).toBe('unknown error');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('handles empty / missing input', () => {
|
|
46
|
+
expect(summarizeLoginEmailOutcomes([]).failures).toEqual([]);
|
|
47
|
+
expect(summarizeLoginEmailOutcomes().failures).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
jest.mock('../elevated-modules', () => ({
|
|
2
|
+
wixData: { query: jest.fn() },
|
|
3
|
+
}));
|
|
4
|
+
|
|
5
|
+
const { wixData } = require('../elevated-modules');
|
|
6
|
+
const { findMembersByIds } = require('../members-data-methods');
|
|
7
|
+
const { withTransientErrorRetry, isTransientNetworkError } = require('../utils');
|
|
8
|
+
|
|
9
|
+
const makeQueryResult = items => ({ items, hasNext: () => false });
|
|
10
|
+
|
|
11
|
+
const mockQueryReturning = find => {
|
|
12
|
+
const query = {
|
|
13
|
+
hasSome: jest.fn().mockReturnThis(),
|
|
14
|
+
limit: jest.fn().mockReturnThis(),
|
|
15
|
+
find,
|
|
16
|
+
};
|
|
17
|
+
wixData.query.mockReturnValue(query);
|
|
18
|
+
return query;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('isTransientNetworkError', () => {
|
|
22
|
+
test('matches undici "fetch failed" and common network error codes', () => {
|
|
23
|
+
expect(isTransientNetworkError(new Error('fetch failed'))).toBe(true);
|
|
24
|
+
expect(isTransientNetworkError({ message: 'request failed', code: 'ECONNRESET' })).toBe(true);
|
|
25
|
+
expect(
|
|
26
|
+
isTransientNetworkError({ message: 'fetch failed', cause: { code: 'UND_ERR_SOCKET' } })
|
|
27
|
+
).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('does not match application errors', () => {
|
|
31
|
+
expect(isTransientNetworkError(new Error('Multiple members found with memberId 1'))).toBe(
|
|
32
|
+
false
|
|
33
|
+
);
|
|
34
|
+
expect(isTransientNetworkError(new Error('WDE0025: validation failed'))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('withTransientErrorRetry', () => {
|
|
39
|
+
test('retries transient failures and resolves with the eventual result', async () => {
|
|
40
|
+
const operation = jest
|
|
41
|
+
.fn()
|
|
42
|
+
.mockRejectedValueOnce(new Error('fetch failed'))
|
|
43
|
+
.mockResolvedValueOnce('ok');
|
|
44
|
+
|
|
45
|
+
await expect(withTransientErrorRetry(operation, { baseDelayMs: 1 })).resolves.toBe('ok');
|
|
46
|
+
expect(operation).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('rethrows immediately on non-transient errors', async () => {
|
|
50
|
+
const operation = jest.fn().mockRejectedValue(new Error('validation failed'));
|
|
51
|
+
|
|
52
|
+
await expect(withTransientErrorRetry(operation, { baseDelayMs: 1 })).rejects.toThrow(
|
|
53
|
+
'validation failed'
|
|
54
|
+
);
|
|
55
|
+
expect(operation).toHaveBeenCalledTimes(1);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('gives up after the configured number of retries', async () => {
|
|
59
|
+
const operation = jest.fn().mockRejectedValue(new Error('fetch failed'));
|
|
60
|
+
|
|
61
|
+
await expect(
|
|
62
|
+
withTransientErrorRetry(operation, { retries: 2, baseDelayMs: 1 })
|
|
63
|
+
).rejects.toThrow('fetch failed');
|
|
64
|
+
expect(operation).toHaveBeenCalledTimes(3);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('findMembersByIds', () => {
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
wixData.query.mockReset();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('returns a map of String(memberId) to member record', async () => {
|
|
74
|
+
mockQueryReturning(
|
|
75
|
+
jest.fn().mockResolvedValue(
|
|
76
|
+
makeQueryResult([
|
|
77
|
+
{ _id: 'a', memberId: 1 },
|
|
78
|
+
{ _id: 'b', memberId: 2 },
|
|
79
|
+
])
|
|
80
|
+
)
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const membersById = await findMembersByIds([1, 2, 3]);
|
|
84
|
+
|
|
85
|
+
expect(membersById.get('1')).toEqual({ _id: 'a', memberId: 1 });
|
|
86
|
+
expect(membersById.get('2')).toEqual({ _id: 'b', memberId: 2 });
|
|
87
|
+
expect(membersById.has('3')).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('deduplicates input IDs and skips null/undefined before querying', async () => {
|
|
91
|
+
const query = mockQueryReturning(jest.fn().mockResolvedValue(makeQueryResult([])));
|
|
92
|
+
|
|
93
|
+
await findMembersByIds([1, 1, null, undefined, 2]);
|
|
94
|
+
|
|
95
|
+
expect(query.hasSome).toHaveBeenCalledWith('memberId', [1, 2]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('returns an empty map without querying when there are no IDs', async () => {
|
|
99
|
+
const membersById = await findMembersByIds([]);
|
|
100
|
+
|
|
101
|
+
expect(membersById.size).toBe(0);
|
|
102
|
+
expect(wixData.query).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('chunks large ID lists into multiple queries', async () => {
|
|
106
|
+
const query = mockQueryReturning(jest.fn().mockResolvedValue(makeQueryResult([])));
|
|
107
|
+
const ids = Array.from({ length: 250 }, (_, i) => i + 1);
|
|
108
|
+
|
|
109
|
+
await findMembersByIds(ids);
|
|
110
|
+
|
|
111
|
+
expect(query.hasSome).toHaveBeenCalledTimes(3);
|
|
112
|
+
expect(query.hasSome.mock.calls.map(call => call[1].length)).toEqual([100, 100, 50]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('throws when multiple records share the same memberId', async () => {
|
|
116
|
+
mockQueryReturning(
|
|
117
|
+
jest.fn().mockResolvedValue(
|
|
118
|
+
makeQueryResult([
|
|
119
|
+
{ _id: 'a', memberId: 1 },
|
|
120
|
+
{ _id: 'b', memberId: 1 },
|
|
121
|
+
])
|
|
122
|
+
)
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await expect(findMembersByIds([1])).rejects.toThrow(
|
|
126
|
+
'Multiple members found with memberId(s): [1]'
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
});
|