Skip to content

Commit f96ca65

Browse files
authored
Merge pull request #2 from coldbox-modules/codex/sanitize-result-request-mementos
Add resubscribe method
2 parents dd2dbce + 3542e6d commit f96ca65

4 files changed

Lines changed: 169 additions & 4 deletions

File tree

.github/workflows/tests.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ jobs:
5353
box install
5454
5555
- name: Start ${{ matrix.cfengine }} Server
56+
continue-on-error: ${{ matrix.experimental }}
5657
env:
5758
CORDIAL_SDK_API_KEY: ${{ secrets.CORDIAL_SDK_API_KEY }}
5859
CORDIAL_SDK_BASE_URL: ${{ secrets.CORDIAL_SDK_BASE_URL }}
@@ -79,13 +80,14 @@ jobs:
7980
if: always()
8081
with:
8182
files: tests/results/**/*.xml
82-
check_name: "${{ matrix.cfengine }} Test Results"
83+
check_name: "${{ matrix.cfengine }} Test Results (FULL_NULL=${{ matrix.fullNull }})"
84+
comment_mode: "off"
8385

8486
- name: Upload Test Results to Artifacts
8587
if: always()
8688
uses: actions/upload-artifact@v7
8789
with:
88-
name: test-results-${{ matrix.cfengine }}
90+
name: test-results-${{ matrix.cfengine }}-fullNull-${{ matrix.fullNull }}
8991
path: |
9092
tests/results/**/*
9193
@@ -98,7 +100,7 @@ jobs:
98100
if: ${{ failure() }}
99101
uses: actions/upload-artifact@v7
100102
with:
101-
name: Failure Debugging Info - ${{ matrix.cfengine }}
103+
name: Failure Debugging Info - ${{ matrix.cfengine }} - FULL_NULL=${{ matrix.fullNull }}
102104
path: |
103105
.engine/**/logs/*
104106
.engine/**/WEB-INF/cfusion/logs/*
@@ -111,6 +113,6 @@ jobs:
111113
SLACK_COLOR: ${{ job.status }}
112114
SLACK_ICON_EMOJI: ":bell:"
113115
SLACK_MESSAGE: '${{ github.repository }} tests failed :cry:'
114-
SLACK_TITLE: ${{ github.repository }} Tests For ${{ matrix.cfengine }} failed
116+
SLACK_TITLE: ${{ github.repository }} Tests For ${{ matrix.cfengine }} FULL_NULL=${{ matrix.fullNull }} failed
115117
SLACK_USERNAME: CI
116118
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,29 @@ Behavior:
123123
* Performs a global email channel unsubscribe, not a list membership update.
124124
* Invalid email entries are reported as failures and do not generate HTTP requests.
125125

126+
#### resubscribe
127+
128+
Resubscribes many subscribers to the email channel.
129+
130+
```cfc
131+
var result = subscriptions.resubscribe(
132+
subscribers = [ "one@example.com", "two@example.com" ],
133+
concurrencyLimit = 5
134+
);
135+
```
136+
137+
| Name | Type | Required? | Default | Description |
138+
| ---- | ---- | --------- | ------- | ----------- |
139+
| `subscribers` | Array<String> | `true` | | Email subscribers for this operation. |
140+
| `concurrencyLimit` | Numeric | `false` | module setting | Max async requests in each chunk. |
141+
142+
Behavior:
143+
144+
* Uses `PUT /v2/contacts/email:{urlEncodedEmail}` per valid subscriber.
145+
* Sets `forceSubscribe = true` and `channels.email.subscribeStatus = "subscribed"`.
146+
* Performs a global email channel resubscribe, not a list membership update.
147+
* Invalid email entries are reported as failures and do not generate HTTP requests.
148+
126149
## Return Contract
127150

128151
All methods return an aggregate result struct:

models/Subscriptions.cfc

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,40 @@ component singleton accessors="true" {
132132
return result;
133133
}
134134

135+
/**
136+
* Resubscribe many subscribers to the email channel.
137+
*/
138+
function resubscribe( required array subscribers, numeric concurrencyLimit = variables.maxConcurrency ) {
139+
validateSubscribersArray( arguments.subscribers );
140+
141+
var normalized = normalizeSubscribers( arguments.subscribers );
142+
var result = newAggregateResult( normalized.totalRequested );
143+
144+
arrayAppend( result.results, normalized.preflightFailures, true );
145+
146+
if ( !normalized.validEmails.len() ) {
147+
finalizeResult( result );
148+
return result;
149+
}
150+
151+
var operationResults = executeInParallel(
152+
emails = normalized.validEmails,
153+
concurrencyLimit = normalizeConcurrency( arguments.concurrencyLimit ),
154+
callback = function( required string subscriberEmail ) {
155+
return hyperClient
156+
.new()
157+
.setMethod( "PUT" )
158+
.setUrl( "/v2/contacts/email:#urlEncodedFormat( subscriberEmail )#" )
159+
.setBody( buildResubscribePayload() );
160+
}
161+
);
162+
163+
arrayAppend( result.results, operationResults, true );
164+
finalizeResult( result );
165+
166+
return result;
167+
}
168+
135169
private struct function buildSubscribePayload(
136170
required string listKey,
137171
required string subscriberEmail,
@@ -150,6 +184,10 @@ component singleton accessors="true" {
150184
return payload;
151185
}
152186

187+
private struct function buildResubscribePayload() {
188+
return { "forceSubscribe": true, "channels": { "email": { "subscribeStatus": "subscribed" } } };
189+
}
190+
153191
private array function executeInParallel(
154192
required array emails,
155193
required numeric concurrencyLimit,

tests/specs/unit/SubscriptionsSpec.cfc

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,54 @@ component extends="tests.resources.ModuleIntegrationSpec" appMapping="/app" {
163163
} );
164164
} );
165165

