Merge pull request #1274 from PicPeak/fix/1261-crm-invitation-visibility
fix(crm): tell the admin whether a customer's invitation actually went out (#1261)
This commit is contained in:
@@ -126,7 +126,16 @@ async function createInvitation({ email, invitedById, prefill }) {
|
|||||||
.first();
|
.first();
|
||||||
if (existingCustomer && existingCustomer.password_hash) {
|
if (existingCustomer && existingCustomer.password_hash) {
|
||||||
// Already-active customer with this email — duplicate, reject.
|
// Already-active customer with this email — duplicate, reject.
|
||||||
throw new ConflictError('A customer account with this email already exists', 'email');
|
//
|
||||||
|
// Carries the same code the send-invite route uses for its own
|
||||||
|
// already-active check (#1261). Both conflicts are 409 and both can mean
|
||||||
|
// "this address already has portal access" — the route reads the customer
|
||||||
|
// before this recheck runs, so an invitation accepted in between lands
|
||||||
|
// here instead — and a caller that cannot tell the two apart ends up
|
||||||
|
// telling the admin to cancel an invitation acceptance already closed.
|
||||||
|
const err = new ConflictError('A customer account with this email already exists', 'email');
|
||||||
|
err.code = 'CUSTOMER_ALREADY_ACTIVE';
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
// If the existing customer is PASSIVE (password_hash IS NULL), this
|
// If the existing customer is PASSIVE (password_hash IS NULL), this
|
||||||
// is the "promote to active" path: the admin clicked "Send portal
|
// is the "promote to active" path: the admin clicked "Send portal
|
||||||
@@ -139,7 +148,9 @@ async function createInvitation({ email, invitedById, prefill }) {
|
|||||||
.where('expires_at', '>', new Date())
|
.where('expires_at', '>', new Date())
|
||||||
.first();
|
.first();
|
||||||
if (pendingInvite) {
|
if (pendingInvite) {
|
||||||
throw new ConflictError('A pending invitation already exists for this email', 'email');
|
const err = new ConflictError('A pending invitation already exists for this email', 'email');
|
||||||
|
err.code = 'INVITATION_ALREADY_PENDING';
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 64-char hex = 32 bytes = 256 bits of entropy. Same as admin invites.
|
// 64-char hex = 32 bytes = 256 bits of entropy. Same as admin invites.
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
* stays saved (passive) and a warning toast asks the admin to retry
|
* stays saved (passive) and a warning toast asks the admin to retry
|
||||||
* from the customer detail page.
|
* from the customer detail page.
|
||||||
*
|
*
|
||||||
|
* #1261 — the toasts here say what actually happened rather than what was
|
||||||
|
* intended. Two calls means three outcomes, and the middle one used to be
|
||||||
|
* indistinguishable from success: the invitation email is only QUEUED, so
|
||||||
|
* "invitation sent" was a claim this code cannot make.
|
||||||
|
*
|
||||||
* Field set mirrors the customer detail page so admins see the same
|
* Field set mirrors the customer detail page so admins see the same
|
||||||
* shape regardless of where they're editing.
|
* shape regardless of where they're editing.
|
||||||
*/
|
*/
|
||||||
@@ -182,10 +187,41 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
|
|||||||
try {
|
try {
|
||||||
await customerAdminService.sendInvite(customer.id);
|
await customerAdminService.sendInvite(customer.id);
|
||||||
toast.success(t('customers.create.savedActiveToast',
|
toast.success(t('customers.create.savedActiveToast',
|
||||||
'Customer created and portal invitation sent.'));
|
'Customer created and portal invitation queued. It is sent by the email queue — check System health if it does not arrive.'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.warn(t('customers.create.inviteFailedToast',
|
// Four different things reach this branch and they need four
|
||||||
'Customer saved (passive). Invitation email failed — retry from the customer detail page.'));
|
// different answers. Only ONE of them means "nothing happened, go
|
||||||
|
// ahead and retry" — every other message that says so sends the
|
||||||
|
// admin into a retry that then 409s.
|
||||||
|
const status = err?.response?.status;
|
||||||
|
const code = err?.response?.data?.code;
|
||||||
|
|
||||||
|
if (code === 'CUSTOMER_ALREADY_ACTIVE') {
|
||||||
|
// This address already has portal access — either the route's own
|
||||||
|
// check, or an invitation accepted between it and the service's
|
||||||
|
// recheck. Nothing to cancel, nothing to resend.
|
||||||
|
toast.info(t('customers.create.inviteAlreadyActiveToast',
|
||||||
|
'Customer saved. This address already has portal access, so no invitation was needed.'));
|
||||||
|
} else if (status === 409) {
|
||||||
|
// An invitation for this address is already open and the RE-invite
|
||||||
|
// was refused. The customer IS invited.
|
||||||
|
toast.warn(t('customers.create.inviteAlreadyPendingToast',
|
||||||
|
'Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.'));
|
||||||
|
} else if (!err?.response || status >= 500) {
|
||||||
|
// No response (dropped connection, timeout) or a server error.
|
||||||
|
// Neither says the invitation was not created: createInvitation
|
||||||
|
// inserts the customer_invitations row and only then queues the
|
||||||
|
// email, without a transaction, so a 500 out of the queueing step
|
||||||
|
// leaves an open invitation behind. Asserting a clean failure here
|
||||||
|
// sends the admin into a retry that 409s and still queues nothing.
|
||||||
|
toast.warn(t('customers.create.inviteUnconfirmedToast',
|
||||||
|
'Customer saved, but the invitation could not be confirmed — check the Invitations tab before sending another.'));
|
||||||
|
} else {
|
||||||
|
// A 4xx that is not a conflict: validation, permissions, no such
|
||||||
|
// customer. The request was rejected before anything was written.
|
||||||
|
toast.warn(t('customers.create.inviteFailedToast',
|
||||||
|
'Customer saved as PASSIVE — no invitation went out. Retry "Send portal invitation" from the customer detail page.'));
|
||||||
|
}
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('sendInvite failed', err);
|
console.warn('sendInvite failed', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4874,10 +4874,13 @@
|
|||||||
"saveAsPassive": "Als passiven Kunden speichern",
|
"saveAsPassive": "Als passiven Kunden speichern",
|
||||||
"saveAndInvite": "Speichern & Portal-Einladung senden",
|
"saveAndInvite": "Speichern & Portal-Einladung senden",
|
||||||
"savedPassiveToast": "Passiver Kunde erstellt.",
|
"savedPassiveToast": "Passiver Kunde erstellt.",
|
||||||
"savedActiveToast": "Kunde erstellt und Portal-Einladung gesendet.",
|
"savedActiveToast": "Kunde angelegt und Portal-Einladung eingereiht. Der Versand läuft über die E-Mail-Warteschlange — prüfen Sie den Systemzustand, falls sie nicht ankommt.",
|
||||||
"inviteFailedToast": "Kunde gespeichert (passiv). Einladungs-E-Mail fehlgeschlagen — bitte aus dem Kundendetail erneut versuchen.",
|
"inviteFailedToast": "Kunde als PASSIV gespeichert — es ging keine Einladung raus. Wiederholen Sie „Portal-Einladung senden“ auf der Kundendetailseite.",
|
||||||
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.",
|
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.",
|
||||||
"nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an."
|
"nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an.",
|
||||||
|
"inviteAlreadyPendingToast": "Kunde gespeichert. Für diese Adresse ist bereits eine Einladung offen — stornieren Sie sie im Tab „Einladungen“, bevor Sie eine neue senden.",
|
||||||
|
"inviteAlreadyActiveToast": "Kunde gespeichert. Diese Adresse hat bereits Portalzugang, eine Einladung war nicht nötig.",
|
||||||
|
"inviteUnconfirmedToast": "Kunde gespeichert, aber die Einladung ist unbestätigt — prüfen Sie den Tab „Einladungen“, bevor Sie erneut senden."
|
||||||
},
|
},
|
||||||
"passive": {
|
"passive": {
|
||||||
"badge": "Passiv — nur Admin",
|
"badge": "Passiv — nur Admin",
|
||||||
@@ -5071,6 +5074,10 @@
|
|||||||
"addedN_other": "{{count}} hinzugefügt",
|
"addedN_other": "{{count}} hinzugefügt",
|
||||||
"removedN_one": "{{count}} entfernt",
|
"removedN_one": "{{count}} entfernt",
|
||||||
"removedN_other": "{{count}} entfernt"
|
"removedN_other": "{{count}} entfernt"
|
||||||
|
},
|
||||||
|
"invitePending": {
|
||||||
|
"badge": "Einladung offen",
|
||||||
|
"hint": "Für diese Adresse ist ein Einladungslink offen und noch nicht angenommen. Das ist kein Beleg dafür, dass die E-Mail angekommen ist — prüfen Sie den Systemzustand, falls der Kunde nichts erhalten hat."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
|||||||
@@ -4874,10 +4874,13 @@
|
|||||||
"saveAsPassive": "Save as passive customer",
|
"saveAsPassive": "Save as passive customer",
|
||||||
"saveAndInvite": "Save & send portal invitation",
|
"saveAndInvite": "Save & send portal invitation",
|
||||||
"savedPassiveToast": "Passive customer created.",
|
"savedPassiveToast": "Passive customer created.",
|
||||||
"savedActiveToast": "Customer created and portal invitation sent.",
|
"savedActiveToast": "Customer created and portal invitation queued. It is sent by the email queue — check System health if it does not arrive.",
|
||||||
"inviteFailedToast": "Customer saved (passive). Invitation email failed — retry from the customer detail page.",
|
"inviteFailedToast": "Customer saved as PASSIVE — no invitation went out. Retry \"Send portal invitation\" from the customer detail page.",
|
||||||
"emailRequired": "A valid email is required.",
|
"emailRequired": "A valid email is required.",
|
||||||
"nameRequired": "Enter at least a company name or a contact name."
|
"nameRequired": "Enter at least a company name or a contact name.",
|
||||||
|
"inviteAlreadyPendingToast": "Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.",
|
||||||
|
"inviteAlreadyActiveToast": "Customer saved. This address already has portal access, so no invitation was needed.",
|
||||||
|
"inviteUnconfirmedToast": "Customer saved, but the invitation could not be confirmed — check the Invitations tab before sending another."
|
||||||
},
|
},
|
||||||
"passive": {
|
"passive": {
|
||||||
"badge": "Passive — admin only",
|
"badge": "Passive — admin only",
|
||||||
@@ -5071,6 +5074,10 @@
|
|||||||
"addedN_other": "{{count}} added",
|
"addedN_other": "{{count}} added",
|
||||||
"removedN_one": "{{count}} removed",
|
"removedN_one": "{{count}} removed",
|
||||||
"removedN_other": "{{count}} removed"
|
"removedN_other": "{{count}} removed"
|
||||||
|
},
|
||||||
|
"invitePending": {
|
||||||
|
"badge": "Invitation pending",
|
||||||
|
"hint": "An invitation link for this address is open and has not been accepted. That is not proof the email reached them — check System health if they say it never arrived."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
|
UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock, MailCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { InlineCustomerCreate } from '../../components/admin/InlineCustomerCreate';
|
import { InlineCustomerCreate } from '../../components/admin/InlineCustomerCreate';
|
||||||
import { useMutationWithToast } from '../../hooks';
|
import { useMutationWithToast } from '../../hooks';
|
||||||
@@ -89,6 +89,18 @@ export const CustomerManagementPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}, [customers, debouncedTerm]);
|
}, [customers, debouncedTerm]);
|
||||||
|
|
||||||
|
// #1261 — the "Invite customer" flow is createDirect-then-sendInvite, so a
|
||||||
|
// customer whose second call never landed is left looking identical to one
|
||||||
|
// the admin deliberately created as passive. Both showed only
|
||||||
|
// "Passive — admin only", and there was nothing on this row to tell them
|
||||||
|
// apart. Cross-reference the invitations we already fetch (the endpoint
|
||||||
|
// returns unaccepted, unexpired ones) so an invited customer says so.
|
||||||
|
const pendingInviteByEmail = useMemo(() => {
|
||||||
|
const map = new Map<string, CustomerInvitationSummary>();
|
||||||
|
for (const i of invitations || []) map.set(i.email.trim().toLowerCase(), i);
|
||||||
|
return map;
|
||||||
|
}, [invitations]);
|
||||||
|
|
||||||
const filteredInvitations = useMemo(() => {
|
const filteredInvitations = useMemo(() => {
|
||||||
const list = invitations || [];
|
const list = invitations || [];
|
||||||
if (!debouncedTerm.trim()) return list;
|
if (!debouncedTerm.trim()) return list;
|
||||||
@@ -239,11 +251,28 @@ export const CustomerManagementPage: React.FC = () => {
|
|||||||
status badge sits on its own line so a
|
status badge sits on its own line so a
|
||||||
passive deactivated customer can still
|
passive deactivated customer can still
|
||||||
show both states clearly. */}
|
show both states clearly. */}
|
||||||
{c.isPassive && (
|
{c.isPassive && (() => {
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
const invite = pendingInviteByEmail.get(c.email.trim().toLowerCase());
|
||||||
{t('customers.passive.badge', 'Passive — admin only')}
|
return invite ? (
|
||||||
</span>
|
<span
|
||||||
)}
|
className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300"
|
||||||
|
// Deliberately describes the invitation ROW, not a
|
||||||
|
// delivery. createInvitation inserts the row and then
|
||||||
|
// queues the email without a transaction, so an open
|
||||||
|
// invitation does not prove an email_queue row exists,
|
||||||
|
// let alone that anything was delivered.
|
||||||
|
title={t('customers.invitePending.hint',
|
||||||
|
'An invitation link for this address is open and has not been accepted. That is not proof the email reached them — check System health if they say it never arrived.') as string}
|
||||||
|
>
|
||||||
|
<MailCheck className="w-3 h-3" />
|
||||||
|
{t('customers.invitePending.badge', 'Invitation pending')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t('customers.passive.badge', 'Passive — admin only')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-right">
|
<td className="px-3 py-3 text-right">
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* "Invite customer" is two calls: createDirect, then sendInvite. When the
|
||||||
|
* second one doesn't land, what's left behind is a passive customer — and the
|
||||||
|
* customers table rendered that identically to one the admin created as
|
||||||
|
* passive on purpose. Both showed "Passive — admin only", so the row could not
|
||||||
|
* answer the only question the admin had: did the invitation go out? (#1261)
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string, fb?: unknown) => (typeof fb === 'string' ? fb : k),
|
||||||
|
i18n: { language: 'en' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('react-toastify', () => ({
|
||||||
|
toast: { success: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||||
|
}));
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
const toastSuccess = toast.success as ReturnType<typeof vi.fn>;
|
||||||
|
const toastWarn = toast.warn as ReturnType<typeof vi.fn>;
|
||||||
|
const toastInfo = toast.info as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
vi.mock('../../../contexts/PermissionsContext', () => ({
|
||||||
|
usePermissions: () => ({
|
||||||
|
hasPermission: () => true,
|
||||||
|
hasAnyPermission: () => true,
|
||||||
|
hasAllPermissions: () => true,
|
||||||
|
isSuperAdmin: true,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const list = vi.fn();
|
||||||
|
const listInvitations = vi.fn();
|
||||||
|
const createDirect = vi.fn();
|
||||||
|
const sendInvite = vi.fn();
|
||||||
|
vi.mock('../../../services/customerAdmin.service', () => ({
|
||||||
|
customerAdminService: {
|
||||||
|
list: (...a: unknown[]) => list(...a),
|
||||||
|
listInvitations: (...a: unknown[]) => listInvitations(...a),
|
||||||
|
createDirect: (...a: unknown[]) => createDirect(...a),
|
||||||
|
sendInvite: (...a: unknown[]) => sendInvite(...a),
|
||||||
|
deactivate: vi.fn(),
|
||||||
|
cancelInvitation: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../services/businessProfile.service', () => ({
|
||||||
|
businessProfileService: { get: vi.fn().mockResolvedValue({ profile: {} }) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CustomerManagementPage } from '../CustomerManagementPage';
|
||||||
|
import { InlineCustomerCreate } from '../../../components/admin/InlineCustomerCreate';
|
||||||
|
|
||||||
|
const customer = (id: number, email: string, extra: Record<string, unknown> = {}) => ({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
displayName: `Customer ${id}`,
|
||||||
|
firstName: null,
|
||||||
|
lastName: null,
|
||||||
|
salutation: null,
|
||||||
|
companyName: null,
|
||||||
|
isActive: true,
|
||||||
|
isPassive: true,
|
||||||
|
eventCount: 0,
|
||||||
|
lastLogin: null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invitation = (id: number, email: string) => ({
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
expiresAt: '2026-12-01T00:00:00.000Z',
|
||||||
|
createdAt: '2026-09-01T00:00:00.000Z',
|
||||||
|
invitedBy: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderWith(node: React.ReactElement) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{node}</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
listInvitations.mockResolvedValue([]);
|
||||||
|
list.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomerManagementPage — invited vs merely passive (#1261)', () => {
|
||||||
|
it('marks a passive customer who has an open invitation', async () => {
|
||||||
|
list.mockResolvedValue([customer(1, '[email protected]')]);
|
||||||
|
listInvitations.mockResolvedValue([invitation(9, '[email protected]')]);
|
||||||
|
|
||||||
|
renderWith(<CustomerManagementPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Invitation pending')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Passive — admin only')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a passive customer with no invitation reading as passive', async () => {
|
||||||
|
list.mockResolvedValue([customer(2, '[email protected]')]);
|
||||||
|
listInvitations.mockResolvedValue([]);
|
||||||
|
|
||||||
|
renderWith(<CustomerManagementPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Passive — admin only')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Invitation pending')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the invitation regardless of address casing', async () => {
|
||||||
|
// customer_invitations stores the address lowercased; customer_accounts
|
||||||
|
// preserves what the admin typed. A case-sensitive match would show every
|
||||||
|
// mixed-case customer as never invited.
|
||||||
|
list.mockResolvedValue([customer(3, '[email protected]')]);
|
||||||
|
listInvitations.mockResolvedValue([invitation(11, '[email protected]')]);
|
||||||
|
|
||||||
|
renderWith(<CustomerManagementPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Invitation pending')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark an active customer, who needs no invitation', async () => {
|
||||||
|
list.mockResolvedValue([customer(4, '[email protected]', { isPassive: false })]);
|
||||||
|
listInvitations.mockResolvedValue([invitation(12, '[email protected]')]);
|
||||||
|
|
||||||
|
renderWith(<CustomerManagementPage />);
|
||||||
|
|
||||||
|
await screen.findByText('Customer 4');
|
||||||
|
expect(screen.queryByText('Invitation pending')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InlineCustomerCreate — what the toast may claim (#1261)', () => {
|
||||||
|
const fill = async () => {
|
||||||
|
await userEvent.type(screen.getByPlaceholderText('[email protected]'), '[email protected]');
|
||||||
|
await userEvent.type(screen.getByLabelText(/Company name/i), 'Acme');
|
||||||
|
};
|
||||||
|
|
||||||
|
it('says queued, not sent — this code cannot know it was delivered', async () => {
|
||||||
|
createDirect.mockResolvedValue({ id: 5, email: '[email protected]' });
|
||||||
|
sendInvite.mockResolvedValue({ id: 1, email: '[email protected]', expiresAt: 'x' });
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||||
|
const message = String(toastSuccess.mock.calls[0][0]);
|
||||||
|
expect(message).toMatch(/queued/i);
|
||||||
|
expect(message).not.toMatch(/invitation sent/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the customer is PASSIVE when the invitation was cleanly rejected', async () => {
|
||||||
|
// A 400 is the shape where "no invitation went out, retry" is true: the
|
||||||
|
// request never got as far as writing anything. Round 2 moved 5xx out of
|
||||||
|
// this branch, because a 500 can leave an invitation row behind.
|
||||||
|
createDirect.mockResolvedValue({ id: 6, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue({ response: { status: 400, data: {} } });
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastWarn).toHaveBeenCalled());
|
||||||
|
expect(String(toastWarn.mock.calls[0][0])).toMatch(/PASSIVE/);
|
||||||
|
expect(toastSuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('distinguishes a 409 — the customer IS invited, the re-invite was refused', async () => {
|
||||||
|
createDirect.mockResolvedValue({ id: 7, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue({ response: { status: 409 } });
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastWarn).toHaveBeenCalled());
|
||||||
|
const message = String(toastWarn.mock.calls[0][0]);
|
||||||
|
expect(message).toMatch(/already open/i);
|
||||||
|
expect(message).not.toMatch(/PASSIVE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('separates CUSTOMER_ALREADY_ACTIVE from a pending-invitation 409', async () => {
|
||||||
|
// Codex review round 1. Both conflicts are 409. This one means an open
|
||||||
|
// invitation was accepted between createDirect and sendInvite, so there is
|
||||||
|
// no invitation row to cancel — sending the admin to the Invitations tab
|
||||||
|
// points them at something that does not exist.
|
||||||
|
createDirect.mockResolvedValue({ id: 8, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue({
|
||||||
|
response: { status: 409, data: { code: 'CUSTOMER_ALREADY_ACTIVE' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastInfo).toHaveBeenCalled());
|
||||||
|
const message = String(toastInfo.mock.calls[0][0]);
|
||||||
|
expect(message).toMatch(/already has portal access/i);
|
||||||
|
expect(message).not.toMatch(/Invitations tab/i);
|
||||||
|
expect(toastWarn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays indeterminate on a 5xx — the invitation row may already exist', async () => {
|
||||||
|
// Codex review round 2. createInvitation inserts customer_invitations and
|
||||||
|
// only then queues the email, with no transaction around the pair, so a
|
||||||
|
// 500 out of the queueing step leaves an open invitation behind. Telling
|
||||||
|
// the admin to retry sends them into a 409 that still queues nothing.
|
||||||
|
createDirect.mockResolvedValue({ id: 11, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue({ response: { status: 500, data: {} } });
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastWarn).toHaveBeenCalled());
|
||||||
|
const message = String(toastWarn.mock.calls[0][0]);
|
||||||
|
expect(message).toMatch(/could not be confirmed/i);
|
||||||
|
expect(message).not.toMatch(/PASSIVE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still calls a plain 4xx a clean failure, where retrying is right', async () => {
|
||||||
|
// The one case where "nothing happened, go ahead and retry" is true: the
|
||||||
|
// request was rejected before anything was written.
|
||||||
|
createDirect.mockResolvedValue({ id: 12, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue({ response: { status: 403, data: {} } });
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastWarn).toHaveBeenCalled());
|
||||||
|
expect(String(toastWarn.mock.calls[0][0])).toMatch(/PASSIVE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the service-level already-active 409, not just the route-level one', async () => {
|
||||||
|
// Codex review round 2. createInvitation's own recheck throws
|
||||||
|
// ConflictError, which used to serialise as code CONFLICT — indistinguishable
|
||||||
|
// from the pending-invitation conflict, so the admin was told to cancel an
|
||||||
|
// invitation that acceptance had already closed. The service now labels it.
|
||||||
|
createDirect.mockResolvedValue({ id: 13, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue({
|
||||||
|
response: { status: 409, data: { code: 'CUSTOMER_ALREADY_ACTIVE', error: 'A customer account with this email already exists' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastInfo).toHaveBeenCalled());
|
||||||
|
expect(String(toastInfo.mock.calls[0][0])).toMatch(/already has portal access/i);
|
||||||
|
expect(toastWarn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays indeterminate when the server never answered', async () => {
|
||||||
|
// Codex review round 1. A dropped connection or timeout rejects with no
|
||||||
|
// `response`, but the request may have succeeded server-side. Declaring
|
||||||
|
// "no invitation went out" sends the admin into a retry that then 409s.
|
||||||
|
createDirect.mockResolvedValue({ id: 9, email: '[email protected]' });
|
||||||
|
sendInvite.mockRejectedValue(new Error('Network Error'));
|
||||||
|
|
||||||
|
renderWith(<InlineCustomerCreate mode="invite" onCreated={() => {}} onCancel={() => {}} />);
|
||||||
|
await fill();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastWarn).toHaveBeenCalled());
|
||||||
|
const message = String(toastWarn.mock.calls[0][0]);
|
||||||
|
expect(message).toMatch(/could not be confirmed/i);
|
||||||
|
expect(message).not.toMatch(/PASSIVE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not claim the invitation email was delivered', async () => {
|
||||||
|
// createInvitation inserts the customer_invitations row and only then
|
||||||
|
// queues the email, without a transaction — so an open invitation is not
|
||||||
|
// evidence that an email_queue row exists, let alone that it was sent.
|
||||||
|
list.mockResolvedValue([customer(10, '[email protected]')]);
|
||||||
|
listInvitations.mockResolvedValue([invitation(20, '[email protected]')]);
|
||||||
|
|
||||||
|
renderWith(<CustomerManagementPage />);
|
||||||
|
|
||||||
|
const badge = await screen.findByText('Invitation pending');
|
||||||
|
const tooltip = badge.getAttribute('title') || badge.closest('[title]')?.getAttribute('title') || '';
|
||||||
|
expect(tooltip).not.toMatch(/invitation sent/i);
|
||||||
|
expect(tooltip).toMatch(/not proof/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user