diff --git a/README.md b/README.md index ab3cee6..edc54b0 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,9 @@ reply schedule list --json re-run. On exit 3 (outcome unknown) a reply or a send may already have gone, and running it again may send it twice, unless the hint says nothing was sent. A direct send's exit 3 comes from a lost answer, a 5xx, or a 400 to the send whose JSON carries neither a `code` nor `errors[]`, which is -Reply failing while it made the send; a 400 with `errors[]` is `reply.invalid_request` and sent +Reply failing while it made the send. On a LinkedIn send, a 400 `directLinkedIn.unknown` is the +same: Reply failed while making the send or could not map the provider's answer, and its code is +kept in `reply_code`. A 400 with `errors[]` is `reply.invalid_request` and sent nothing. A send whose contact's import lost its answer was never made, and its hint says so; run again, it answers `contact.already_held` if that import did create the contact. An `--idempotency-key` does not change this: a Reply that does not know the header yet would make the send again. diff --git a/src/__tests__/operations/commands.test.ts b/src/__tests__/operations/commands.test.ts index 5da9511..c6e3845 100644 --- a/src/__tests__/operations/commands.test.ts +++ b/src/__tests__/operations/commands.test.ts @@ -307,6 +307,8 @@ describe('operations/commands', ()=>{ + 'refusal\'s own details; its code is the refusal\'s own'); expect(flat, text).toContain('A 400 to the send with neither a code nor errors[] is Reply failing while it made the send: ' + 'like a 5xx or a lost answer, it is outcome.unknown (exit 3)'); + expect(flat, text).toContain('So is a 400 directLinkedIn.unknown to a LinkedIn send, Reply\'s answer when it failed while ' + + 'making the send or could not map the provider\'s answer; Reply\'s code is kept in reply_code'); // Exactly the codes its endpoint publishes, then the shared ones, as the help lists them. const listed = text.split('\nErrors')[1].split('\nExit codes')[0].split('\n').slice(1).map(l=>l.trim()); expect(listed, name).toEqual([...codes, 'idempotency_key.in_use', 'idempotency_key.reused', 'access.denied', 'access.feature_unavailable', 'rate_limited', 'reply.unavailable', diff --git a/src/__tests__/operations/direct-send.test.ts b/src/__tests__/operations/direct-send.test.ts index 5f7bf35..08b62c1 100644 --- a/src/__tests__/operations/direct-send.test.ts +++ b/src/__tests__/operations/direct-send.test.ts @@ -530,6 +530,8 @@ describe('a direct send to a person given by their fields', ()=>{ [[nobody(), nobody(), json({items: [{id: null, status: 'failed', error: 'Invalid data'}]})], 'reply.refused', 1], // A 400 with neither a code nor errors[] is read as Reply's exception only on the send call, never on the import. [[nobody(), nobody(), json({title: 'Bad Request', status: 400}, 400, {'Content-Type': 'application/problem+json'})], 'reply.refused', 1], + // So is the LinkedIn sends' exception code. + [[nobody(), nobody(), json({code: 'directLinkedIn.unknown', detail: 'Unexpected error.'}, 400)], 'reply.refused', 1], ]; for (const [answers, code, exit_code] of cases) { @@ -1016,3 +1018,58 @@ describe('a 400 on the send call with neither a code nor errors[]', ()=>{ } }); }); + +describe('a 400 directLinkedIn.unknown on the send call', ()=>{ + const LINKEDIN: [Direct_send_name, unknown][] = [ + ['linkedin-connect', {note: NOTE}], + ['linkedin-message', {body: TEXT}], + ['linkedin-inmail', {subject: SUBJECT, body: TEXT}], + ['linkedin-voice', {script: SCRIPT}], + ]; + const unknown = (status: number, extra: Record = {})=> + json({code: 'directLinkedIn.unknown', detail: 'An unexpected error occurred.', ...extra}, status, {'Content-Type': 'application/problem+json'}); + + it('is Reply\'s exception path on a LinkedIn send: outcome.unknown (exit 3), status 400 and Reply\'s code kept, made once, ' + + 'and a repeat may send it twice', async()=>{ + for (const [name, message] of LINKEDIN) + { + mock_fetch.mockReset(); + answer(unknown(400)); + const e = await failure(name, message, LINKEDIN_OPTS); + expect(e, name).toMatchObject({code: 'outcome.unknown', exit_code: 3, status: 400, reply_code: 'directLinkedIn.unknown', + ids: {contact_id: 456}}); + expect((e as {hint?: string}).hint ?? '', name).toContain('may send it twice'); + expect(mock_fetch, name).toHaveBeenCalledTimes(1); + } + }); + + it('keeps the send it names under details, should Reply give one', async()=>{ + answer(unknown(400, {reference: 'linkedin-1042', state: 'failed'})); + const e = await failure('linkedin-message', {body: TEXT}, LINKEDIN_OPTS); + expect(e).toMatchObject({code: 'outcome.unknown', exit_code: 3, details: {reference: 'linkedin-1042', state: 'failed'}}); + }); + + it('under any other status it maps as before', async()=>{ + for (const [name, message] of LINKEDIN) + { + for (const [status, code] of [[409, 'reply.refused'], [403, 'access.denied'], [404, 'reply.refused']] as const) + { + mock_fetch.mockReset(); + answer(unknown(status)); + const e = await failure(name, message, LINKEDIN_OPTS); + expect(e, `${name} ${status}`).toMatchObject({code, exit_code: 1, status, reply_code: 'directLinkedIn.unknown'}); + } + } + }); + + it('on send-email it maps as before, and Reply\'s contact.emailSendingFailed stays a refusal: nothing was sent', async()=>{ + for (const reply_code of ['directLinkedIn.unknown', 'contact.emailSendingFailed']) + { + mock_fetch.mockReset(); + answer(json({code: reply_code, detail: 'The email could not be sent.'}, 400, {'Content-Type': 'application/problem+json'})); + const e = await failure('send-email', {subject: SUBJECT, body: BODY}, EMAIL_OPTS); + expect(e, reply_code).toMatchObject({code: 'reply.refused', exit_code: 1, status: 400, reply_code, ids: {contact_id: 456}}); + expect(mock_fetch, reply_code).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/operations/commands.ts b/src/operations/commands.ts index c96e5e7..d67ed67 100644 --- a/src/operations/commands.ts +++ b/src/operations/commands.ts @@ -311,7 +311,9 @@ const LATER = 'With --send-after Reply holds the send until that moment, given w + 'for in details: reference, state and send_after, each only when Reply gives it. Any other refusal of a send Reply\n' + 'already holds names that send the same way, beside the refusal\'s own details; its code is the refusal\'s own.\n' + 'A 400 to the send with neither a code nor errors[] is Reply failing while it made the send: like a 5xx or a lost\n' - + 'answer, it is outcome.unknown (exit 3). A 400 with errors[] is reply.invalid_request, and nothing was sent.\n'; + + 'answer, it is outcome.unknown (exit 3). So is a 400 directLinkedIn.unknown to a LinkedIn send, Reply\'s answer when\n' + + 'it failed while making the send or could not map the provider\'s answer; Reply\'s code is kept in reply_code. A 400\n' + + 'with errors[] is reply.invalid_request, and nothing was sent.\n'; // The refusals of a send's key, the same on every direct send, after each send's own. const KEY_CODES = ['idempotency_key.in_use', 'idempotency_key.reused']; diff --git a/src/operations/direct-send.ts b/src/operations/direct-send.ts index f88a7bb..7d2b846 100644 --- a/src/operations/direct-send.ts +++ b/src/operations/direct-send.ts @@ -229,14 +229,19 @@ const known_refusal = (a: Answer, table: Record): Operation_err const exception_of_send = (a: Answer): boolean=> a.status === 400 && is_object(a.data) && a.data.code === undefined && a.data.errors === undefined; +// The LinkedIn sends answer their own exception path, and a provider answer Reply cannot map, as a +// 400 directLinkedIn.unknown: the send may already be made. An email's exception is a 500. +const linkedin_exception = (a: Answer, by: Channel): boolean=> + by === 'linkedin' && a.status === 400 && reply_code_of(a) === 'directLinkedIn.unknown'; + const refusal_of_send = (a: Answer, contact_id: number, by: Channel): Operation_error=>{ - if (exception_of_send(a)) + const reply_code = reply_code_of(a); + if (exception_of_send(a) || linkedin_exception(a, by)) { return new Unknown_outcome_error('Reply failed while handling the send; it may or may not have been made.', { - status: a.status, detail: detail_of(a), + status: a.status, reply_code, detail: detail_of(a), }); } - const reply_code = reply_code_of(a); if (a.status === 404 && (reply_code === 'contact.notFound' || reply_code === 'directLinkedIn.contactNotFound')) { return contact_not_found(contact_id, a);