166+
it( "builds resubscribe requests for each valid subscriber", function() {
167+
var result = variables.client.resubscribe(
168+
subscribers = [ "person1@example.com", "person2@example.com" ],
169+
concurrencyLimit = 2
170+
);
171+
172+
expect( result.total ).toBe( 2 );
173+
expect( result.success ).toBeTrue();
174+
expect( result.succeeded ).toBe( 2 );
175+
expect( result.failed ).toBe( 0 );
176+
expect( result.results ).toHaveLength( 2 );
177+
expect( result.results[ 1 ].subscriber ).toBe( "person1@example.com" );
178+
expect( result.results[ 1 ].success ).toBeTrue();
179+
expect( result.results[ 1 ].statusCode ).toBeGTE( 200 );
180+
expect( result.results[ 1 ].statusCode ).toBeLT( 300 );
181+
expect( result.results[ 2 ].subscriber ).toBe( "person2@example.com" );
182+
expect( result.results[ 2 ].success ).toBeTrue();
183+
expect( result.results[ 2 ].statusCode ).toBeGTE( 200 );
184+
expect( result.results[ 2 ].statusCode ).toBeLT( 300 );
185+
expect( variables.hyper ).toHaveSentCount( 2 );
186+
187+
expect( variables.hyper ).toHaveSentRequest( function( req ) {
188+
var body = req.getBody();
189+
return req.getMethod() == "PUT"
190+
&& req.getUrl().startsWith( "/v2/contacts/email:" )
191+
&& findNoCase( "person1", req.getUrl() )
192+
&& body.forceSubscribe == true
193+
&& body.channels.email.subscribeStatus == "subscribed";
194+
} );
195+
} );
196+
197+
it( "url encodes special characters for resubscribe endpoint email key", function() {
198+
var result = variables.client.resubscribe( subscribers = [ "first.last+promo%tag@example.com" ] );
199+
200+
expect( result.total ).toBe( 1 );
201+
expect( result.success ).toBeTrue();
202+
expect( variables.hyper ).toHaveSentCount( 1 );
203+
204+
expect( variables.hyper ).toHaveSentRequest( function( req ) {
205+
var requestURL = req.getUrl();
206+
return req.getMethod() == "PUT"
207+
&& requestURL.startsWith( "/v2/contacts/email:" )
208+
&& !requestURL.endsWith( "/unsubscribe/email" )
209+
&& findNoCase( "%25", requestURL )
210+
&& ( findNoCase( "%2B", requestURL ) || findNoCase( "+", requestURL ) );
211+
} );
212+
} );
213+
166214
it( "throws for an empty list key", function() {
167215
expect( function() {
168216
variables.client.create( listKey = "", subscribers = [ "person@example.com" ] );
@@ -187,6 +235,12 @@ component extends="tests.resources.ModuleIntegrationSpec" appMapping="/app" {
187235
} ).toThrow( type = "cordial-sdk.InvalidSubscribers" );
188236
} );
189237

238+
it( "throws for an empty subscriber array when resubscribing", function() {
239+
expect( function() {
240+
variables.client.resubscribe( subscribers = [] );
241+
} ).toThrow( type = "cordial-sdk.InvalidSubscribers" );
242+
} );
243+
190244
it( "returns failed results for blank subscriber values while still processing valid emails", function() {
191245
var result = variables.client.create(
192246
listKey = "myList",
@@ -243,6 +297,21 @@ component extends="tests.resources.ModuleIntegrationSpec" appMapping="/app" {
243297
expect( variables.hyper ).toHaveSentCount( 1 );
244298
} );
245299

300+
it( "returns mixed success and failure details for resubscribe when a subscriber is blank", function() {
301+
var result = variables.client.resubscribe( subscribers = [ "person@example.com", "" ] );
302+
303+
expect( result.total ).toBe( 2 );
304+
expect( result.success ).toBeFalse();
305+
expect( result.succeeded ).toBe( 1 );
306+
expect( result.failed ).toBe( 1 );
307+
expect( result.results ).toHaveLength( 2 );
308+
expect( result.results[ 1 ].subscriber ).toBe( "" );
309+
expect( result.results[ 1 ].exceptionType ).toBe( "InvalidSubscriber" );
310+
expect( result.results[ 2 ].subscriber ).toBe( "person@example.com" );
311+
expect( result.results[ 2 ].success ).toBeTrue();
312+
expect( variables.hyper ).toHaveSentCount( 1 );
313+
} );
314+
246315
it( "returns only preflight failures for cancel when all subscribers are blank", function() {
247316
var result = variables.client.cancel( listKey = "myList", subscribers = [ "", " " ] );
248317

@@ -269,6 +338,19 @@ component extends="tests.resources.ModuleIntegrationSpec" appMapping="/app" {
269338
expect( variables.hyper ).toHaveSentNothing();
270339
} );
271340

341+
it( "returns only preflight failures for resubscribe when all subscribers are blank", function() {
342+
var result = variables.client.resubscribe( subscribers = [ "", " " ] );
343+
344+
expect( result.total ).toBe( 2 );
345+
expect( result.success ).toBeFalse();
346+
expect( result.succeeded ).toBe( 0 );
347+
expect( result.failed ).toBe( 2 );
348+
expect( result.results ).toHaveLength( 2 );
349+
expect( result.results[ 1 ].exceptionType ).toBe( "InvalidSubscriber" );
350+
expect( result.results[ 2 ].exceptionType ).toBe( "InvalidSubscriber" );
351+
expect( variables.hyper ).toHaveSentNothing();
352+
} );
353+
272354
it( "returns only preflight failures when all subscribers are blank and sends no requests", function() {
273355
var result = variables.client.create( listKey = "myList", subscribers = [ "", " " ] );
274356

@@ -387,6 +469,26 @@ component extends="tests.resources.ModuleIntegrationSpec" appMapping="/app" {
387469
expect( result.results[ 1 ].statusCode ).toBe( 503 );
388470
} );
389471

472+
it( "marks resubscribe results as failed when Cordial returns non-2xx", function() {
473+
variables.hyper
474+
.fake( {
475+
"*/v2/contacts/email:*": function( newFakeResponse, req ) {
476+
return newFakeResponse( 503, "Service Unavailable", "{}" );
477+
}
478+
} )
479+
.preventStrayRequests();
480+
481+
var result = variables.client.resubscribe( subscribers = [ "person@example.com" ] );
482+
483+
expect( result.total ).toBe( 1 );
484+
expect( result.success ).toBeFalse();
485+
expect( result.succeeded ).toBe( 0 );
486+
expect( result.failed ).toBe( 1 );
487+
expect( result.results[ 1 ].subscriber ).toBe( "person@example.com" );
488+
expect( result.results[ 1 ].success ).toBeFalse();
489+
expect( result.results[ 1 ].statusCode ).toBe( 503 );
490+
} );
491+
390492
it( "handles async future failures and returns mixed results", function() {
391493
variables.hyper
392494
.fake( {

0 commit comments

Comments
 (0)