From 2b34b0d8df7f32b5f1be822ba778784276c64c78 Mon Sep 17 00:00:00 2001 From: Joshua Hawxwell Date: Wed, 18 Dec 2024 12:06:22 +0000 Subject: [PATCH] Send reminders to attorneys when they haven't signed --- cmd/event-received/factory.go | 3 +- cmd/event-received/sirius_event_handler.go | 22 +- .../sirius_event_handler_test.go | 129 ++- cmd/schedule-runner/main.go | 3 + cmd/scheduled-task-adder/main.go | 3 +- internal/app/app.go | 2 +- internal/attorney/mock_DynamoClient_test.go | 49 + internal/attorney/mock_test.go | 8 + internal/attorney/store.go | 23 +- internal/attorney/store_test.go | 17 + .../provide_certificate.go | 12 + .../provide_certificate_test.go | 203 +++- .../certificateproviderpage/register.go | 2 +- internal/donor/donordata/provided.go | 6 +- internal/donor/donordata/provided_test.go | 4 +- internal/dynamo/keys.go | 5 +- internal/dynamo/keys_test.go | 2 +- internal/lpastore/lpadata/lpa.go | 4 + internal/lpastore/resolving_service.go | 1 + internal/notify/email.go | 51 + internal/notify/to.go | 10 + internal/notify/to_test.go | 17 + internal/page/fixtures/attorney.go | 17 +- internal/scheduled/action.go | 5 + internal/scheduled/mock_AttorneyStore_test.go | 97 ++ internal/scheduled/mock_test.go | 5 + internal/scheduled/runner.go | 9 + internal/scheduled/runner_test.go | 6 +- .../step_remind_attorney_to_complete.go | 226 +++++ .../step_remind_attorney_to_complete_test.go | 949 ++++++++++++++++++ ..._certificate_provider_to_complete_test.go} | 0 ...cate_provider_to_confirm_identity_test.go} | 0 internal/scheduled/store.go | 5 +- internal/scheduled/store_test.go | 26 +- .../sharecode/mock_ScheduledStore_test.go | 98 ++ internal/sharecode/sender.go | 46 +- internal/sharecode/sender_test.go | 227 ++++- .../supporter/supporterpage/donor_access.go | 2 +- 38 files changed, 2202 insertions(+), 92 deletions(-) create mode 100644 internal/scheduled/mock_AttorneyStore_test.go create mode 100644 internal/scheduled/step_remind_attorney_to_complete.go create mode 100644 internal/scheduled/step_remind_attorney_to_complete_test.go rename internal/scheduled/{remind_certificate_provider_to_complete_test.go => step_remind_certificate_provider_to_complete_test.go} (100%) rename internal/scheduled/{remind_certificate_provider_to_confirm_identity_test.go => step_remind_certificate_provider_to_confirm_identity_test.go} (100%) create mode 100644 internal/sharecode/mock_ScheduledStore_test.go diff --git a/cmd/event-received/factory.go b/cmd/event-received/factory.go index 407ccf2073..822076ec84 100644 --- a/cmd/event-received/factory.go +++ b/cmd/event-received/factory.go @@ -19,7 +19,6 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore" "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" - "github.com/ministryofjustice/opg-modernising-lpa/internal/random" "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/search" "github.com/ministryofjustice/opg-modernising-lpa/internal/secrets" @@ -167,7 +166,7 @@ func (f *Factory) ShareCodeSender(ctx context.Context) (ShareCodeSender, error) return nil, err } - f.shareCodeSender = sharecode.NewSender(sharecode.NewStore(f.dynamoClient), notifyClient, f.appPublicURL, random.String, event.NewClient(f.cfg, f.eventBusName), certificateprovider.NewStore(f.dynamoClient)) + f.shareCodeSender = sharecode.NewSender(sharecode.NewStore(f.dynamoClient), notifyClient, f.appPublicURL, event.NewClient(f.cfg, f.eventBusName), certificateprovider.NewStore(f.dynamoClient), scheduled.NewStore(f.dynamoClient)) } return f.shareCodeSender, nil diff --git a/cmd/event-received/sirius_event_handler.go b/cmd/event-received/sirius_event_handler.go index ba7ca31ba7..2d57c8581a 100644 --- a/cmd/event-received/sirius_event_handler.go +++ b/cmd/event-received/sirius_event_handler.go @@ -247,7 +247,7 @@ func handleDonorSubmissionCompleted(ctx context.Context, client dynamodbClient, Create(donor). Create(scheduled.Event{ PK: dynamo.ScheduledDayKey(donor.CertificateProviderInvitedAt.AddDate(0, 3, 1)), - SK: dynamo.ScheduledKey(donor.CertificateProviderInvitedAt.AddDate(0, 3, 1), int(scheduled.ActionRemindCertificateProviderToComplete)), + SK: dynamo.ScheduledKey(donor.CertificateProviderInvitedAt.AddDate(0, 3, 1), uuidString()), CreatedAt: now(), At: donor.CertificateProviderInvitedAt.AddDate(0, 3, 1), Action: scheduled.ActionRemindCertificateProviderToComplete, @@ -276,12 +276,12 @@ func handleCertificateProviderSubmissionCompleted(ctx context.Context, event *ev return err } - donor, err := lpaStoreClient.Lpa(ctx, v.UID) + lpa, err := lpaStoreClient.Lpa(ctx, v.UID) if err != nil { return fmt.Errorf("failed to retrieve lpa: %w", err) } - if donor.CertificateProvider.Channel.IsPaper() { + if lpa.CertificateProvider.Channel.IsPaper() { shareCodeSender, err := factory.ShareCodeSender(ctx) if err != nil { return err @@ -292,9 +292,23 @@ func handleCertificateProviderSubmissionCompleted(ctx context.Context, event *ev return err } - if err := shareCodeSender.SendAttorneys(ctx, appData, donor); err != nil { + dynamoClient := factory.DynamoClient() + + donor, err := getDonorByLpaUID(ctx, dynamoClient, v.UID) + if err != nil { + return fmt.Errorf("failed to get donor: %w", err) + } + + now := factory.Now() + donor.AttorneysInvitedAt = now() + + if err := shareCodeSender.SendAttorneys(ctx, appData, lpa); err != nil { return fmt.Errorf("failed to send share codes to attorneys: %w", err) } + + if err := putDonor(ctx, donor, now, dynamoClient); err != nil { + return fmt.Errorf("failed to put donor: %w", err) + } } return nil diff --git a/cmd/event-received/sirius_event_handler_test.go b/cmd/event-received/sirius_event_handler_test.go index 47fd76514a..f934002320 100644 --- a/cmd/event-received/sirius_event_handler_test.go +++ b/cmd/event-received/sirius_event_handler_test.go @@ -917,7 +917,7 @@ func TestHandleDonorSubmissionCompleted(t *testing.T) { }, scheduled.Event{ PK: dynamo.ScheduledDayKey(testNow.AddDate(0, 3, 1)), - SK: dynamo.ScheduledKey(testNow.AddDate(0, 3, 1), int(scheduled.ActionRemindCertificateProviderToComplete)), + SK: dynamo.ScheduledKey(testNow.AddDate(0, 3, 1), testUuidString), CreatedAt: testNow, At: testNow.AddDate(0, 3, 1), Action: scheduled.ActionRemindCertificateProviderToComplete, @@ -1034,11 +1034,31 @@ func TestHandleCertificateProviderSubmissionCompleted(t *testing.T) { }, } + updatedDonor := &donordata.Provided{ + PK: dynamo.LpaKey("an-lpa"), + UpdatedAt: testNow, + AttorneysInvitedAt: testNow, + } + updatedDonor.UpdateHash() + lpaStoreClient := newMockLpaStoreClient(t) lpaStoreClient.EXPECT(). Lpa(ctx, "M-1111-2222-3333"). Return(lpa, nil) + dynamoClient := newMockDynamodbClient(t) + dynamoClient.EXPECT(). + OneByUID(ctx, "M-1111-2222-3333", mock.Anything). + Return(nil). + SetData(&donordata.Provided{PK: dynamo.LpaKey("an-lpa"), SK: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor"))}) + dynamoClient.EXPECT(). + One(ctx, dynamo.LpaKey("an-lpa"), dynamo.DonorKey("a-donor"), mock.Anything). + Return(nil). + SetData(&donordata.Provided{PK: dynamo.LpaKey("an-lpa")}) + dynamoClient.EXPECT(). + Put(ctx, updatedDonor). + Return(nil) + shareCodeSender := newMockShareCodeSender(t) shareCodeSender.EXPECT(). SendAttorneys(ctx, appData, lpa). @@ -1054,6 +1074,12 @@ func TestHandleCertificateProviderSubmissionCompleted(t *testing.T) { factory.EXPECT(). AppData(). Return(appData, nil) + factory.EXPECT(). + DynamoClient(). + Return(dynamoClient) + factory.EXPECT(). + Now(). + Return(testNowFn) handler := &siriusEventHandler{} err := handler.Handle(ctx, factory, certificateProviderSubmissionCompletedEvent) @@ -1110,6 +1136,91 @@ func TestHandleCertificateProviderSubmissionCompletedWhenLpaStoreErrors(t *testi assert.Equal(t, fmt.Errorf("failed to retrieve lpa: %w", expectedError), err) } +func TestHandleCertificateProviderSubmissionCompletedWhenDonorGetErrors(t *testing.T) { + lpaStoreClient := newMockLpaStoreClient(t) + lpaStoreClient.EXPECT(). + Lpa(ctx, "M-1111-2222-3333"). + Return(&lpadata.Lpa{ + CertificateProvider: lpadata.CertificateProvider{ + Channel: lpadata.ChannelPaper, + }, + }, nil) + + dynamoClient := newMockDynamodbClient(t) + dynamoClient.EXPECT(). + OneByUID(mock.Anything, mock.Anything, mock.Anything). + Return(expectedError) + + shareCodeSender := newMockShareCodeSender(t) + + factory := newMockFactory(t) + factory.EXPECT(). + LpaStoreClient(). + Return(lpaStoreClient, nil) + factory.EXPECT(). + ShareCodeSender(ctx). + Return(shareCodeSender, nil) + factory.EXPECT(). + AppData(). + Return(appcontext.Data{}, nil) + factory.EXPECT(). + DynamoClient(). + Return(dynamoClient) + + handler := &siriusEventHandler{} + err := handler.Handle(ctx, factory, certificateProviderSubmissionCompletedEvent) + assert.ErrorIs(t, err, expectedError) +} + +func TestHandleCertificateProviderSubmissionCompletedWhenDonorPutErrors(t *testing.T) { + lpaStoreClient := newMockLpaStoreClient(t) + lpaStoreClient.EXPECT(). + Lpa(ctx, "M-1111-2222-3333"). + Return(&lpadata.Lpa{ + CertificateProvider: lpadata.CertificateProvider{ + Channel: lpadata.ChannelPaper, + }, + }, nil) + + dynamoClient := newMockDynamodbClient(t) + dynamoClient.EXPECT(). + OneByUID(mock.Anything, mock.Anything, mock.Anything). + Return(nil). + SetData(&donordata.Provided{PK: dynamo.LpaKey("an-lpa"), SK: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor"))}) + dynamoClient.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil) + dynamoClient.EXPECT(). + Put(mock.Anything, mock.Anything). + Return(expectedError) + + shareCodeSender := newMockShareCodeSender(t) + shareCodeSender.EXPECT(). + SendAttorneys(ctx, mock.Anything, mock.Anything). + Return(nil) + + factory := newMockFactory(t) + factory.EXPECT(). + LpaStoreClient(). + Return(lpaStoreClient, nil) + factory.EXPECT(). + ShareCodeSender(ctx). + Return(shareCodeSender, nil) + factory.EXPECT(). + AppData(). + Return(appcontext.Data{}, nil) + factory.EXPECT(). + DynamoClient(). + Return(dynamoClient) + factory.EXPECT(). + Now(). + Return(testNowFn) + + handler := &siriusEventHandler{} + err := handler.Handle(ctx, factory, certificateProviderSubmissionCompletedEvent) + assert.ErrorIs(t, err, expectedError) +} + func TestHandleCertificateProviderSubmissionCompletedWhenShareCodeSenderErrors(t *testing.T) { lpaStoreClient := newMockLpaStoreClient(t) lpaStoreClient.EXPECT(). @@ -1120,6 +1231,16 @@ func TestHandleCertificateProviderSubmissionCompletedWhenShareCodeSenderErrors(t }, }, nil) + dynamoClient := newMockDynamodbClient(t) + dynamoClient.EXPECT(). + OneByUID(ctx, "M-1111-2222-3333", mock.Anything). + Return(nil). + SetData(&donordata.Provided{PK: dynamo.LpaKey("an-lpa"), SK: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor"))}) + dynamoClient.EXPECT(). + One(ctx, dynamo.LpaKey("an-lpa"), dynamo.DonorKey("a-donor"), mock.Anything). + Return(nil). + SetData(&donordata.Provided{PK: dynamo.LpaKey("an-lpa")}) + shareCodeSender := newMockShareCodeSender(t) shareCodeSender.EXPECT(). SendAttorneys(ctx, mock.Anything, mock.Anything). @@ -1135,6 +1256,12 @@ func TestHandleCertificateProviderSubmissionCompletedWhenShareCodeSenderErrors(t factory.EXPECT(). AppData(). Return(appcontext.Data{}, nil) + factory.EXPECT(). + DynamoClient(). + Return(dynamoClient) + factory.EXPECT(). + Now(). + Return(testNowFn) handler := &siriusEventHandler{} err := handler.Handle(ctx, factory, certificateProviderSubmissionCompletedEvent) diff --git a/cmd/schedule-runner/main.go b/cmd/schedule-runner/main.go index c035667aa2..441568c594 100644 --- a/cmd/schedule-runner/main.go +++ b/cmd/schedule-runner/main.go @@ -14,6 +14,7 @@ import ( v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" + "github.com/ministryofjustice/opg-modernising-lpa/internal/attorney" "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" @@ -94,6 +95,7 @@ func handleRunSchedule(ctx context.Context) error { scheduledStore := scheduled.NewStore(dynamoClient) donorStore := donor.NewStore(dynamoClient, eventClient, logger, searchClient) certificateProviderStore := certificateprovider.NewStore(dynamoClient) + attorneyStore := attorney.NewStore(dynamoClient) lpaStoreResolvingService := lpastore.NewResolvingService(donorStore, lpaStoreClient) if Tag == "" { @@ -108,6 +110,7 @@ func handleRunSchedule(ctx context.Context) error { scheduledStore, donorStore, certificateProviderStore, + attorneyStore, lpaStoreResolvingService, notifyClient, eventClient, diff --git a/cmd/scheduled-task-adder/main.go b/cmd/scheduled-task-adder/main.go index 9c4e7bda98..94937603a4 100644 --- a/cmd/scheduled-task-adder/main.go +++ b/cmd/scheduled-task-adder/main.go @@ -18,6 +18,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" + "github.com/ministryofjustice/opg-modernising-lpa/internal/random" "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" ) @@ -93,7 +94,7 @@ func handleAddScheduledTasks(ctx context.Context, taskCountEvent TaskCountEvent) TargetLpaOwnerKey: donor.SK, LpaUID: lpaUID, PK: dynamo.ScheduledDayKey(now), - SK: dynamo.ScheduledKey(now, int(scheduled.ActionExpireDonorIdentity)), + SK: dynamo.ScheduledKey(now, random.UuidString()), } items = append(items, donor, event) diff --git a/internal/app/app.go b/internal/app/app.go index ebee9b034e..49fb77b024 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -116,7 +116,7 @@ func App( scheduledStore := scheduled.NewStore(lpaDynamoClient) progressTracker := task.ProgressTracker{Localizer: localizer} - shareCodeSender := sharecode.NewSender(shareCodeStore, notifyClient, appPublicURL, random.String, eventClient, certificateProviderStore) + shareCodeSender := sharecode.NewSender(shareCodeStore, notifyClient, appPublicURL, eventClient, certificateProviderStore, scheduledStore) witnessCodeSender := donor.NewWitnessCodeSender(donorStore, certificateProviderStore, notifyClient, localizer) lpaStoreResolvingService := lpastore.NewResolvingService(donorStore, lpaStoreClient) diff --git a/internal/attorney/mock_DynamoClient_test.go b/internal/attorney/mock_DynamoClient_test.go index f2c38c9477..47bad01a93 100644 --- a/internal/attorney/mock_DynamoClient_test.go +++ b/internal/attorney/mock_DynamoClient_test.go @@ -83,6 +83,55 @@ func (_c *mockDynamoClient_AllByKeys_Call) RunAndReturn(run func(context.Context return _c } +// AllByLpaUIDAndPartialSK provides a mock function with given fields: ctx, uid, partialSK, v +func (_m *mockDynamoClient) AllByLpaUIDAndPartialSK(ctx context.Context, uid string, partialSK dynamo.SK, v interface{}) error { + ret := _m.Called(ctx, uid, partialSK, v) + + if len(ret) == 0 { + panic("no return value specified for AllByLpaUIDAndPartialSK") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, dynamo.SK, interface{}) error); ok { + r0 = rf(ctx, uid, partialSK, v) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockDynamoClient_AllByLpaUIDAndPartialSK_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AllByLpaUIDAndPartialSK' +type mockDynamoClient_AllByLpaUIDAndPartialSK_Call struct { + *mock.Call +} + +// AllByLpaUIDAndPartialSK is a helper method to define mock.On call +// - ctx context.Context +// - uid string +// - partialSK dynamo.SK +// - v interface{} +func (_e *mockDynamoClient_Expecter) AllByLpaUIDAndPartialSK(ctx interface{}, uid interface{}, partialSK interface{}, v interface{}) *mockDynamoClient_AllByLpaUIDAndPartialSK_Call { + return &mockDynamoClient_AllByLpaUIDAndPartialSK_Call{Call: _e.mock.On("AllByLpaUIDAndPartialSK", ctx, uid, partialSK, v)} +} + +func (_c *mockDynamoClient_AllByLpaUIDAndPartialSK_Call) Run(run func(ctx context.Context, uid string, partialSK dynamo.SK, v interface{})) *mockDynamoClient_AllByLpaUIDAndPartialSK_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(dynamo.SK), args[3].(interface{})) + }) + return _c +} + +func (_c *mockDynamoClient_AllByLpaUIDAndPartialSK_Call) Return(_a0 error) *mockDynamoClient_AllByLpaUIDAndPartialSK_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockDynamoClient_AllByLpaUIDAndPartialSK_Call) RunAndReturn(run func(context.Context, string, dynamo.SK, interface{}) error) *mockDynamoClient_AllByLpaUIDAndPartialSK_Call { + _c.Call.Return(run) + return _c +} + // AllByPartialSK provides a mock function with given fields: ctx, pk, partialSK, v func (_m *mockDynamoClient) AllByPartialSK(ctx context.Context, pk dynamo.PK, partialSK dynamo.SK, v interface{}) error { ret := _m.Called(ctx, pk, partialSK, v) diff --git a/internal/attorney/mock_test.go b/internal/attorney/mock_test.go index 755c556c36..1f7ce0b64f 100644 --- a/internal/attorney/mock_test.go +++ b/internal/attorney/mock_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" "github.com/stretchr/testify/mock" @@ -15,6 +16,13 @@ var ( expectedError = errors.New("err") ) +func (c *mockDynamoClient_AllByLpaUIDAndPartialSK_Call) SetData(data any) { + c.Run(func(_ context.Context, _ string, _ dynamo.SK, v any) { + b, _ := attributevalue.Marshal(data) + attributevalue.Unmarshal(b, v) + }) +} + func (m *mockDynamoClient) ExpectOne(ctx, pk, sk, data interface{}, err error) { m. On("One", ctx, pk, sk, mock.Anything). diff --git a/internal/attorney/store.go b/internal/attorney/store.go index c4764662e3..8223d6d5d0 100644 --- a/internal/attorney/store.go +++ b/internal/attorney/store.go @@ -16,22 +16,23 @@ import ( ) type DynamoClient interface { - One(ctx context.Context, pk dynamo.PK, sk dynamo.SK, v interface{}) error - OneByPK(ctx context.Context, pk dynamo.PK, v interface{}) error - OneByPartialSK(ctx context.Context, pk dynamo.PK, partialSK dynamo.SK, v interface{}) error + AllByKeys(ctx context.Context, keys []dynamo.Keys) ([]map[string]dynamodbtypes.AttributeValue, error) + AllByLpaUIDAndPartialSK(ctx context.Context, uid string, partialSK dynamo.SK, v interface{}) error AllByPartialSK(ctx context.Context, pk dynamo.PK, partialSK dynamo.SK, v interface{}) error - LatestForActor(ctx context.Context, sk dynamo.SK, v interface{}) error AllBySK(ctx context.Context, sk dynamo.SK, v interface{}) error - AllByKeys(ctx context.Context, keys []dynamo.Keys) ([]map[string]dynamodbtypes.AttributeValue, error) AllKeysByPK(ctx context.Context, pk dynamo.PK) ([]dynamo.Keys, error) - Put(ctx context.Context, v interface{}) error + BatchPut(ctx context.Context, items []interface{}) error Create(ctx context.Context, v interface{}) error DeleteKeys(ctx context.Context, keys []dynamo.Keys) error DeleteOne(ctx context.Context, pk dynamo.PK, sk dynamo.SK) error - Update(ctx context.Context, pk dynamo.PK, sk dynamo.SK, values map[string]dynamodbtypes.AttributeValue, expression string) error - BatchPut(ctx context.Context, items []interface{}) error + LatestForActor(ctx context.Context, sk dynamo.SK, v interface{}) error + One(ctx context.Context, pk dynamo.PK, sk dynamo.SK, v interface{}) error + OneByPK(ctx context.Context, pk dynamo.PK, v interface{}) error + OneByPartialSK(ctx context.Context, pk dynamo.PK, partialSK dynamo.SK, v interface{}) error OneBySK(ctx context.Context, sk dynamo.SK, v interface{}) error OneByUID(ctx context.Context, uid string, v interface{}) error + Put(ctx context.Context, v interface{}) error + Update(ctx context.Context, pk dynamo.PK, sk dynamo.SK, values map[string]dynamodbtypes.AttributeValue, expression string) error WriteTransaction(ctx context.Context, transaction *dynamo.Transaction) error } @@ -100,6 +101,12 @@ func (s *Store) Get(ctx context.Context) (*attorneydata.Provided, error) { return &attorney, err } +func (s *Store) All(ctx context.Context, lpaUID string) ([]*attorneydata.Provided, error) { + var attorneys []*attorneydata.Provided + err := s.dynamoClient.AllByLpaUIDAndPartialSK(ctx, lpaUID, dynamo.AttorneyKey(""), &attorneys) + return attorneys, err +} + func (s *Store) Put(ctx context.Context, attorney *attorneydata.Provided) error { attorney.UpdatedAt = s.now() return s.dynamoClient.Put(ctx, attorney) diff --git a/internal/attorney/store_test.go b/internal/attorney/store_test.go index 353893b82c..f551bddb51 100644 --- a/internal/attorney/store_test.go +++ b/internal/attorney/store_test.go @@ -190,6 +190,23 @@ func TestAttorneyStoreGetOnError(t *testing.T) { assert.Equal(t, expectedError, err) } +func TestAttorneyStoreAll(t *testing.T) { + ctx := context.Background() + expected := []*attorneydata.Provided{{LpaID: "lpa-id"}} + + dynamoClient := newMockDynamoClient(t) + dynamoClient.EXPECT(). + AllByLpaUIDAndPartialSK(ctx, "lpa-uid", dynamo.AttorneyKey(""), mock.Anything). + Return(expectedError). + SetData(expected) + + attorneyStore := &Store{dynamoClient: dynamoClient, now: nil} + + attorney, err := attorneyStore.All(ctx, "lpa-uid") + assert.Equal(t, expectedError, err) + assert.Equal(t, expected, attorney) +} + func TestAttorneyStorePut(t *testing.T) { ctx := context.Background() now := time.Now() diff --git a/internal/certificateprovider/certificateproviderpage/provide_certificate.go b/internal/certificateprovider/certificateproviderpage/provide_certificate.go index dd20f11ea2..b24fa892ff 100644 --- a/internal/certificateprovider/certificateproviderpage/provide_certificate.go +++ b/internal/certificateprovider/certificateproviderpage/provide_certificate.go @@ -33,6 +33,7 @@ func ProvideCertificate( shareCodeSender ShareCodeSender, lpaStoreClient LpaStoreClient, scheduledStore ScheduledStore, + donorStore DonorStore, now func() time.Time, ) Handler { return func(appData appcontext.Data, w http.ResponseWriter, r *http.Request, certificateProvider *certificateproviderdata.Provided, lpa *lpadata.Lpa) error { @@ -83,6 +84,17 @@ func ProvideCertificate( return fmt.Errorf("error sending sharecode to attorneys: %w", err) } + donor, err := donorStore.GetAny(r.Context()) + if err != nil { + return fmt.Errorf("error getting donor: %w", err) + } + + donor.AttorneysInvitedAt = now() + + if err := donorStore.Put(r.Context(), donor); err != nil { + return fmt.Errorf("error putting donor: %w", err) + } + if !certificateProvider.Tasks.ConfirmYourIdentity.IsCompleted() { if err := scheduledStore.Create(r.Context(), scheduled.Event{ At: certificateProvider.SignedAt.AddDate(0, 3, 1), diff --git a/internal/certificateprovider/certificateproviderpage/provide_certificate_test.go b/internal/certificateprovider/certificateproviderpage/provide_certificate_test.go index d316be3618..1013d622f1 100644 --- a/internal/certificateprovider/certificateproviderpage/provide_certificate_test.go +++ b/internal/certificateprovider/certificateproviderpage/provide_certificate_test.go @@ -11,6 +11,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider" "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider/certificateproviderdata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" @@ -38,7 +39,7 @@ func TestGetProvideCertificate(t *testing.T) { }). Return(nil) - err := ProvideCertificate(template.Execute, nil, nil, nil, nil, nil, time.Now)(testAppData, w, r, &certificateproviderdata.Provided{}, donor) + err := ProvideCertificate(template.Execute, nil, nil, nil, nil, nil, nil, time.Now)(testAppData, w, r, &certificateproviderdata.Provided{}, donor) resp := w.Result() assert.Nil(t, err) @@ -51,7 +52,7 @@ func TestGetProvideCertificateWhenAlreadyAgreed(t *testing.T) { donor := &lpadata.Lpa{LpaID: "lpa-id", SignedAt: time.Now(), WitnessedByCertificateProviderAt: time.Now()} - err := ProvideCertificate(nil, nil, nil, nil, nil, nil, time.Now)(testAppData, w, r, &certificateproviderdata.Provided{ + err := ProvideCertificate(nil, nil, nil, nil, nil, nil, nil, time.Now)(testAppData, w, r, &certificateproviderdata.Provided{ SignedAt: time.Now(), }, donor) resp := w.Result() @@ -94,6 +95,11 @@ func TestPostProvideCertificate(t *testing.T) { Email: "a@example.com", } + donor := &donordata.Provided{} + updatedDonor := &donordata.Provided{ + AttorneysInvitedAt: testNow, + } + certificateProviderStore := newMockCertificateProviderStore(t) certificateProviderStore.EXPECT(). Put(r.Context(), certificateProvider). @@ -136,6 +142,14 @@ func TestPostProvideCertificate(t *testing.T) { SendCertificateProvider(r.Context(), certificateProvider, lpa). Return(nil) + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + GetAny(r.Context()). + Return(donor, nil) + donorStore.EXPECT(). + Put(r.Context(), updatedDonor). + Return(nil) + scheduledStore := newMockScheduledStore(t) scheduledStore.EXPECT(). Create(r.Context(), scheduled.Event{ @@ -151,7 +165,7 @@ func TestPostProvideCertificate(t *testing.T) { }). Return(nil) - err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) + err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, donorStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) resp := w.Result() assert.Nil(t, err) @@ -193,6 +207,11 @@ func TestPostProvideCertificateWhenIdentityCompleted(t *testing.T) { Email: "a@example.com", } + donor := &donordata.Provided{} + updatedDonor := &donordata.Provided{ + AttorneysInvitedAt: testNow, + } + certificateProviderStore := newMockCertificateProviderStore(t) certificateProviderStore.EXPECT(). Put(r.Context(), certificateProvider). @@ -230,12 +249,20 @@ func TestPostProvideCertificateWhenIdentityCompleted(t *testing.T) { SendAttorneys(r.Context(), testAppData, lpa). Return(nil) + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + GetAny(r.Context()). + Return(donor, nil) + donorStore.EXPECT(). + Put(r.Context(), updatedDonor). + Return(nil) + lpaStoreClient := newMockLpaStoreClient(t) lpaStoreClient.EXPECT(). SendCertificateProvider(r.Context(), certificateProvider, lpa). Return(nil) - err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{ + err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, nil, donorStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{ LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En, @@ -284,6 +311,11 @@ func TestPostProvideCertificateWhenSignedInLpaStore(t *testing.T) { Email: "a@example.com", } + donor := &donordata.Provided{} + updatedDonor := &donordata.Provided{ + AttorneysInvitedAt: testNow, + } + certificateProviderStore := newMockCertificateProviderStore(t) certificateProviderStore.EXPECT(). Put(r.Context(), certificateProvider). @@ -321,6 +353,14 @@ func TestPostProvideCertificateWhenSignedInLpaStore(t *testing.T) { SendAttorneys(r.Context(), testAppData, lpa). Return(nil) + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + GetAny(r.Context()). + Return(donor, nil) + donorStore.EXPECT(). + Put(r.Context(), updatedDonor). + Return(nil) + scheduledStore := newMockScheduledStore(t) scheduledStore.EXPECT(). Create(r.Context(), scheduled.Event{ @@ -336,7 +376,7 @@ func TestPostProvideCertificateWhenSignedInLpaStore(t *testing.T) { }). Return(nil) - err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, nil, scheduledStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) + err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, nil, scheduledStore, donorStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) resp := w.Result() assert.Nil(t, err) @@ -367,7 +407,7 @@ func TestPostProvideCertificateWhenCannotSubmit(t *testing.T) { Type: lpadata.LpaTypePropertyAndAffairs, } - err := ProvideCertificate(nil, nil, nil, nil, nil, nil, nil)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com"}, lpa) + err := ProvideCertificate(nil, nil, nil, nil, nil, nil, nil, nil)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com"}, lpa) resp := w.Result() assert.Nil(t, err) @@ -426,12 +466,143 @@ func TestPostProvideCertificateWhenScheduledStoreErrors(t *testing.T) { SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). Return(nil) + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + GetAny(r.Context()). + Return(&donordata.Provided{}, nil) + donorStore.EXPECT(). + Put(mock.Anything, mock.Anything). + Return(nil) + scheduledStore := newMockScheduledStore(t) scheduledStore.EXPECT(). Create(mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - err := ProvideCertificate(nil, nil, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) + err := ProvideCertificate(nil, nil, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, donorStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) + assert.ErrorIs(t, err, expectedError) +} + +func TestPostProvideCertificateWhenDonorStoreGetErrors(t *testing.T) { + form := url.Values{ + "agree-to-statement": {"1"}, + "submittable": {"can-submit"}, + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + r.Header.Add("Content-Type", page.FormUrlEncoded) + + lpa := &lpadata.Lpa{ + LpaUID: "lpa-uid", + SignedAt: testNow.AddDate(0, -1, 0), + WitnessedByCertificateProviderAt: testNow, + CertificateProvider: lpadata.CertificateProvider{ + Email: "cp@example.org", + FirstNames: "a", + LastName: "b", + }, + Donor: lpadata.Donor{FirstNames: "c", LastName: "d"}, + Type: lpadata.LpaTypePropertyAndAffairs, + } + + localizer := newMockLocalizer(t) + localizer.EXPECT(). + Possessive(mock.Anything). + Return("the possessive first names") + localizer.EXPECT(). + T(mock.Anything). + Return("the translated term") + localizer.EXPECT(). + FormatDateTime(mock.Anything). + Return("the formatted date") + + testAppData.Localizer = localizer + + notifyClient := newMockNotifyClient(t) + notifyClient.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + shareCodeSender := newMockShareCodeSender(t) + shareCodeSender.EXPECT(). + SendAttorneys(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + lpaStoreClient := newMockLpaStoreClient(t) + lpaStoreClient.EXPECT(). + SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + GetAny(r.Context()). + Return(nil, expectedError) + + err := ProvideCertificate(nil, nil, notifyClient, shareCodeSender, lpaStoreClient, nil, donorStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) + assert.ErrorIs(t, err, expectedError) +} + +func TestPostProvideCertificateWhenDonorStorePutErrors(t *testing.T) { + form := url.Values{ + "agree-to-statement": {"1"}, + "submittable": {"can-submit"}, + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + r.Header.Add("Content-Type", page.FormUrlEncoded) + + lpa := &lpadata.Lpa{ + LpaUID: "lpa-uid", + SignedAt: testNow.AddDate(0, -1, 0), + WitnessedByCertificateProviderAt: testNow, + CertificateProvider: lpadata.CertificateProvider{ + Email: "cp@example.org", + FirstNames: "a", + LastName: "b", + }, + Donor: lpadata.Donor{FirstNames: "c", LastName: "d"}, + Type: lpadata.LpaTypePropertyAndAffairs, + } + + localizer := newMockLocalizer(t) + localizer.EXPECT(). + Possessive(mock.Anything). + Return("the possessive first names") + localizer.EXPECT(). + T(mock.Anything). + Return("the translated term") + localizer.EXPECT(). + FormatDateTime(mock.Anything). + Return("the formatted date") + + testAppData.Localizer = localizer + + notifyClient := newMockNotifyClient(t) + notifyClient.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + shareCodeSender := newMockShareCodeSender(t) + shareCodeSender.EXPECT(). + SendAttorneys(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + lpaStoreClient := newMockLpaStoreClient(t) + lpaStoreClient.EXPECT(). + SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + GetAny(r.Context()). + Return(&donordata.Provided{}, nil) + donorStore.EXPECT(). + Put(mock.Anything, mock.Anything). + Return(expectedError) + + err := ProvideCertificate(nil, nil, notifyClient, shareCodeSender, lpaStoreClient, nil, donorStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) assert.ErrorIs(t, err, expectedError) } @@ -481,12 +652,20 @@ func TestPostProvideCertificateOnStoreError(t *testing.T) { SendAttorneys(r.Context(), testAppData, mock.Anything). Return(nil) + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + GetAny(r.Context()). + Return(&donordata.Provided{}, nil) + donorStore.EXPECT(). + Put(mock.Anything, mock.Anything). + Return(nil) + scheduledStore := newMockScheduledStore(t) scheduledStore.EXPECT(). Create(mock.Anything, mock.Anything, mock.Anything). Return(nil) - err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{}, &lpadata.Lpa{SignedAt: testNow, WitnessedByCertificateProviderAt: testNow}) + err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, donorStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{}, &lpadata.Lpa{SignedAt: testNow, WitnessedByCertificateProviderAt: testNow}) assert.ErrorIs(t, err, expectedError) } @@ -518,7 +697,7 @@ func TestPostProvideCertificateWhenLpaStoreClientError(t *testing.T) { SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - err := ProvideCertificate(nil, nil, nil, nil, lpaStoreClient, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, donor) + err := ProvideCertificate(nil, nil, nil, nil, lpaStoreClient, nil, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, donor) assert.ErrorIs(t, err, expectedError) } @@ -558,7 +737,7 @@ func TestPostProvideCertificateOnNotifyClientError(t *testing.T) { SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). Return(nil) - err := ProvideCertificate(nil, nil, notifyClient, nil, lpaStoreClient, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, &lpadata.Lpa{ + err := ProvideCertificate(nil, nil, notifyClient, nil, lpaStoreClient, nil, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, &lpadata.Lpa{ SignedAt: testNow, WitnessedByCertificateProviderAt: testNow, CertificateProvider: lpadata.CertificateProvider{ @@ -616,7 +795,7 @@ func TestPostProvideCertificateWhenShareCodeSenderErrors(t *testing.T) { SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). Return(nil) - err := ProvideCertificate(nil, nil, notifyClient, shareCodeSender, lpaStoreClient, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, &lpadata.Lpa{ + err := ProvideCertificate(nil, nil, notifyClient, shareCodeSender, lpaStoreClient, nil, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, &lpadata.Lpa{ SignedAt: testNow, WitnessedByCertificateProviderAt: testNow, Donor: lpadata.Donor{FirstNames: "c", LastName: "d"}, @@ -642,7 +821,7 @@ func TestPostProvideCertificateWhenValidationErrors(t *testing.T) { })). Return(nil) - err := ProvideCertificate(template.Execute, nil, nil, nil, nil, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{}, &lpadata.Lpa{SignedAt: testNow, WitnessedByCertificateProviderAt: testNow}) + err := ProvideCertificate(template.Execute, nil, nil, nil, nil, nil, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{}, &lpadata.Lpa{SignedAt: testNow, WitnessedByCertificateProviderAt: testNow}) resp := w.Result() assert.Nil(t, err) diff --git a/internal/certificateprovider/certificateproviderpage/register.go b/internal/certificateprovider/certificateproviderpage/register.go index f2023ba0ae..570f8713b2 100644 --- a/internal/certificateprovider/certificateproviderpage/register.go +++ b/internal/certificateprovider/certificateproviderpage/register.go @@ -187,7 +187,7 @@ func Register( handleCertificateProvider(certificateprovider.PathWhatHappensNext, page.CanGoBack, Guidance(tmpls.Get("what_happens_next.gohtml"))) handleCertificateProvider(certificateprovider.PathProvideCertificate, page.CanGoBack, - ProvideCertificate(tmpls.Get("provide_certificate.gohtml"), certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, time.Now)) + ProvideCertificate(tmpls.Get("provide_certificate.gohtml"), certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, donorStore, time.Now)) handleCertificateProvider(certificateprovider.PathCertificateProvided, page.None, Guidance(tmpls.Get("certificate_provided.gohtml"))) handleCertificateProvider(certificateprovider.PathConfirmDontWantToBeCertificateProvider, page.CanGoBack, diff --git a/internal/donor/donordata/provided.go b/internal/donor/donordata/provided.go index d02978a528..329aedf455 100644 --- a/internal/donor/donordata/provided.go +++ b/internal/donor/donordata/provided.go @@ -169,6 +169,9 @@ type Provided struct { // certificate provider to act. CertificateProviderInvitedAt time.Time + // AttorneysInvitedAt records when the invites are sent to the attorneys. + AttorneysInvitedAt time.Time + HasSentApplicationUpdatedEvent bool `hash:"-"` } @@ -223,7 +226,8 @@ func (c toCheck) HashInclude(field string, _ any) (bool, error) { "Voucher", "FailedVouchAttempts", "CostOfRepeatApplication", - "CertificateProviderInvitedAt": + "CertificateProviderInvitedAt", + "AttorneysInvitedAt": return false, nil } diff --git a/internal/donor/donordata/provided_test.go b/internal/donor/donordata/provided_test.go index cf9b42494f..68cad5f64d 100644 --- a/internal/donor/donordata/provided_test.go +++ b/internal/donor/donordata/provided_test.go @@ -42,14 +42,14 @@ func TestGenerateHash(t *testing.T) { } // DO change this value to match the updates - const modified uint64 = 0x31b4f1c0f521f1ab + const modified uint64 = 0x7789c0f3bc416e1e // DO NOT change these initial hash values. If a field has been added/removed // you will need to handle the version gracefully by modifying // (*Provided).HashInclude and adding another testcase for the new // version. testcases := map[uint8]uint64{ - 0: 0xeae937046f784453, + 0: 0xe907158cc0223aec, } for version, initial := range testcases { diff --git a/internal/dynamo/keys.go b/internal/dynamo/keys.go index cec455a581..5bcf5b021b 100644 --- a/internal/dynamo/keys.go +++ b/internal/dynamo/keys.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "errors" "fmt" - "strconv" "strings" "time" ) @@ -318,8 +317,8 @@ type ScheduledKeyType string func (t ScheduledKeyType) SK() string { return string(t) } // ScheduledKey is used as the SK for a scheduled.Event. -func ScheduledKey(at time.Time, action int) ScheduledKeyType { - return ScheduledKeyType(scheduledPrefix + "#" + at.Format(time.RFC3339) + "#" + strconv.Itoa(action)) +func ScheduledKey(at time.Time, rnd string) ScheduledKeyType { + return ScheduledKeyType(scheduledPrefix + "#" + at.Format(time.RFC3339) + "#" + rnd) } func PartialScheduledKey() ScheduledKeyType { diff --git a/internal/dynamo/keys_test.go b/internal/dynamo/keys_test.go index 2a0b540c40..1b15079b65 100644 --- a/internal/dynamo/keys_test.go +++ b/internal/dynamo/keys_test.go @@ -85,7 +85,7 @@ func TestSK(t *testing.T) { "VoucherShareSortKey": {VoucherShareSortKey(LpaKey("S")), "VOUCHERSHARESORT#S"}, "DonorInviteKey": {DonorInviteKey(OrganisationKey("org-id"), LpaKey("lpa-id")), "DONORINVITE#org-id#lpa-id"}, "VoucherKey": {VoucherKey("S"), "VOUCHER#S"}, - "ScheduledKey": {ScheduledKey(time.Date(2024, time.January, 2, 12, 13, 14, 15, time.UTC), 99), "SCHEDULED#2024-01-02T12:13:14Z#99"}, + "ScheduledKey": {ScheduledKey(time.Date(2024, time.January, 2, 12, 13, 14, 15, time.UTC), "some-string"), "SCHEDULED#2024-01-02T12:13:14Z#some-string"}, "ReservedKey": {ReservedKey(VoucherKey), "RESERVED#VOUCHER#"}, "PartialScheduledKey": {PartialScheduledKey(), "SCHEDULED#"}, } diff --git a/internal/lpastore/lpadata/lpa.go b/internal/lpastore/lpadata/lpa.go index acbaec9c3e..b443ecbef9 100644 --- a/internal/lpastore/lpadata/lpa.go +++ b/internal/lpastore/lpadata/lpa.go @@ -80,6 +80,10 @@ type Lpa struct { // CertificateProviderInvitedAt is when the certificate provider's share // code is first sent, it is only set with the resolving service. CertificateProviderInvitedAt time.Time + + // AttorneysInvitedAt records when the share codes are sent to the attorneys, + // it is only set with the resolving service. + AttorneysInvitedAt time.Time } // SignedForDonor returns true if the Lpa has been signed and witnessed for the donor. diff --git a/internal/lpastore/resolving_service.go b/internal/lpastore/resolving_service.go index c2d2684768..d2058afa9c 100644 --- a/internal/lpastore/resolving_service.go +++ b/internal/lpastore/resolving_service.go @@ -95,6 +95,7 @@ func (s *ResolvingService) merge(lpa *lpadata.Lpa, donor *donordata.Provided) *l lpa.LpaUID = donor.LpaUID lpa.StatutoryWaitingPeriodAt = donor.StatutoryWaitingPeriodAt lpa.CertificateProviderInvitedAt = donor.CertificateProviderInvitedAt + lpa.AttorneysInvitedAt = donor.AttorneysInvitedAt if donor.SK.Equals(dynamo.DonorKey("PAPER")) { lpa.Drafted = true diff --git a/internal/notify/email.go b/internal/notify/email.go index 07ebfc35e1..96ffb05fe8 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -504,3 +504,54 @@ func (e InformDonorCertificateProviderHasNotConfirmedIdentityEmail) emailID(isPr return "3a6bf17f-f690-4ee6-b815-b5bfe2f70c55" } + +type InformDonorAttorneyHasNotActedEmail struct { + Greeting string + AttorneyFullName string + LpaType string + AttorneyStartPageURL string + DeadlineDate string + InvitedDate string +} + +func (e InformDonorAttorneyHasNotActedEmail) emailID(isProduction bool, lang localize.Lang) string { + if isProduction { + if lang.IsCy() { + return "83317256-fa2a-4dd8-b8dc-64501d2b221c" + } + + return "83f36e64-adb6-483c-ba60-cb70581af84d" + } + + if lang.IsCy() { + return "2ade25e7-5864-45dc-953a-22b2d956f9b5" + } + + return "efc93b6f-d2f3-487d-afef-c6961a0abaed" +} + +type AdviseAttorneyToSignOrOptOutEmail struct { + DonorFullName string + DonorFullNamePossessive string + LpaType string + AttorneyFullName string + InvitedDate string + DeadlineDate string + AttorneyStartPageURL string +} + +func (e AdviseAttorneyToSignOrOptOutEmail) emailID(isProduction bool, lang localize.Lang) string { + if isProduction { + if lang.IsCy() { + return "4c0e65c1-e490-475c-aa8e-a4c693864b7c" + } + + return "1cef45e2-991c-4998-89d4-1f324a45bb25" + } + + if lang.IsCy() { + return "9df92f3d-4070-4000-bad2-c25ca9daa68e" + } + + return "3ddfd30a-02b6-4625-8fbf-5785f5b33864" +} diff --git a/internal/notify/to.go b/internal/notify/to.go index 08aa7c685a..aec01b6a67 100644 --- a/internal/notify/to.go +++ b/internal/notify/to.go @@ -34,6 +34,16 @@ func (t to) toEmail() (string, localize.Lang) { return t.email, t.lang } func (t to) toMobile() (string, localize.Lang) { return t.mobile, t.lang } func (t to) ignore() bool { return t.ignored } +// ToDonorOnly is only needed when we won't want the email to go to the +// correspondent, normally we will use ToDonor. +func ToDonorOnly(donor *donordata.Provided) To { + return to{ + mobile: donor.Donor.Mobile, + email: donor.Donor.Email, + lang: donor.Donor.ContactLanguagePreference, + } +} + func ToDonor(donor *donordata.Provided) To { to := to{ mobile: donor.Donor.Mobile, diff --git a/internal/notify/to_test.go b/internal/notify/to_test.go index ed7d51e513..a6513bd627 100644 --- a/internal/notify/to_test.go +++ b/internal/notify/to_test.go @@ -11,6 +11,23 @@ import ( "github.com/stretchr/testify/assert" ) +func TestToDonorOnly(t *testing.T) { + to := ToDonorOnly(&donordata.Provided{ + Donor: donordata.Donor{Mobile: "0777", Email: "a@b.c", ContactLanguagePreference: localize.Cy}, + Correspondent: donordata.Correspondent{Phone: "0779", Email: "d@e.f"}, + }) + + email, lang := to.toEmail() + assert.Equal(t, "a@b.c", email) + assert.Equal(t, localize.Cy, lang) + + mobile, lang := to.toMobile() + assert.Equal(t, "0777", mobile) + assert.Equal(t, localize.Cy, lang) + + assert.False(t, to.ignore()) +} + func TestToDonor(t *testing.T) { to := ToDonor(&donordata.Provided{ Donor: donordata.Donor{Mobile: "0777", Email: "a@b.c", ContactLanguagePreference: localize.Cy}, diff --git a/internal/page/fixtures/attorney.go b/internal/page/fixtures/attorney.go index def059ef18..b0e1220f0e 100644 --- a/internal/page/fixtures/attorney.go +++ b/internal/page/fixtures/attorney.go @@ -107,6 +107,7 @@ func Attorney( donorSessionID = base64.StdEncoding.EncodeToString([]byte(donorSub)) certificateProviderSessionID = base64.StdEncoding.EncodeToString([]byte(certificateProviderSub)) attorneySessionID = base64.StdEncoding.EncodeToString([]byte(attorneySub)) + signingCount = time.Duration(0) ) if err := sessionStore.SetLogin(r, w, &sesh.LoginSession{Sub: attorneySub, Email: testEmail}); err != nil { @@ -251,7 +252,6 @@ func Attorney( } certificateProvider.ContactLanguagePreference = localize.En - certificateProvider.SignedAt = time.Date(2023, time.January, 2, 3, 4, 5, 6, time.UTC) attorney, err := createAttorney( attorneyCtx, @@ -268,8 +268,8 @@ func Attorney( } if progress >= slices.Index(progressValues, "signedByCertificateProvider") { - donorDetails.SignedAt = time.Now() - certificateProvider.SignedAt = donorDetails.SignedAt.Add(time.Hour) + signingCount++ + certificateProvider.SignedAt = donorDetails.SignedAt.Add(signingCount * time.Hour) } if progress >= slices.Index(progressValues, "confirmYourDetails") { @@ -284,6 +284,7 @@ func Attorney( if progress >= slices.Index(progressValues, "signedByAttorney") { attorney.Tasks.SignTheLpa = task.StateCompleted + signingCount++ if isTrustCorporation { attorney.WouldLikeSecondSignatory = form.No @@ -291,10 +292,10 @@ func Attorney( FirstNames: "A", LastName: "Sign", ProfessionalTitle: "Assistant to the signer", - SignedAt: donorDetails.SignedAt.Add(2 * time.Hour), + SignedAt: donorDetails.SignedAt.Add(signingCount * time.Hour), }} } else { - attorney.SignedAt = donorDetails.SignedAt.Add(2 * time.Hour) + attorney.SignedAt = donorDetails.SignedAt.Add(signingCount * time.Hour) } } @@ -323,7 +324,8 @@ func Attorney( attorney.Tasks.ConfirmYourDetails = task.StateCompleted attorney.Tasks.ReadTheLpa = task.StateCompleted attorney.Tasks.SignTheLpa = task.StateCompleted - attorney.SignedAt = donorDetails.SignedAt.Add(2 * time.Hour) + signingCount++ + attorney.SignedAt = donorDetails.SignedAt.Add(signingCount * time.Hour) if err := attorneyStore.Put(ctx, attorney); err != nil { return err @@ -355,11 +357,12 @@ func Attorney( attorney.Tasks.ReadTheLpa = task.StateCompleted attorney.Tasks.SignTheLpa = task.StateCompleted attorney.WouldLikeSecondSignatory = form.No + signingCount++ attorney.AuthorisedSignatories = [2]attorneydata.TrustCorporationSignatory{{ FirstNames: "A", LastName: "Sign", ProfessionalTitle: "Assistant to the signer", - SignedAt: donorDetails.SignedAt.Add(2 * time.Hour), + SignedAt: donorDetails.SignedAt.Add(signingCount * time.Hour), }} if err := attorneyStore.Put(ctx, attorney); err != nil { diff --git a/internal/scheduled/action.go b/internal/scheduled/action.go index 9a77766c1d..d8051d9bf3 100644 --- a/internal/scheduled/action.go +++ b/internal/scheduled/action.go @@ -20,4 +20,9 @@ const ( // a reminder email or letter, plus another to the donor (or correspondent, if // set). ActionRemindCertificateProviderToConfirmIdentity + + // ActionRemindAttorneyToComplete will check that the target attorney has + // neither signed nor opted-out, and if so send them a reminder email or + // letter, plus another to the donor (or correspondent, if set). + ActionRemindAttorneyToComplete ) diff --git a/internal/scheduled/mock_AttorneyStore_test.go b/internal/scheduled/mock_AttorneyStore_test.go new file mode 100644 index 0000000000..8d970866d3 --- /dev/null +++ b/internal/scheduled/mock_AttorneyStore_test.go @@ -0,0 +1,97 @@ +// Code generated by mockery. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + attorneydata "github.com/ministryofjustice/opg-modernising-lpa/internal/attorney/attorneydata" + + mock "github.com/stretchr/testify/mock" +) + +// mockAttorneyStore is an autogenerated mock type for the AttorneyStore type +type mockAttorneyStore struct { + mock.Mock +} + +type mockAttorneyStore_Expecter struct { + mock *mock.Mock +} + +func (_m *mockAttorneyStore) EXPECT() *mockAttorneyStore_Expecter { + return &mockAttorneyStore_Expecter{mock: &_m.Mock} +} + +// All provides a mock function with given fields: ctx, lpaUID +func (_m *mockAttorneyStore) All(ctx context.Context, lpaUID string) ([]*attorneydata.Provided, error) { + ret := _m.Called(ctx, lpaUID) + + if len(ret) == 0 { + panic("no return value specified for All") + } + + var r0 []*attorneydata.Provided + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) ([]*attorneydata.Provided, error)); ok { + return rf(ctx, lpaUID) + } + if rf, ok := ret.Get(0).(func(context.Context, string) []*attorneydata.Provided); ok { + r0 = rf(ctx, lpaUID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*attorneydata.Provided) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, lpaUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockAttorneyStore_All_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'All' +type mockAttorneyStore_All_Call struct { + *mock.Call +} + +// All is a helper method to define mock.On call +// - ctx context.Context +// - lpaUID string +func (_e *mockAttorneyStore_Expecter) All(ctx interface{}, lpaUID interface{}) *mockAttorneyStore_All_Call { + return &mockAttorneyStore_All_Call{Call: _e.mock.On("All", ctx, lpaUID)} +} + +func (_c *mockAttorneyStore_All_Call) Run(run func(ctx context.Context, lpaUID string)) *mockAttorneyStore_All_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *mockAttorneyStore_All_Call) Return(_a0 []*attorneydata.Provided, _a1 error) *mockAttorneyStore_All_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockAttorneyStore_All_Call) RunAndReturn(run func(context.Context, string) ([]*attorneydata.Provided, error)) *mockAttorneyStore_All_Call { + _c.Call.Return(run) + return _c +} + +// newMockAttorneyStore creates a new instance of mockAttorneyStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockAttorneyStore(t interface { + mock.TestingT + Cleanup(func()) +}) *mockAttorneyStore { + mock := &mockAttorneyStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_test.go b/internal/scheduled/mock_test.go index 3c8be16c97..0781f1eeda 100644 --- a/internal/scheduled/mock_test.go +++ b/internal/scheduled/mock_test.go @@ -7,6 +7,11 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" ) +var ( + testUuidString = "a-uuid" + testUuidStringFn = func() string { return testUuidString } +) + func (c *mockDynamoClient_AllByLpaUIDAndPartialSK_Call) SetData(data any) { c.Run(func(_ context.Context, _ string, _ dynamo.SK, v any) { b, _ := attributevalue.Marshal(data) diff --git a/internal/scheduled/runner.go b/internal/scheduled/runner.go index 9cdb260f4b..a681b1028b 100644 --- a/internal/scheduled/runner.go +++ b/internal/scheduled/runner.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" "github.com/aws/aws-sdk-go-v2/service/cloudwatch/types" + "github.com/ministryofjustice/opg-modernising-lpa/internal/attorney/attorneydata" "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider/certificateproviderdata" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" @@ -36,6 +37,10 @@ type CertificateProviderStore interface { One(ctx context.Context, pk dynamo.LpaKeyType) (*certificateproviderdata.Provided, error) } +type AttorneyStore interface { + All(ctx context.Context, lpaUID string) ([]*attorneydata.Provided, error) +} + type NotifyClient interface { EmailGreeting(lpa *lpadata.Lpa) string SendActorEmail(ctx context.Context, to notify.ToEmail, lpaUID string, email notify.Email) error @@ -74,6 +79,7 @@ type Runner struct { since func(time.Time) time.Duration donorStore DonorStore certificateProviderStore CertificateProviderStore + attorneyStore AttorneyStore lpaStoreResolvingService LpaStoreResolvingService notifyClient NotifyClient eventClient EventClient @@ -95,6 +101,7 @@ func NewRunner( store ScheduledStore, donorStore DonorStore, certificateProviderStore CertificateProviderStore, + attorneyStore AttorneyStore, lpaStoreResolvingService LpaStoreResolvingService, notifyClient NotifyClient, eventClient EventClient, @@ -110,6 +117,7 @@ func NewRunner( since: time.Since, donorStore: donorStore, certificateProviderStore: certificateProviderStore, + attorneyStore: attorneyStore, lpaStoreResolvingService: lpaStoreResolvingService, notifyClient: notifyClient, eventClient: eventClient, @@ -124,6 +132,7 @@ func NewRunner( ActionExpireDonorIdentity: r.stepCancelDonorIdentity, ActionRemindCertificateProviderToComplete: r.stepRemindCertificateProviderToComplete, ActionRemindCertificateProviderToConfirmIdentity: r.stepRemindCertificateProviderToConfirmIdentity, + ActionRemindAttorneyToComplete: r.stepRemindAttorneyToComplete, } return r diff --git a/internal/scheduled/runner_test.go b/internal/scheduled/runner_test.go index 61c5731657..bc1fb6ccf1 100644 --- a/internal/scheduled/runner_test.go +++ b/internal/scheduled/runner_test.go @@ -51,17 +51,21 @@ func TestNewRunner(t *testing.T) { store := newMockScheduledStore(t) donorStore := newMockDonorStore(t) certificateProviderStore := newMockCertificateProviderStore(t) + attorneyStore := newMockAttorneyStore(t) lpaStoreResolvingService := newMockLpaStoreResolvingService(t) notifyClient := newMockNotifyClient(t) eventClient := newMockEventClient(t) metricsClient := newMockMetricsClient(t) bundle := newMockBundle(t) - runner := NewRunner(logger, store, donorStore, certificateProviderStore, lpaStoreResolvingService, notifyClient, eventClient, bundle, metricsClient, true, "app://url") + runner := NewRunner(logger, store, donorStore, certificateProviderStore, attorneyStore, lpaStoreResolvingService, notifyClient, eventClient, bundle, metricsClient, true, "app://url") assert.Equal(t, logger, runner.logger) assert.Equal(t, store, runner.store) assert.Equal(t, donorStore, runner.donorStore) + assert.Equal(t, certificateProviderStore, runner.certificateProviderStore) + assert.Equal(t, attorneyStore, runner.attorneyStore) + assert.Equal(t, lpaStoreResolvingService, runner.lpaStoreResolvingService) assert.Equal(t, notifyClient, runner.notifyClient) assert.Equal(t, metricsClient, runner.metricsClient) assert.Equal(t, true, runner.metricsEnabled) diff --git a/internal/scheduled/step_remind_attorney_to_complete.go b/internal/scheduled/step_remind_attorney_to_complete.go new file mode 100644 index 0000000000..3424c643e4 --- /dev/null +++ b/internal/scheduled/step_remind_attorney_to_complete.go @@ -0,0 +1,226 @@ +package scheduled + +import ( + "context" + "errors" + "fmt" + + "github.com/ministryofjustice/opg-modernising-lpa/internal/actor" + "github.com/ministryofjustice/opg-modernising-lpa/internal/actor/actoruid" + "github.com/ministryofjustice/opg-modernising-lpa/internal/attorney/attorneydata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/ministryofjustice/opg-modernising-lpa/internal/event" + "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" + "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" + "github.com/ministryofjustice/opg-modernising-lpa/internal/page" +) + +func (r *Runner) stepRemindAttorneyToComplete(ctx context.Context, row *Event) error { + donor, err := r.donorStore.One(ctx, row.TargetLpaKey, row.TargetLpaOwnerKey) + if err != nil { + return fmt.Errorf("error retrieving donor: %w", err) + } + + lpa, err := r.lpaStoreResolvingService.Resolve(ctx, donor) + if err != nil { + return fmt.Errorf("error resolving lpa: %w", err) + } + + beforeExpiry := lpa.ExpiresAt().AddDate(0, -3, 0) + afterInvite := lpa.AttorneysInvitedAt.AddDate(0, 3, 0) + + if r.now().Before(afterInvite) || r.now().Before(beforeExpiry) { + return errStepIgnored + } + + attorneys, err := r.attorneyStore.All(ctx, donor.LpaUID) + if err != nil && !errors.Is(err, dynamo.NotFoundError{}) { + return fmt.Errorf("error retrieving attorney: %w", err) + } + + attorneyMap := map[actoruid.UID]*attorneydata.Provided{} + for _, attorney := range attorneys { + attorneyMap[attorney.UID] = attorney + } + + ran := false + + for _, attorney := range lpa.Attorneys.Attorneys { + if provided, ok := attorneyMap[attorney.UID]; !ok || !provided.Signed() { + ran = true + if err := r.stepRemindAttorneyToCompleteAttorney(ctx, lpa, actor.TypeAttorney, attorney, provided); err != nil { + return err + } + } + } + + if trustCorporation := lpa.Attorneys.TrustCorporation; !lpa.Attorneys.TrustCorporation.UID.IsZero() { + if provided, ok := attorneyMap[trustCorporation.UID]; !ok || !provided.Signed() { + ran = true + if err := r.stepRemindAttorneyToCompleteTrustCorporation(ctx, lpa, actor.TypeTrustCorporation, trustCorporation, provided); err != nil { + return err + } + } + } + + for _, attorney := range lpa.ReplacementAttorneys.Attorneys { + if provided, ok := attorneyMap[attorney.UID]; !ok || !provided.Signed() { + ran = true + if err := r.stepRemindAttorneyToCompleteAttorney(ctx, lpa, actor.TypeReplacementAttorney, attorney, provided); err != nil { + return err + } + } + } + + if trustCorporation := lpa.ReplacementAttorneys.TrustCorporation; !trustCorporation.UID.IsZero() { + if provided, ok := attorneyMap[trustCorporation.UID]; !ok || !provided.Signed() { + ran = true + if err := r.stepRemindAttorneyToCompleteTrustCorporation(ctx, lpa, actor.TypeReplacementTrustCorporation, trustCorporation, provided); err != nil { + return err + } + } + } + + if !ran { + return errStepIgnored + } + + return nil +} + +func (r *Runner) stepRemindAttorneyToCompleteAttorney(ctx context.Context, lpa *lpadata.Lpa, actorType actor.Type, attorney lpadata.Attorney, provided *attorneydata.Provided) error { + if attorney.Channel.IsPaper() { + letterRequest := event.LetterRequested{ + UID: lpa.LpaUID, + LetterType: "ADVISE_ATTORNEY_TO_SIGN_OR_OPT_OUT", + ActorType: actorType, + ActorUID: attorney.UID, + } + + if err := r.eventClient.SendLetterRequested(ctx, letterRequest); err != nil { + return fmt.Errorf("could not send attorney letter request: %w", err) + } + } else { + localizer := r.bundle.For(localize.En) + if provided != nil && !provided.ContactLanguagePreference.Empty() { + localizer = r.bundle.For(provided.ContactLanguagePreference) + } + + toAttorneyEmail := notify.ToLpaAttorney(attorney) + + if err := r.notifyClient.SendActorEmail(ctx, toAttorneyEmail, lpa.LpaUID, notify.AdviseAttorneyToSignOrOptOutEmail{ + DonorFullName: lpa.Donor.FullName(), + DonorFullNamePossessive: localizer.Possessive(lpa.Donor.FullName()), + LpaType: localizer.T(lpa.Type.String()), + AttorneyFullName: attorney.FullName(), + InvitedDate: localizer.FormatDate(lpa.AttorneysInvitedAt), + DeadlineDate: localizer.FormatDate(lpa.ExpiresAt()), + AttorneyStartPageURL: r.appPublicURL + page.PathAttorneyStart.Format(), + }); err != nil { + return fmt.Errorf("could not send attorney email: %w", err) + } + } + + if lpa.Donor.Channel.IsPaper() { + letterRequest := event.LetterRequested{ + UID: lpa.LpaUID, + LetterType: "INFORM_DONOR_ATTORNEY_HAS_NOT_ACTED", + ActorType: actor.TypeDonor, + ActorUID: lpa.Donor.UID, + } + + if lpa.Correspondent.Address.Line1 != "" { + letterRequest.ActorType = actor.TypeCorrespondent + letterRequest.ActorUID = lpa.Correspondent.UID + } + + if err := r.eventClient.SendLetterRequested(ctx, letterRequest); err != nil { + return fmt.Errorf("could not send donor letter request: %w", err) + } + } else { + localizer := r.bundle.For(lpa.Donor.ContactLanguagePreference) + toDonorEmail := notify.ToLpaDonor(lpa) + + if err := r.notifyClient.SendActorEmail(ctx, toDonorEmail, lpa.LpaUID, notify.InformDonorAttorneyHasNotActedEmail{ + Greeting: r.notifyClient.EmailGreeting(lpa), + AttorneyFullName: attorney.FullName(), + LpaType: localizer.T(lpa.Type.String()), + InvitedDate: localizer.FormatDate(lpa.AttorneysInvitedAt), + DeadlineDate: localizer.FormatDate(lpa.ExpiresAt()), + AttorneyStartPageURL: r.appPublicURL + page.PathAttorneyStart.Format(), + }); err != nil { + return fmt.Errorf("could not send donor email: %w", err) + } + } + + return nil +} + +func (r *Runner) stepRemindAttorneyToCompleteTrustCorporation(ctx context.Context, lpa *lpadata.Lpa, actorType actor.Type, trustCorporation lpadata.TrustCorporation, provided *attorneydata.Provided) error { + if trustCorporation.Channel.IsPaper() { + letterRequest := event.LetterRequested{ + UID: lpa.LpaUID, + LetterType: "ADVISE_ATTORNEY_TO_SIGN_OR_OPT_OUT", + ActorType: actorType, + ActorUID: trustCorporation.UID, + } + + if err := r.eventClient.SendLetterRequested(ctx, letterRequest); err != nil { + return fmt.Errorf("could not send certificate provider letter request: %w", err) + } + } else { + localizer := r.bundle.For(localize.En) + if provided != nil && !provided.ContactLanguagePreference.Empty() { + localizer = r.bundle.For(provided.ContactLanguagePreference) + } + + toAttorneyEmail := notify.ToLpaTrustCorporation(trustCorporation) + + if err := r.notifyClient.SendActorEmail(ctx, toAttorneyEmail, lpa.LpaUID, notify.AdviseAttorneyToSignOrOptOutEmail{ + DonorFullName: lpa.Donor.FullName(), + DonorFullNamePossessive: localizer.Possessive(lpa.Donor.FullName()), + LpaType: localizer.T(lpa.Type.String()), + AttorneyFullName: trustCorporation.Name, + InvitedDate: localizer.FormatDate(lpa.AttorneysInvitedAt), + DeadlineDate: localizer.FormatDate(lpa.ExpiresAt()), + AttorneyStartPageURL: r.appPublicURL + page.PathAttorneyStart.Format(), + }); err != nil { + return fmt.Errorf("could not send trust corporation email: %w", err) + } + } + + if lpa.Donor.Channel.IsPaper() { + letterRequest := event.LetterRequested{ + UID: lpa.LpaUID, + LetterType: "INFORM_DONOR_ATTORNEY_HAS_NOT_ACTED", + ActorType: actor.TypeDonor, + ActorUID: lpa.Donor.UID, + } + + if lpa.Correspondent.Address.Line1 != "" { + letterRequest.ActorType = actor.TypeCorrespondent + letterRequest.ActorUID = lpa.Correspondent.UID + } + + if err := r.eventClient.SendLetterRequested(ctx, letterRequest); err != nil { + return fmt.Errorf("could not send donor letter request: %w", err) + } + } else { + localizer := r.bundle.For(lpa.Donor.ContactLanguagePreference) + toDonorEmail := notify.ToLpaDonor(lpa) + + if err := r.notifyClient.SendActorEmail(ctx, toDonorEmail, lpa.LpaUID, notify.InformDonorAttorneyHasNotActedEmail{ + Greeting: r.notifyClient.EmailGreeting(lpa), + AttorneyFullName: trustCorporation.Name, + LpaType: localizer.T(lpa.Type.String()), + InvitedDate: localizer.FormatDate(lpa.AttorneysInvitedAt), + DeadlineDate: localizer.FormatDate(lpa.ExpiresAt()), + AttorneyStartPageURL: r.appPublicURL + page.PathAttorneyStart.Format(), + }); err != nil { + return fmt.Errorf("could not send donor email: %w", err) + } + } + + return nil +} diff --git a/internal/scheduled/step_remind_attorney_to_complete_test.go b/internal/scheduled/step_remind_attorney_to_complete_test.go new file mode 100644 index 0000000000..fd911a8e42 --- /dev/null +++ b/internal/scheduled/step_remind_attorney_to_complete_test.go @@ -0,0 +1,949 @@ +package scheduled + +import ( + "testing" + "time" + + "github.com/ministryofjustice/opg-modernising-lpa/internal/actor" + "github.com/ministryofjustice/opg-modernising-lpa/internal/actor/actoruid" + "github.com/ministryofjustice/opg-modernising-lpa/internal/attorney/attorneydata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/ministryofjustice/opg-modernising-lpa/internal/event" + "github.com/ministryofjustice/opg-modernising-lpa/internal/form" + "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" + "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" + "github.com/ministryofjustice/opg-modernising-lpa/internal/place" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestRunnerRemindAttorneyToComplete(t *testing.T) { + attorneyUID := actoruid.New() + replacementAttorneyUID := actoruid.New() + trustCorporationUID := actoruid.New() + replacementTrustCorporationUID := actoruid.New() + + testcases := map[string]struct { + attorneys []*attorneydata.Provided + attorneyError error + }{ + "not started": { + attorneyError: dynamo.NotFoundError{}, + }, + "started": { + attorneys: []*attorneydata.Provided{{ + UID: attorneyUID, + ContactLanguagePreference: localize.En, + }, { + UID: replacementAttorneyUID, + IsReplacement: true, + ContactLanguagePreference: localize.En, + }, { + UID: trustCorporationUID, + IsTrustCorporation: true, + ContactLanguagePreference: localize.En, + }, { + UID: replacementTrustCorporationUID, + IsReplacement: true, + IsTrustCorporation: true, + ContactLanguagePreference: localize.En, + }}, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + row := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + donor := &donordata.Provided{ + LpaUID: "lpa-uid", + } + lpa := &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + }, + Attorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: attorneyUID, + FirstNames: "c", + LastName: "d", + ContactLanguagePreference: localize.En, + }}, + TrustCorporation: lpadata.TrustCorporation{ + UID: trustCorporationUID, + Name: "trusty", + ContactLanguagePreference: localize.En, + }, + }, + ReplacementAttorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: replacementAttorneyUID, + FirstNames: "e", + LastName: "f", + ContactLanguagePreference: localize.En, + }}, + TrustCorporation: lpadata.TrustCorporation{ + UID: replacementTrustCorporationUID, + Name: "untrusty", + ContactLanguagePreference: localize.En, + }, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(ctx, row.TargetLpaKey, row.TargetLpaOwnerKey). + Return(donor, nil) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(ctx, donor). + Return(lpa, nil) + + attorneyStore := newMockAttorneyStore(t) + attorneyStore.EXPECT(). + All(ctx, "lpa-uid"). + Return(tc.attorneys, tc.attorneyError) + + notifyClient := newMockNotifyClient(t) + notifyClient.EXPECT(). + EmailGreeting(lpa). + Return("hey") + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaAttorney(lpa.Attorneys.Attorneys[0]), "lpa-uid", notify.AdviseAttorneyToSignOrOptOutEmail{ + DonorFullName: "a b", + DonorFullNamePossessive: "a b’s", + LpaType: "personal-welfare", + AttorneyFullName: "c d", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + AttorneyStartPageURL: "http://app/attorney-start", + }). + Return(nil). + Once() + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaTrustCorporation(lpa.Attorneys.TrustCorporation), "lpa-uid", notify.AdviseAttorneyToSignOrOptOutEmail{ + DonorFullName: "a b", + DonorFullNamePossessive: "a b’s", + LpaType: "personal-welfare", + AttorneyFullName: "trusty", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + AttorneyStartPageURL: "http://app/attorney-start", + }). + Return(nil). + Once() + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaAttorney(lpa.ReplacementAttorneys.Attorneys[0]), "lpa-uid", notify.AdviseAttorneyToSignOrOptOutEmail{ + DonorFullName: "a b", + DonorFullNamePossessive: "a b’s", + LpaType: "personal-welfare", + AttorneyFullName: "e f", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + AttorneyStartPageURL: "http://app/attorney-start", + }). + Return(nil). + Once() + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaTrustCorporation(lpa.ReplacementAttorneys.TrustCorporation), "lpa-uid", notify.AdviseAttorneyToSignOrOptOutEmail{ + DonorFullName: "a b", + DonorFullNamePossessive: "a b’s", + LpaType: "personal-welfare", + AttorneyFullName: "untrusty", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + AttorneyStartPageURL: "http://app/attorney-start", + }). + Return(nil). + Once() + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaDonor(lpa), "lpa-uid", notify.InformDonorAttorneyHasNotActedEmail{ + Greeting: "hey", + AttorneyFullName: "c d", + LpaType: "personal-welfare", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + AttorneyStartPageURL: "http://app/attorney-start", + }). + Return(nil). + Once() + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaDonor(lpa), "lpa-uid", notify.InformDonorAttorneyHasNotActedEmail{ + Greeting: "hey", + AttorneyFullName: "trusty", + LpaType: "personal-welfare", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + AttorneyStartPageURL: "http://app/attorney-start", + }). + Return(nil). + Once() + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaDonor(lpa), "lpa-uid", notify.InformDonorAttorneyHasNotActedEmail{ + Greeting: "hey", + AttorneyFullName: "e f", + LpaType: "personal-welfare", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + AttorneyStartPageURL: "http://app/attorney-start", + }). + Return(nil). + Once() + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaDonor(lpa), "lpa-uid", notify.InformDonorAttorneyHasNotActedEmail{ + Greeting: "hey", + AttorneyFullName: "untrusty", + LpaType: "personal-welfare", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + AttorneyStartPageURL: "http://app/attorney-start", + }). + Return(nil). + Once() + + localizer := &localize.Localizer{} + + bundle := newMockBundle(t) + bundle.EXPECT(). + For(localize.En). + Return(localizer) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + attorneyStore: attorneyStore, + notifyClient: notifyClient, + bundle: bundle, + now: testNowFn, + appPublicURL: "http://app", + } + + err := runner.stepRemindAttorneyToComplete(ctx, row) + assert.Nil(t, err) + }) + } +} + +func TestRunnerRemindAttorneyToCompleteWhenOnPaper(t *testing.T) { + donorUID := actoruid.New() + correspondentUID := actoruid.New() + attorneyUID := actoruid.New() + replacementAttorneyUID := actoruid.New() + trustCorporationUID := actoruid.New() + replacementTrustCorporationUID := actoruid.New() + + testcases := map[string]struct { + lpa *lpadata.Lpa + donorLetterRequest event.LetterRequested + }{ + "to donor": { + lpa: &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + UID: donorUID, + FirstNames: "a", + LastName: "b", + Channel: lpadata.ChannelPaper, + }, + Attorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: attorneyUID, + FirstNames: "c", + LastName: "d", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }}, + TrustCorporation: lpadata.TrustCorporation{ + UID: trustCorporationUID, + Name: "trusty", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + }, + ReplacementAttorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: replacementAttorneyUID, + FirstNames: "e", + LastName: "f", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }}, + TrustCorporation: lpadata.TrustCorporation{ + UID: replacementTrustCorporationUID, + Name: "untrusty", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + }, + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + donorLetterRequest: event.LetterRequested{ + UID: "lpa-uid", + LetterType: "INFORM_DONOR_ATTORNEY_HAS_NOT_ACTED", + ActorType: actor.TypeDonor, + ActorUID: donorUID, + }, + }, + "to correspondent": { + lpa: &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + Channel: lpadata.ChannelPaper, + }, + Attorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: attorneyUID, + FirstNames: "c", + LastName: "d", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }}, + TrustCorporation: lpadata.TrustCorporation{ + UID: trustCorporationUID, + Name: "trusty", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + }, + ReplacementAttorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: replacementAttorneyUID, + FirstNames: "e", + LastName: "f", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }}, + TrustCorporation: lpadata.TrustCorporation{ + UID: replacementTrustCorporationUID, + Name: "untrusty", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + }, + Correspondent: lpadata.Correspondent{ + UID: correspondentUID, + Address: place.Address{Line1: "123"}, + }, + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + donorLetterRequest: event.LetterRequested{ + UID: "lpa-uid", + LetterType: "INFORM_DONOR_ATTORNEY_HAS_NOT_ACTED", + ActorType: actor.TypeCorrespondent, + ActorUID: correspondentUID, + }, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + row := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + donor := &donordata.Provided{ + LpaUID: "lpa-uid", + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(ctx, row.TargetLpaKey, row.TargetLpaOwnerKey). + Return(donor, nil) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(ctx, donor). + Return(tc.lpa, nil) + + attorneyStore := newMockAttorneyStore(t) + attorneyStore.EXPECT(). + All(ctx, "lpa-uid"). + Return(nil, dynamo.NotFoundError{}) + + eventClient := newMockEventClient(t) + eventClient.EXPECT(). + SendLetterRequested(ctx, event.LetterRequested{ + UID: "lpa-uid", + LetterType: "ADVISE_ATTORNEY_TO_SIGN_OR_OPT_OUT", + ActorType: actor.TypeAttorney, + ActorUID: attorneyUID, + }). + Return(nil) + eventClient.EXPECT(). + SendLetterRequested(ctx, event.LetterRequested{ + UID: "lpa-uid", + LetterType: "ADVISE_ATTORNEY_TO_SIGN_OR_OPT_OUT", + ActorType: actor.TypeTrustCorporation, + ActorUID: trustCorporationUID, + }). + Return(nil) + eventClient.EXPECT(). + SendLetterRequested(ctx, event.LetterRequested{ + UID: "lpa-uid", + LetterType: "ADVISE_ATTORNEY_TO_SIGN_OR_OPT_OUT", + ActorType: actor.TypeReplacementAttorney, + ActorUID: replacementAttorneyUID, + }). + Return(nil) + eventClient.EXPECT(). + SendLetterRequested(ctx, event.LetterRequested{ + UID: "lpa-uid", + LetterType: "ADVISE_ATTORNEY_TO_SIGN_OR_OPT_OUT", + ActorType: actor.TypeReplacementTrustCorporation, + ActorUID: replacementTrustCorporationUID, + }). + Return(nil) + eventClient.EXPECT(). + SendLetterRequested(ctx, tc.donorLetterRequest). + Return(nil) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + attorneyStore: attorneyStore, + eventClient: eventClient, + now: testNowFn, + } + + err := runner.stepRemindAttorneyToComplete(ctx, row) + assert.Nil(t, err) + }) + } +} + +func TestRunnerRemindAttorneyToCompleteWhenNotValidTime(t *testing.T) { + testcases := map[string]*lpadata.Lpa{ + "invite sent almost 3 months ago": { + AttorneysInvitedAt: testNow.AddDate(0, -3, 1), + SignedAt: testNow.AddDate(0, -3, 0), + }, + "expiry almost in 3 months": { + AttorneysInvitedAt: testNow.AddDate(0, -3, 0), + SignedAt: testNow.AddDate(0, -3, 1), + }, + "submitted expiry almost in 3 months": { + Donor: lpadata.Donor{ + IdentityCheck: &lpadata.IdentityCheck{ + CheckedAt: testNow, + }, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, 0), + SignedAt: testNow.AddDate(-2, 3, 1), + Submitted: true, + }, + } + + for name, lpa := range testcases { + t.Run(name, func(t *testing.T) { + donor := &donordata.Provided{ + LpaUID: "lpa-uid", + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(donor, nil) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Return(lpa, nil) + + runner := &Runner{ + lpaStoreResolvingService: lpaStoreResolvingService, + donorStore: donorStore, + now: testNowFn, + } + + err := runner.stepRemindAttorneyToComplete(ctx, &Event{}) + assert.Equal(t, errStepIgnored, err) + }) + } +} + +func TestRunnerRemindAttorneyToCompleteWhenAttorneyAlreadyCompleted(t *testing.T) { + attorneyUID := actoruid.New() + replacementAttorneyUID := actoruid.New() + trustCorporationUID := actoruid.New() + replacementTrustCorporationUID := actoruid.New() + + row := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + attorneys := []*attorneydata.Provided{{ + UID: attorneyUID, + SignedAt: time.Now(), + }, { + UID: trustCorporationUID, + IsTrustCorporation: true, + WouldLikeSecondSignatory: form.No, + AuthorisedSignatories: [2]attorneydata.TrustCorporationSignatory{{ + SignedAt: time.Now(), + }}, + }, { + UID: replacementAttorneyUID, + IsReplacement: true, + SignedAt: time.Now(), + }, { + UID: replacementTrustCorporationUID, + IsTrustCorporation: true, + IsReplacement: true, + WouldLikeSecondSignatory: form.Yes, + AuthorisedSignatories: [2]attorneydata.TrustCorporationSignatory{{ + SignedAt: time.Now(), + }, { + SignedAt: time.Now(), + }}, + }} + donor := &donordata.Provided{ + LpaUID: "lpa-uid", + } + lpa := &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + }, + Attorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: attorneyUID, + FirstNames: "c", + LastName: "d", + ContactLanguagePreference: localize.En, + }}, + TrustCorporation: lpadata.TrustCorporation{ + UID: trustCorporationUID, + Name: "trusty", + ContactLanguagePreference: localize.En, + }, + }, + ReplacementAttorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: replacementAttorneyUID, + FirstNames: "e", + LastName: "f", + ContactLanguagePreference: localize.En, + }}, + TrustCorporation: lpadata.TrustCorporation{ + UID: replacementTrustCorporationUID, + Name: "untrusty", + ContactLanguagePreference: localize.En, + }, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(donor, nil) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Return(lpa, nil) + + attorneyStore := newMockAttorneyStore(t) + attorneyStore.EXPECT(). + All(mock.Anything, mock.Anything). + Return(attorneys, nil) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + attorneyStore: attorneyStore, + now: testNowFn, + } + + err := runner.stepRemindAttorneyToComplete(ctx, row) + assert.Equal(t, errStepIgnored, err) +} + +func TestRunnerRemindAttorneyToCompleteWhenDonorStoreErrors(t *testing.T) { + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(nil, expectedError) + + runner := &Runner{ + donorStore: donorStore, + now: testNowFn, + } + + err := runner.stepRemindAttorneyToComplete(ctx, &Event{}) + assert.ErrorIs(t, err, expectedError) +} + +func TestRunnerRemindAttorneyToCompleteWhenLpaStoreResolvingServiceErrors(t *testing.T) { + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(&donordata.Provided{}, nil) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Return(nil, expectedError) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + now: testNowFn, + } + + err := runner.stepRemindAttorneyToComplete(ctx, &Event{}) + assert.ErrorIs(t, err, expectedError) +} + +func TestRunnerRemindAttorneyToCompleteWhenAttorneyStoreErrors(t *testing.T) { + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(&donordata.Provided{}, nil) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Return(&lpadata.Lpa{}, nil) + + attorneyStore := newMockAttorneyStore(t) + attorneyStore.EXPECT(). + All(mock.Anything, mock.Anything). + Return(nil, expectedError) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + attorneyStore: attorneyStore, + now: testNowFn, + } + + err := runner.stepRemindAttorneyToComplete(ctx, &Event{}) + assert.ErrorIs(t, err, expectedError) +} + +func TestRunnerRemindAttorneyToCompleteWhenNotifyClientErrors(t *testing.T) { + actorUID := actoruid.New() + + notifyCases := map[string]func(*mockNotifyClient){ + "email to attorney": func(notifyClient *mockNotifyClient) { + notifyClient.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedError). + Once() + }, + "email to donor": func(notifyClient *mockNotifyClient) { + notifyClient.EXPECT(). + EmailGreeting(mock.Anything). + Return("hey") + notifyClient.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil). + Once() + notifyClient.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedError). + Once() + }, + } + + lpaCases := map[string]*lpadata.Lpa{ + "attorney": &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + }, + Attorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: actorUID, + FirstNames: "c", + LastName: "d", + ContactLanguagePreference: localize.En, + }}, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + "replacement attorney": &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + }, + ReplacementAttorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: actorUID, + FirstNames: "e", + LastName: "f", + ContactLanguagePreference: localize.En, + }}, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + "trust corporation": &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + }, + Attorneys: lpadata.Attorneys{ + TrustCorporation: lpadata.TrustCorporation{ + UID: actorUID, + Name: "trusty", + ContactLanguagePreference: localize.En, + }, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + "replacement trust corporation": &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + }, + ReplacementAttorneys: lpadata.Attorneys{ + TrustCorporation: lpadata.TrustCorporation{ + UID: actorUID, + Name: "untrusty", + ContactLanguagePreference: localize.En, + }, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + } + + for setupName, setupNotify := range notifyCases { + for name, lpa := range lpaCases { + t.Run(name+" "+setupName, func(t *testing.T) { + row := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + donor := &donordata.Provided{ + LpaUID: "lpa-uid", + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(donor, nil) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Return(lpa, nil) + + attorneyStore := newMockAttorneyStore(t) + attorneyStore.EXPECT(). + All(mock.Anything, mock.Anything). + Return([]*attorneydata.Provided{{}}, nil) + + notifyClient := newMockNotifyClient(t) + setupNotify(notifyClient) + + localizer := &localize.Localizer{} + + bundle := newMockBundle(t) + bundle.EXPECT(). + For(localize.En). + Return(localizer) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + attorneyStore: attorneyStore, + notifyClient: notifyClient, + bundle: bundle, + now: testNowFn, + appPublicURL: "http://app", + } + + err := runner.stepRemindAttorneyToComplete(ctx, row) + assert.ErrorIs(t, err, expectedError) + }) + } + } +} + +func TestRunnerRemindAttorneyToCompleteWhenEventClientErrors(t *testing.T) { + actorUID := actoruid.New() + + eventCases := map[string]func(*mockEventClient){ + "email to attorney": func(eventClient *mockEventClient) { + eventClient.EXPECT(). + SendLetterRequested(mock.Anything, mock.Anything). + Return(expectedError). + Once() + }, + "email to donor": func(eventClient *mockEventClient) { + eventClient.EXPECT(). + SendLetterRequested(mock.Anything, mock.Anything). + Return(nil). + Once() + eventClient.EXPECT(). + SendLetterRequested(mock.Anything, mock.Anything). + Return(expectedError). + Once() + }, + } + + lpaCases := map[string]*lpadata.Lpa{ + "attorney": &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + Attorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: actorUID, + FirstNames: "c", + LastName: "d", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }}, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + "replacement attorney": &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + ReplacementAttorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{ + UID: actorUID, + FirstNames: "e", + LastName: "f", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }}, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + "trust corporation": &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + Attorneys: lpadata.Attorneys{ + TrustCorporation: lpadata.TrustCorporation{ + UID: actorUID, + Name: "trusty", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + "replacement trust corporation": &lpadata.Lpa{ + LpaUID: "lpa-uid", + Type: lpadata.LpaTypePersonalWelfare, + Donor: lpadata.Donor{ + FirstNames: "a", + LastName: "b", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + ReplacementAttorneys: lpadata.Attorneys{ + TrustCorporation: lpadata.TrustCorporation{ + UID: actorUID, + Name: "untrusty", + ContactLanguagePreference: localize.En, + Channel: lpadata.ChannelPaper, + }, + }, + AttorneysInvitedAt: testNow.AddDate(0, -3, -1), + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + } + + for setupName, setupEvent := range eventCases { + for name, lpa := range lpaCases { + t.Run(name+" "+setupName, func(t *testing.T) { + row := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + donor := &donordata.Provided{ + LpaUID: "lpa-uid", + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(donor, nil) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(mock.Anything, mock.Anything). + Return(lpa, nil) + + attorneyStore := newMockAttorneyStore(t) + attorneyStore.EXPECT(). + All(mock.Anything, mock.Anything). + Return([]*attorneydata.Provided{{}}, nil) + + eventClient := newMockEventClient(t) + setupEvent(eventClient) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + attorneyStore: attorneyStore, + eventClient: eventClient, + now: testNowFn, + appPublicURL: "http://app", + } + + err := runner.stepRemindAttorneyToComplete(ctx, row) + assert.ErrorIs(t, err, expectedError) + }) + } + } +} diff --git a/internal/scheduled/remind_certificate_provider_to_complete_test.go b/internal/scheduled/step_remind_certificate_provider_to_complete_test.go similarity index 100% rename from internal/scheduled/remind_certificate_provider_to_complete_test.go rename to internal/scheduled/step_remind_certificate_provider_to_complete_test.go diff --git a/internal/scheduled/remind_certificate_provider_to_confirm_identity_test.go b/internal/scheduled/step_remind_certificate_provider_to_confirm_identity_test.go similarity index 100% rename from internal/scheduled/remind_certificate_provider_to_confirm_identity_test.go rename to internal/scheduled/step_remind_certificate_provider_to_confirm_identity_test.go diff --git a/internal/scheduled/store.go b/internal/scheduled/store.go index 5fb440ee0f..498e4e7a6c 100644 --- a/internal/scheduled/store.go +++ b/internal/scheduled/store.go @@ -6,6 +6,7 @@ import ( "time" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/ministryofjustice/opg-modernising-lpa/internal/random" ) type DynamoClient interface { @@ -18,12 +19,14 @@ type DynamoClient interface { type Store struct { dynamoClient DynamoClient + uuidString func() string now func() time.Time } func NewStore(dynamoClient DynamoClient) *Store { return &Store{ dynamoClient: dynamoClient, + uuidString: random.UuidString, now: time.Now, } } @@ -49,7 +52,7 @@ func (s *Store) Create(ctx context.Context, rows ...Event) error { for _, row := range rows { row.PK = dynamo.ScheduledDayKey(row.At) - row.SK = dynamo.ScheduledKey(row.At, int(row.Action)) + row.SK = dynamo.ScheduledKey(row.At, s.uuidString()) row.CreatedAt = s.now() transaction.Put(row) diff --git a/internal/scheduled/store_test.go b/internal/scheduled/store_test.go index 1fdb9448cb..c14129277e 100644 --- a/internal/scheduled/store_test.go +++ b/internal/scheduled/store_test.go @@ -19,14 +19,14 @@ func TestStorePop(t *testing.T) { row := &Event{ Action: 99, PK: dynamo.ScheduledDayKey(testNow), - SK: dynamo.ScheduledKey(testNow, 99), + SK: dynamo.ScheduledKey(testNow, testUuidString), TargetLpaKey: dynamo.LpaKey("an-lpa"), TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), } movedRow := &Event{ Action: 99, PK: dynamo.ScheduledDayKey(testNow).Handled(), - SK: dynamo.ScheduledKey(testNow, 99), + SK: dynamo.ScheduledKey(testNow, testUuidString), TargetLpaKey: dynamo.LpaKey("an-lpa"), TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), } @@ -40,7 +40,7 @@ func TestStorePop(t *testing.T) { Move(ctx, dynamo.Keys{PK: row.PK, SK: row.SK}, *movedRow). Return(nil) - store := &Store{dynamoClient: dynamoClient} + store := &Store{dynamoClient: dynamoClient, uuidString: testUuidStringFn} result, err := store.Pop(ctx, testNow) assert.Nil(t, err) assert.Equal(t, movedRow, result) @@ -65,7 +65,7 @@ func TestStorePopWhenDeleteOneErrors(t *testing.T) { SetData(&Event{ Action: 99, PK: dynamo.ScheduledDayKey(testNow), - SK: dynamo.ScheduledKey(testNow, 99), + SK: dynamo.ScheduledKey(testNow, testUuidString), TargetLpaKey: dynamo.LpaKey("an-lpa"), TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), }) @@ -73,7 +73,7 @@ func TestStorePopWhenDeleteOneErrors(t *testing.T) { Move(mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - store := &Store{dynamoClient: dynamoClient} + store := &Store{dynamoClient: dynamoClient, uuidString: testUuidStringFn} _, err := store.Pop(ctx, testNow) assert.Equal(t, expectedError, err) } @@ -88,14 +88,14 @@ func TestStoreCreate(t *testing.T) { Puts: []any{ Event{ PK: dynamo.ScheduledDayKey(at), - SK: dynamo.ScheduledKey(at, 99), + SK: dynamo.ScheduledKey(at, testUuidString), CreatedAt: testNow, At: at, Action: 99, }, Event{ PK: dynamo.ScheduledDayKey(at2), - SK: dynamo.ScheduledKey(at2, 100), + SK: dynamo.ScheduledKey(at2, testUuidString), CreatedAt: testNow, At: at2, Action: 100, @@ -104,7 +104,7 @@ func TestStoreCreate(t *testing.T) { }). Return(expectedError) - store := &Store{dynamoClient: dynamoClient, now: testNowFn} + store := &Store{dynamoClient: dynamoClient, now: testNowFn, uuidString: testUuidStringFn} err := store.Create(ctx, Event{At: at, Action: 99}, Event{At: at2, Action: 100}) assert.Equal(t, expectedError, err) } @@ -118,17 +118,17 @@ func TestDeleteAllByUID(t *testing.T) { AllByLpaUIDAndPartialSK(ctx, "lpa-uid", dynamo.PartialScheduledKey(), mock.Anything). Return(nil). SetData([]Event{ - {LpaUID: "lpa-uid", PK: dynamo.ScheduledDayKey(now), SK: dynamo.ScheduledKey(now, 98)}, - {LpaUID: "lpa-uid", PK: dynamo.ScheduledDayKey(yesterday), SK: dynamo.ScheduledKey(yesterday, 99)}, + {LpaUID: "lpa-uid", PK: dynamo.ScheduledDayKey(now), SK: dynamo.ScheduledKey(now, testUuidString)}, + {LpaUID: "lpa-uid", PK: dynamo.ScheduledDayKey(yesterday), SK: dynamo.ScheduledKey(yesterday, testUuidString)}, }) dynamoClient.EXPECT(). DeleteKeys(ctx, []dynamo.Keys{ - {PK: dynamo.ScheduledDayKey(now), SK: dynamo.ScheduledKey(now, 98)}, - {PK: dynamo.ScheduledDayKey(yesterday), SK: dynamo.ScheduledKey(yesterday, 99)}, + {PK: dynamo.ScheduledDayKey(now), SK: dynamo.ScheduledKey(now, testUuidString)}, + {PK: dynamo.ScheduledDayKey(yesterday), SK: dynamo.ScheduledKey(yesterday, testUuidString)}, }). Return(nil) - store := &Store{dynamoClient: dynamoClient, now: testNowFn} + store := &Store{dynamoClient: dynamoClient, now: testNowFn, uuidString: testUuidStringFn} err := store.DeleteAllByUID(ctx, "lpa-uid") assert.Nil(t, err) diff --git a/internal/sharecode/mock_ScheduledStore_test.go b/internal/sharecode/mock_ScheduledStore_test.go new file mode 100644 index 0000000000..d3bac9f687 --- /dev/null +++ b/internal/sharecode/mock_ScheduledStore_test.go @@ -0,0 +1,98 @@ +// Code generated by mockery. DO NOT EDIT. + +package sharecode + +import ( + context "context" + + scheduled "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" + mock "github.com/stretchr/testify/mock" +) + +// mockScheduledStore is an autogenerated mock type for the ScheduledStore type +type mockScheduledStore struct { + mock.Mock +} + +type mockScheduledStore_Expecter struct { + mock *mock.Mock +} + +func (_m *mockScheduledStore) EXPECT() *mockScheduledStore_Expecter { + return &mockScheduledStore_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, rows +func (_m *mockScheduledStore) Create(ctx context.Context, rows ...scheduled.Event) error { + _va := make([]interface{}, len(rows)) + for _i := range rows { + _va[_i] = rows[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, ...scheduled.Event) error); ok { + r0 = rf(ctx, rows...) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockScheduledStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type mockScheduledStore_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - rows ...scheduled.Event +func (_e *mockScheduledStore_Expecter) Create(ctx interface{}, rows ...interface{}) *mockScheduledStore_Create_Call { + return &mockScheduledStore_Create_Call{Call: _e.mock.On("Create", + append([]interface{}{ctx}, rows...)...)} +} + +func (_c *mockScheduledStore_Create_Call) Run(run func(ctx context.Context, rows ...scheduled.Event)) *mockScheduledStore_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]scheduled.Event, len(args)-1) + for i, a := range args[1:] { + if a != nil { + variadicArgs[i] = a.(scheduled.Event) + } + } + run(args[0].(context.Context), variadicArgs...) + }) + return _c +} + +func (_c *mockScheduledStore_Create_Call) Return(_a0 error) *mockScheduledStore_Create_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockScheduledStore_Create_Call) RunAndReturn(run func(context.Context, ...scheduled.Event) error) *mockScheduledStore_Create_Call { + _c.Call.Return(run) + return _c +} + +// newMockScheduledStore creates a new instance of mockScheduledStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockScheduledStore(t interface { + mock.TestingT + Cleanup(func()) +}) *mockScheduledStore { + mock := &mockScheduledStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/sharecode/sender.go b/internal/sharecode/sender.go index 76f412c4b1..c1eae253ef 100644 --- a/internal/sharecode/sender.go +++ b/internal/sharecode/sender.go @@ -17,6 +17,8 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + "github.com/ministryofjustice/opg-modernising-lpa/internal/random" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode/sharecodedata" ) @@ -54,24 +56,32 @@ type CertificateProviderStore interface { GetAny(ctx context.Context) (*certificateproviderdata.Provided, error) } +type ScheduledStore interface { + Create(ctx context.Context, rows ...scheduled.Event) error +} + type Sender struct { testCode string shareCodeStore ShareCodeStore certificateProviderStore CertificateProviderStore + scheduledStore ScheduledStore notifyClient NotifyClient appPublicURL string - randomString func(int) string eventClient EventClient + randomString func(int) string + now func() time.Time } -func NewSender(shareCodeStore ShareCodeStore, notifyClient NotifyClient, appPublicURL string, randomString func(int) string, eventClient EventClient, certificateProviderStore CertificateProviderStore) *Sender { +func NewSender(shareCodeStore ShareCodeStore, notifyClient NotifyClient, appPublicURL string, eventClient EventClient, certificateProviderStore CertificateProviderStore, scheduledStore ScheduledStore) *Sender { return &Sender{ shareCodeStore: shareCodeStore, notifyClient: notifyClient, appPublicURL: appPublicURL, - randomString: randomString, eventClient: eventClient, certificateProviderStore: certificateProviderStore, + scheduledStore: scheduledStore, + randomString: random.String, + now: time.Now, } } @@ -138,22 +148,38 @@ func (s *Sender) SendCertificateProviderPrompt(ctx context.Context, appData appc }) } -func (s *Sender) SendAttorneys(ctx context.Context, appData appcontext.Data, donor *lpadata.Lpa) error { - if err := s.sendTrustCorporation(ctx, appData, donor, donor.Attorneys.TrustCorporation); err != nil { +func (s *Sender) SendAttorneys(ctx context.Context, appData appcontext.Data, lpa *lpadata.Lpa) error { + if err := s.scheduledStore.Create(ctx, scheduled.Event{ + At: s.now().AddDate(0, 3, 1), + Action: scheduled.ActionRemindAttorneyToComplete, + TargetLpaKey: lpa.LpaKey, + TargetLpaOwnerKey: lpa.LpaOwnerKey, + LpaUID: lpa.LpaUID, + }, scheduled.Event{ + At: lpa.ExpiresAt().AddDate(0, -3, 1), + Action: scheduled.ActionRemindAttorneyToComplete, + TargetLpaKey: lpa.LpaKey, + TargetLpaOwnerKey: lpa.LpaOwnerKey, + LpaUID: lpa.LpaUID, + }); err != nil { + return fmt.Errorf("error scheduling attorneys prompt: %w", err) + } + + if err := s.sendTrustCorporation(ctx, appData, lpa, lpa.Attorneys.TrustCorporation); err != nil { return err } - if err := s.sendReplacementTrustCorporation(ctx, appData, donor, donor.ReplacementAttorneys.TrustCorporation); err != nil { + if err := s.sendReplacementTrustCorporation(ctx, appData, lpa, lpa.ReplacementAttorneys.TrustCorporation); err != nil { return err } - for _, attorney := range donor.Attorneys.Attorneys { - if err := s.sendOriginalAttorney(ctx, appData, donor, attorney); err != nil { + for _, attorney := range lpa.Attorneys.Attorneys { + if err := s.sendOriginalAttorney(ctx, appData, lpa, attorney); err != nil { return err } } - for _, attorney := range donor.ReplacementAttorneys.Attorneys { - if err := s.sendReplacementAttorney(ctx, appData, donor, attorney); err != nil { + for _, attorney := range lpa.ReplacementAttorneys.Attorneys { + if err := s.sendReplacementAttorney(ctx, appData, lpa, attorney); err != nil { return err } } diff --git a/internal/sharecode/sender_test.go b/internal/sharecode/sender_test.go index ab60fc4148..b3e946ba2a 100644 --- a/internal/sharecode/sender_test.go +++ b/internal/sharecode/sender_test.go @@ -9,7 +9,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/actor" "github.com/ministryofjustice/opg-modernising-lpa/internal/actor/actoruid" "github.com/ministryofjustice/opg-modernising-lpa/internal/appcontext" - certificateproviderdata "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider/certificateproviderdata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider/certificateproviderdata" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" "github.com/ministryofjustice/opg-modernising-lpa/internal/event" @@ -17,6 +17,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode/sharecodedata" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -89,7 +90,12 @@ func TestShareCodeSenderSendCertificateProviderInvite(t *testing.T) { }). Return(nil) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + } err := sender.SendCertificateProviderInvite(ctx, TestAppData, CertificateProviderInvite{ LpaKey: dynamo.LpaKey("lpa"), LpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("donor")), @@ -197,7 +203,12 @@ func TestShareCodeSenderSendCertificateProviderInviteWithTestCode(t *testing.T) Once(). Return(nil) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + } if tc.useTestCode { sender.UseTestCode("abcdef123456") @@ -267,7 +278,12 @@ func TestShareCodeSenderSendCertificateProviderInviteWhenEmailErrors(t *testing. SendActorEmail(ctx, mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + } err := sender.SendCertificateProviderInvite(ctx, TestAppData, CertificateProviderInvite{ LpaUID: donor.LpaUID, Type: donor.Type, @@ -288,7 +304,11 @@ func TestShareCodeSenderSendCertificateProviderInviteWhenShareCodeStoreErrors(t Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + } err := sender.SendCertificateProviderInvite(ctx, TestAppData, CertificateProviderInvite{}, notify.ToCustomEmail(localize.En, "")) assert.Equal(t, expectedError, errors.Unwrap(err)) @@ -345,7 +365,13 @@ func TestShareCodeSenderSendCertificateProviderPromptOnline(t *testing.T) { GetAny(ctx). Return(nil, expectedError) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, nil, certificateProviderStore) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + certificateProviderStore: certificateProviderStore, + } err := sender.SendCertificateProviderPrompt(ctx, TestAppData, donor) assert.Nil(t, err) @@ -407,7 +433,13 @@ func TestShareCodeSenderSendCertificateProviderPromptOnlineWhenStarted(t *testin GetAny(ctx). Return(certificateProvider, nil) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, nil, certificateProviderStore) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + certificateProviderStore: certificateProviderStore, + } err := sender.SendCertificateProviderPrompt(ctx, TestAppData, donor) assert.Nil(t, err) @@ -455,7 +487,12 @@ func TestShareCodeSenderSendCertificateProviderPromptPaper(t *testing.T) { }). Return(nil) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, eventClient, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + eventClient: eventClient, + } err := sender.SendCertificateProviderPrompt(ctx, TestAppData, donor) assert.Nil(t, err) @@ -548,7 +585,13 @@ func TestShareCodeSenderSendCertificateProviderPromptWithTestCode(t *testing.T) GetAny(ctx). Return(nil, expectedError) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, nil, certificateProviderStore) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + certificateProviderStore: certificateProviderStore, + } if tc.useTestCode { sender.UseTestCode("abcdef123456") @@ -577,7 +620,11 @@ func TestShareCodeSenderSendCertificateProviderPromptPaperWhenShareCodeStoreErro Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + } err := sender.SendCertificateProviderPrompt(ctx, TestAppData, donor) assert.ErrorIs(t, err, expectedError) @@ -602,7 +649,12 @@ func TestShareCodeSenderSendCertificateProviderPromptPaperWhenEventClientError(t SendPaperFormRequested(mock.Anything, mock.Anything). Return(expectedError) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, eventClient, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + eventClient: eventClient, + } err := sender.SendCertificateProviderPrompt(ctx, TestAppData, donor) assert.Equal(t, expectedError, err) @@ -646,7 +698,13 @@ func TestShareCodeSenderSendCertificateProviderPromptWhenEmailErrors(t *testing. GetAny(ctx). Return(nil, expectedError) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, nil, certificateProviderStore) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + certificateProviderStore: certificateProviderStore, + } err := sender.SendCertificateProviderPrompt(ctx, TestAppData, donor) assert.Equal(t, expectedError, errors.Unwrap(err)) @@ -660,7 +718,11 @@ func TestShareCodeSenderSendCertificateProviderPromptWhenShareCodeStoreErrors(t Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + } err := sender.SendCertificateProviderPrompt(ctx, TestAppData, &donordata.Provided{}) assert.Equal(t, expectedError, errors.Unwrap(err)) @@ -744,6 +806,23 @@ func TestShareCodeSenderSendAttorneys(t *testing.T) { ctx := context.Background() + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(ctx, scheduled.Event{ + At: testNow.AddDate(0, 3, 1), + Action: scheduled.ActionRemindAttorneyToComplete, + TargetLpaKey: dynamo.LpaKey("lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("donor")), + LpaUID: "lpa-uid", + }, scheduled.Event{ + At: lpa.ExpiresAt().AddDate(0, -3, 1), + Action: scheduled.ActionRemindAttorneyToComplete, + TargetLpaKey: dynamo.LpaKey("lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("donor")), + LpaUID: "lpa-uid", + }). + Return(nil) + shareCodeStore := newMockShareCodeStore(t) shareCodeStore.EXPECT(). Put(ctx, actor.TypeTrustCorporation, testRandomString, sharecodedata.Link{LpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("donor")), LpaKey: dynamo.LpaKey("lpa"), ActorUID: trustCorporationUID, IsTrustCorporation: true}). @@ -889,7 +968,15 @@ func TestShareCodeSenderSendAttorneys(t *testing.T) { }). Return(nil) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, eventClient, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + eventClient: eventClient, + scheduledStore: scheduledStore, + now: testNowFn, + } err := sender.SendAttorneys(ctx, TestAppData, lpa) assert.Nil(t, err) @@ -923,6 +1010,12 @@ func TestShareCodeSenderSendAttorneysTrustCorporationsNoEmail(t *testing.T) { } ctx := context.Background() + + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + shareCodeStore := newMockShareCodeStore(t) shareCodeStore.EXPECT(). Put(ctx, actor.TypeTrustCorporation, testRandomString, sharecodedata.Link{ @@ -972,7 +1065,14 @@ func TestShareCodeSenderSendAttorneysTrustCorporationsNoEmail(t *testing.T) { }). Return(nil) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, eventClient, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + scheduledStore: scheduledStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + eventClient: eventClient, + now: testNowFn, + } err := sender.SendAttorneys(ctx, TestAppData, donor) assert.Nil(t, err) @@ -1028,6 +1128,11 @@ func TestShareCodeSenderSendAttorneysWithTestCode(t *testing.T) { t.Run(name, func(t *testing.T) { ctx := context.Background() + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + shareCodeStore := newMockShareCodeStore(t) shareCodeStore.EXPECT(). Put(ctx, actor.TypeAttorney, tc.expectedTestCode, sharecodedata.Link{ @@ -1082,7 +1187,15 @@ func TestShareCodeSenderSendAttorneysWithTestCode(t *testing.T) { }). Return(nil) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, eventClient, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + scheduledStore: scheduledStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + eventClient: eventClient, + now: testNowFn, + } if tc.useTestCode { sender.UseTestCode("abcdef123456") @@ -1126,6 +1239,11 @@ func TestShareCodeSenderSendAttorneysWhenEmailErrors(t *testing.T) { Return("Jan's") TestAppData.Localizer = localizer + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + shareCodeStore := newMockShareCodeStore(t) shareCodeStore.EXPECT(). Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything). @@ -1141,21 +1259,60 @@ func TestShareCodeSenderSendAttorneysWhenEmailErrors(t *testing.T) { SendAttorneyStarted(mock.Anything, mock.Anything). Return(nil) - sender := NewSender(shareCodeStore, notifyClient, "http://app", testRandomStringFn, eventClient, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: notifyClient, + appPublicURL: "http://app", + randomString: testRandomStringFn, + eventClient: eventClient, + scheduledStore: scheduledStore, + now: testNowFn, + } err := sender.SendAttorneys(ctx, TestAppData, donor) assert.Equal(t, expectedError, errors.Unwrap(err)) } +func TestShareCodeSenderSendAttorneysWhenScheduledStoreErrors(t *testing.T) { + ctx := context.Background() + + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(expectedError) + + sender := &Sender{ + randomString: testRandomStringFn, + scheduledStore: scheduledStore, + now: testNowFn, + } + err := sender.SendAttorneys(ctx, TestAppData, &lpadata.Lpa{ + Attorneys: lpadata.Attorneys{Attorneys: []lpadata.Attorney{{Email: "hey@example.com"}}}, + }) + + assert.Equal(t, expectedError, errors.Unwrap(err)) +} + func TestShareCodeSenderSendAttorneysWhenShareCodeStoreErrors(t *testing.T) { ctx := context.Background() + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + shareCodeStore := newMockShareCodeStore(t) shareCodeStore.EXPECT(). Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + scheduledStore: scheduledStore, + now: testNowFn, + } err := sender.SendAttorneys(ctx, TestAppData, &lpadata.Lpa{ Attorneys: lpadata.Attorneys{Attorneys: []lpadata.Attorney{{Email: "hey@example.com"}}}, }) @@ -1193,6 +1350,11 @@ func TestShareCodeSenderSendAttorneysWhenEventClientErrors(t *testing.T) { for name, lpa := range testcases { t.Run(name, func(t *testing.T) { + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + shareCodeStore := newMockShareCodeStore(t) shareCodeStore.EXPECT(). Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything). @@ -1203,7 +1365,14 @@ func TestShareCodeSenderSendAttorneysWhenEventClientErrors(t *testing.T) { SendAttorneyStarted(mock.Anything, mock.Anything). Return(expectedError) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, eventClient, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + eventClient: eventClient, + scheduledStore: scheduledStore, + now: testNowFn, + } err := sender.SendAttorneys(ctx, TestAppData, lpa) assert.Equal(t, expectedError, err) @@ -1338,7 +1507,12 @@ func TestSendVoucherAccessCode(t *testing.T) { }). Return(nil) - sender := NewSender(shareCodeStore, tc.notifyClient(provided), "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: tc.notifyClient(provided), + appPublicURL: "http://app", + randomString: testRandomStringFn, + } TestAppData.Localizer = tc.localizer() err := sender.SendVoucherAccessCode(ctx, provided, TestAppData) @@ -1356,7 +1530,11 @@ func TestSendVoucherAccessCodeWhenShareCodeStoreError(t *testing.T) { Put(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - sender := NewSender(shareCodeStore, nil, "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + appPublicURL: "http://app", + randomString: testRandomStringFn, + } err := sender.SendVoucherAccessCode(ctx, &donordata.Provided{ PK: dynamo.LpaKey("lpa"), @@ -1473,7 +1651,12 @@ func TestSendVoucherAccessCodeWhenNotifyClientError(t *testing.T) { TestAppData.Localizer = tc.localizer() - sender := NewSender(shareCodeStore, tc.notifyClient(), "http://app", testRandomStringFn, nil, nil) + sender := &Sender{ + shareCodeStore: shareCodeStore, + notifyClient: tc.notifyClient(), + appPublicURL: "http://app", + randomString: testRandomStringFn, + } err := sender.SendVoucherAccessCode(ctx, &donordata.Provided{ PK: dynamo.LpaKey("lpa"), diff --git a/internal/supporter/supporterpage/donor_access.go b/internal/supporter/supporterpage/donor_access.go index 698350b2ac..2beae783c6 100644 --- a/internal/supporter/supporterpage/donor_access.go +++ b/internal/supporter/supporterpage/donor_access.go @@ -102,7 +102,7 @@ func DonorAccess(logger Logger, tmpl template.Template, donorStore DonorStore, s return err } - if err := notifyClient.SendEmail(r.Context(), notify.ToDonor(donor), notify.DonorAccessEmail{ + if err := notifyClient.SendEmail(r.Context(), notify.ToDonorOnly(donor), notify.DonorAccessEmail{ SupporterFullName: member.FullName(), OrganisationName: organisation.Name, LpaType: localize.LowerFirst(appData.Localizer.T(donor.Type.String())),