Skip to content

Commit abafa0a

Browse files
sfe: Create Salesforce Case at override request form submission time (#8438)
1 parent d1e5e16 commit abafa0a

File tree

16 files changed

+955
-108
lines changed

16 files changed

+955
-108
lines changed

cmd/email-exporter/main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ type Config struct {
4545
ClientSecret cmd.PasswordConfig
4646

4747
// SalesforceBaseURL is the base URL for the Salesforce API. (e.g.,
48-
// "https://login.salesforce.com")
48+
// "https://company.salesforce.com")
4949
SalesforceBaseURL string `validate:"required"`
5050

5151
// PardotBaseURL is the base URL for the Pardot API. (e.g.,
@@ -100,7 +100,7 @@ func main() {
100100
cache = email.NewHashedEmailCache(c.EmailExporter.EmailCacheSize, scope)
101101
}
102102

103-
pardotClient, err := email.NewPardotClientImpl(
103+
sfClient, err := email.NewSalesforceClientImpl(
104104
clk,
105105
c.EmailExporter.PardotBusinessUnit,
106106
clientId,
@@ -109,7 +109,7 @@ func main() {
109109
c.EmailExporter.PardotBaseURL,
110110
)
111111
cmd.FailOnError(err, "Creating Pardot API client")
112-
exporterServer := email.NewExporterImpl(pardotClient, cache, c.EmailExporter.PerDayLimit, c.EmailExporter.MaxConcurrentRequests, scope, logger)
112+
exporterServer := email.NewExporterImpl(sfClient, cache, c.EmailExporter.PerDayLimit, c.EmailExporter.MaxConcurrentRequests, scope, logger)
113113

114114
tlsConfig, err := c.EmailExporter.TLS.Load(scope)
115115
cmd.FailOnError(err, "Loading email-exporter TLS config")

email/exporter.go

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,11 @@ type ExporterImpl struct {
3939

4040
maxConcurrentRequests int
4141
limiter *rate.Limiter
42-
client PardotClient
42+
client SalesforceClient
4343
emailCache *EmailCache
4444
emailsHandledCounter prometheus.Counter
4545
pardotErrorCounter prometheus.Counter
46+
caseErrorCounter prometheus.Counter
4647
log blog.Logger
4748
}
4849

@@ -55,7 +56,7 @@ var _ emailpb.ExporterServer = (*ExporterImpl)(nil)
5556
// is assigned 40% (20,000 requests), it should also receive 40% of the max
5657
// concurrent requests (e.g., 2 out of 5). For more details, see:
5758
// https://developer.salesforce.com/docs/marketing/pardot/guide/overview.html?q=rate%20limits
58-
func NewExporterImpl(client PardotClient, cache *EmailCache, perDayLimit float64, maxConcurrentRequests int, scope prometheus.Registerer, logger blog.Logger) *ExporterImpl {
59+
func NewExporterImpl(client SalesforceClient, cache *EmailCache, perDayLimit float64, maxConcurrentRequests int, scope prometheus.Registerer, logger blog.Logger) *ExporterImpl {
5960
limiter := rate.NewLimiter(rate.Limit(perDayLimit/86400.0), maxConcurrentRequests)
6061

6162
emailsHandledCounter := prometheus.NewCounter(prometheus.CounterOpts{
@@ -70,6 +71,12 @@ func NewExporterImpl(client PardotClient, cache *EmailCache, perDayLimit float64
7071
})
7172
scope.MustRegister(pardotErrorCounter)
7273

74+
caseErrorCounter := prometheus.NewCounter(prometheus.CounterOpts{
75+
Name: "email_exporter_case_errors",
76+
Help: "Total number of errors encountered when sending Cases to the Salesforce REST API",
77+
})
78+
scope.MustRegister(caseErrorCounter)
79+
7380
impl := &ExporterImpl{
7481
maxConcurrentRequests: maxConcurrentRequests,
7582
limiter: limiter,
@@ -78,6 +85,7 @@ func NewExporterImpl(client PardotClient, cache *EmailCache, perDayLimit float64
7885
emailCache: cache,
7986
emailsHandledCounter: emailsHandledCounter,
8087
pardotErrorCounter: pardotErrorCounter,
88+
caseErrorCounter: caseErrorCounter,
8189
log: logger,
8290
}
8391
impl.wake = sync.NewCond(&impl.Mutex)
@@ -116,6 +124,33 @@ func (impl *ExporterImpl) SendContacts(ctx context.Context, req *emailpb.SendCon
116124
return &emptypb.Empty{}, nil
117125
}
118126

127+
// SendCase immediately submits a new Case to the Salesforce REST API using the
128+
// provided details. Any retries are handled internally by the SalesforceClient.
129+
// The following fields are required: Origin, Subject, ContactEmail.
130+
func (impl *ExporterImpl) SendCase(ctx context.Context, req *emailpb.SendCaseRequest) (*emptypb.Empty, error) {
131+
if core.IsAnyNilOrZero(req, req.Origin, req.Subject, req.ContactEmail) {
132+
return nil, berrors.InternalServerError("incomplete gRPC request message")
133+
}
134+
135+
err := impl.client.SendCase(Case{
136+
Origin: req.Origin,
137+
Subject: req.Subject,
138+
Description: req.Description,
139+
ContactEmail: req.ContactEmail,
140+
Organization: req.Organization,
141+
AccountId: req.AccountId,
142+
RateLimitName: req.RateLimitName,
143+
RateLimitTier: req.RateLimitTier,
144+
UseCase: req.UseCase,
145+
})
146+
if err != nil {
147+
impl.caseErrorCounter.Inc()
148+
return nil, berrors.InternalServerError("sending Case to the Salesforce REST API: %s", err)
149+
}
150+
151+
return &emptypb.Empty{}, nil
152+
}
153+
119154
// Start begins asynchronous processing of the email queue. When the parent
120155
// daemonCtx is cancelled the queue will be drained and the workers will exit.
121156
func (impl *ExporterImpl) Start(daemonCtx context.Context) {

email/exporter_test.go

Lines changed: 106 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,46 +18,64 @@ import (
1818

1919
var ctx = context.Background()
2020

21-
// mockPardotClientImpl is a mock implementation of PardotClient.
22-
type mockPardotClientImpl struct {
21+
var _ SalesforceClient = (*mockSalesforceClientImpl)(nil)
22+
23+
// mockSalesforceClientImpl is a mock implementation of PardotClient.
24+
type mockSalesforceClientImpl struct {
25+
SalesforceClient
26+
2327
sync.Mutex
2428
CreatedContacts []string
29+
CreatedCases []Case
2530
}
2631

27-
// newMockPardotClientImpl returns a MockPardotClientImpl, implementing the
28-
// PardotClient interface. Both refer to the same instance, with the interface
29-
// for mock interaction and the struct for state inspection and modification.
30-
func newMockPardotClientImpl() *mockPardotClientImpl {
31-
return &mockPardotClientImpl{
32-
CreatedContacts: []string{},
33-
}
32+
// newMockSalesforceClientImpl returns a mockSalesforceClientImpl, which implements
33+
// the PardotClient interface. It returns the underlying concrete type, so callers
34+
// have access to its struct members and helper methods.
35+
func newMockSalesforceClientImpl() *mockSalesforceClientImpl {
36+
return &mockSalesforceClientImpl{}
3437
}
3538

3639
// SendContact adds an email to CreatedContacts.
37-
func (m *mockPardotClientImpl) SendContact(email string) error {
40+
func (m *mockSalesforceClientImpl) SendContact(email string) error {
3841
m.Lock()
42+
defer m.Unlock()
3943
m.CreatedContacts = append(m.CreatedContacts, email)
40-
m.Unlock()
4144
return nil
4245
}
4346

44-
func (m *mockPardotClientImpl) getCreatedContacts() []string {
47+
func (m *mockSalesforceClientImpl) getCreatedContacts() []string {
4548
m.Lock()
4649
defer m.Unlock()
4750

4851
// Return a copy to avoid race conditions.
4952
return slices.Clone(m.CreatedContacts)
5053
}
5154

52-
// setup creates a new ExporterImpl, a MockPardotClientImpl, and the start and
55+
func (m *mockSalesforceClientImpl) SendCase(payload Case) error {
56+
m.Lock()
57+
defer m.Unlock()
58+
m.CreatedCases = append(m.CreatedCases, payload)
59+
return nil
60+
}
61+
62+
func (m *mockSalesforceClientImpl) getCreatedCases() []Case {
63+
m.Lock()
64+
defer m.Unlock()
65+
66+
// Return a copy to avoid race conditions.
67+
return slices.Clone(m.CreatedCases)
68+
}
69+
70+
// setup creates a new ExporterImpl, a mockSalesForceClientImpl, and the start and
5371
// cleanup functions for the ExporterImpl. Call start() to begin processing the
5472
// ExporterImpl queue and cleanup() to drain and shutdown. If start() is called,
5573
// cleanup() must be called.
56-
func setup() (*ExporterImpl, *mockPardotClientImpl, func(), func()) {
57-
mockClient := newMockPardotClientImpl()
58-
exporter := NewExporterImpl(mockClient, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
74+
func setup() (*ExporterImpl, *mockSalesforceClientImpl, func(), func()) {
75+
clientImpl := newMockSalesforceClientImpl()
76+
exporter := NewExporterImpl(clientImpl, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
5977
daemonCtx, cancel := context.WithCancel(context.Background())
60-
return exporter, mockClient,
78+
return exporter, clientImpl,
6179
func() { exporter.Start(daemonCtx) },
6280
func() {
6381
cancel()
@@ -134,7 +152,9 @@ func TestSendContactsQueueDrains(t *testing.T) {
134152
test.AssertEquals(t, 100, len(clientImpl.getCreatedContacts()))
135153
}
136154

137-
type mockAlwaysFailClient struct{}
155+
type mockAlwaysFailClient struct {
156+
mockSalesforceClientImpl
157+
}
138158

139159
func (m *mockAlwaysFailClient) SendContact(email string) error {
140160
return fmt.Errorf("simulated failure")
@@ -166,8 +186,8 @@ func TestSendContactDeduplication(t *testing.T) {
166186
t.Parallel()
167187

168188
cache := NewHashedEmailCache(1000, metrics.NoopRegisterer)
169-
mockClient := newMockPardotClientImpl()
170-
exporter := NewExporterImpl(mockClient, cache, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
189+
clientImpl := newMockSalesforceClientImpl()
190+
exporter := NewExporterImpl(clientImpl, cache, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
171191

172192
daemonCtx, cancel := context.WithCancel(context.Background())
173193
exporter.Start(daemonCtx)
@@ -181,7 +201,7 @@ func TestSendContactDeduplication(t *testing.T) {
181201
cancel()
182202
exporter.Drain()
183203

184-
contacts := mockClient.getCreatedContacts()
204+
contacts := clientImpl.getCreatedContacts()
185205
test.AssertEquals(t, 1, len(contacts))
186206
test.AssertEquals(t, "[email protected]", contacts[0])
187207

@@ -222,3 +242,68 @@ func TestSendContactErrorRemovesFromCache(t *testing.T) {
222242
// Check that the error counter was incremented.
223243
test.AssertMetricWithLabelsEquals(t, exporter.pardotErrorCounter, prometheus.Labels{}, 1)
224244
}
245+
246+
func TestSendCase(t *testing.T) {
247+
t.Parallel()
248+
249+
clientImpl := newMockSalesforceClientImpl()
250+
exporter := NewExporterImpl(clientImpl, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
251+
252+
_, err := exporter.SendCase(ctx, &emailpb.SendCaseRequest{
253+
Origin: "Web",
254+
Subject: "Some Override",
255+
Description: "Please review",
256+
ContactEmail: "[email protected]",
257+
})
258+
test.AssertNotError(t, err, "SendCase should succeed")
259+
260+
got := clientImpl.getCreatedCases()
261+
if len(got) != 1 {
262+
t.Fatalf("expected 1 case, got %d", len(got))
263+
}
264+
test.AssertEquals(t, got[0].Origin, "Web")
265+
test.AssertEquals(t, got[0].Subject, "Some Override")
266+
test.AssertEquals(t, got[0].Description, "Please review")
267+
test.AssertEquals(t, got[0].ContactEmail, "[email protected]")
268+
test.AssertMetricWithLabelsEquals(t, exporter.caseErrorCounter, prometheus.Labels{}, 0)
269+
}
270+
271+
type mockAlwaysFailCaseClient struct {
272+
mockSalesforceClientImpl
273+
}
274+
275+
func (m *mockAlwaysFailCaseClient) SendCase(payload Case) error {
276+
return fmt.Errorf("oops, lol")
277+
}
278+
279+
func TestSendCaseClientErrorIncrementsMetric(t *testing.T) {
280+
t.Parallel()
281+
282+
mockClient := &mockAlwaysFailCaseClient{}
283+
exporter := NewExporterImpl(mockClient, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
284+
285+
_, err := exporter.SendCase(ctx, &emailpb.SendCaseRequest{
286+
Origin: "Web",
287+
Subject: "Some Override",
288+
Description: "Please review",
289+
ContactEmail: "[email protected]",
290+
})
291+
test.AssertError(t, err, "SendCase should return error on client failure")
292+
test.AssertMetricWithLabelsEquals(t, exporter.caseErrorCounter, prometheus.Labels{}, 1)
293+
}
294+
295+
func TestSendCaseMissingOriginValidation(t *testing.T) {
296+
t.Parallel()
297+
298+
clientImpl := newMockSalesforceClientImpl()
299+
exporter := NewExporterImpl(clientImpl, nil, 1000000, 5, metrics.NoopRegisterer, blog.NewMock())
300+
301+
_, err := exporter.SendCase(ctx, &emailpb.SendCaseRequest{Subject: "No origin in this one, d00d"})
302+
test.AssertError(t, err, "SendCase should fail validation when Origin is missing")
303+
304+
got := clientImpl.getCreatedCases()
305+
if len(got) != 0 {
306+
t.Errorf("expected 0 cases due to validation error, got %d", len(got))
307+
}
308+
test.AssertMetricWithLabelsEquals(t, exporter.caseErrorCounter, prometheus.Labels{}, 0)
309+
}

0 commit comments

Comments
 (0)