From 122e78e032ff408c714e8d6364257a3e369d657e Mon Sep 17 00:00:00 2001 From: Joshua Hawxwell Date: Thu, 12 Dec 2024 14:13:38 +0000 Subject: [PATCH] Add scheduled events to remind certificate provider --- cmd/event-received/sirius_event_handler.go | 24 +- .../sirius_event_handler_test.go | 24 +- cmd/schedule-runner/main.go | 6 +- internal/actor/type.go | 1 + internal/app/app.go | 1 + .../mock_ScheduledStore_test.go | 98 ++++ .../certificateproviderpage/mock_test.go | 3 + .../provide_certificate.go | 28 +- .../provide_certificate_test.go | 182 ++++--- .../certificateproviderpage/register.go | 8 +- .../certificateproviderpage/register_test.go | 2 +- internal/certificateprovider/store.go | 6 +- internal/donor/donordata/provided.go | 7 +- internal/donor/donordata/provided_test.go | 4 +- internal/donor/donorpage/check_your_lpa.go | 20 +- .../donor/donorpage/check_your_lpa_test.go | 76 ++- .../donorpage/mock_ScheduledStore_test.go | 36 +- internal/donor/donorpage/register.go | 8 +- internal/donor/donorpage/sign_your_lpa.go | 20 +- .../donor/donorpage/sign_your_lpa_test.go | 50 +- internal/event/client.go | 5 + internal/event/client_test.go | 5 + internal/event/events.go | 8 + internal/event/events_test.go | 9 + internal/event/testdata/letter-requested.json | 31 ++ internal/lpastore/lpadata/correspondent.go | 7 + internal/lpastore/lpadata/lpa.go | 17 + internal/lpastore/lpadata/lpa_test.go | 22 + internal/lpastore/resolving_service.go | 21 + internal/lpastore/resolving_service_test.go | 180 ++++++- internal/notify/email.go | 48 ++ internal/notify/to.go | 7 +- internal/scheduled/action.go | 12 + internal/scheduled/mock_Bundle_test.go | 83 +++ .../mock_CertificateProviderStore_test.go | 99 ++++ internal/scheduled/mock_DynamoClient_test.go | 94 ++-- internal/scheduled/mock_EventClient_test.go | 84 +++ .../mock_LpaStoreResolvingService_test.go | 98 ++++ internal/scheduled/runner.go | 163 +++++- internal/scheduled/runner_test.go | 485 +++++++++++++++++- internal/scheduled/store.go | 18 +- internal/scheduled/store_test.go | 26 +- scripts/get_event_schemas.sh | 3 +- 43 files changed, 1910 insertions(+), 219 deletions(-) create mode 100644 internal/certificateprovider/certificateproviderpage/mock_ScheduledStore_test.go create mode 100644 internal/event/testdata/letter-requested.json create mode 100644 internal/scheduled/mock_Bundle_test.go create mode 100644 internal/scheduled/mock_CertificateProviderStore_test.go create mode 100644 internal/scheduled/mock_EventClient_test.go create mode 100644 internal/scheduled/mock_LpaStoreResolvingService_test.go diff --git a/cmd/event-received/sirius_event_handler.go b/cmd/event-received/sirius_event_handler.go index 7a66799c49..ba9f7033bf 100644 --- a/cmd/event-received/sirius_event_handler.go +++ b/cmd/event-received/sirius_event_handler.go @@ -16,6 +16,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore" "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" "github.com/ministryofjustice/opg-modernising-lpa/internal/pay" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" ) @@ -231,16 +232,27 @@ func handleDonorSubmissionCompleted(ctx context.Context, client dynamodbClient, lpaID := uuidString() donor := &donordata.Provided{ - PK: dynamo.LpaKey(lpaID), - SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), - LpaID: lpaID, - LpaUID: v.UID, - CreatedAt: now(), - Version: 1, + PK: dynamo.LpaKey(lpaID), + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), + LpaID: lpaID, + LpaUID: v.UID, + CreatedAt: now(), + Version: 1, + CertificateProviderInvitedAt: now(), } transaction := dynamo.NewTransaction(). Create(donor). + Create(scheduled.Event{ + PK: dynamo.ScheduledDayKey(donor.CertificateProviderInvitedAt.AddDate(0, 3, 0)), + SK: dynamo.ScheduledKey(donor.CertificateProviderInvitedAt.AddDate(0, 3, 0), int(scheduled.ActionRemindCertificateProviderToComplete)), + CreatedAt: now(), + At: donor.CertificateProviderInvitedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: donor.PK, + TargetLpaOwnerKey: donor.SK, + LpaUID: donor.LpaUID, + }). Create(dynamo.Keys{PK: dynamo.UIDKey(v.UID), SK: dynamo.MetadataKey("")}). Create(dynamo.Keys{PK: donor.PK, SK: dynamo.ReservedKey(dynamo.DonorKey)}) diff --git a/cmd/event-received/sirius_event_handler_test.go b/cmd/event-received/sirius_event_handler_test.go index 916808ee0e..e2c18c8839 100644 --- a/cmd/event-received/sirius_event_handler_test.go +++ b/cmd/event-received/sirius_event_handler_test.go @@ -20,6 +20,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/pay" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/stretchr/testify/assert" @@ -756,12 +757,23 @@ func TestHandleDonorSubmissionCompleted(t *testing.T) { WriteTransaction(ctx, &dynamo.Transaction{ Creates: []any{ &donordata.Provided{ - PK: dynamo.LpaKey(testUuidString), - SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), - LpaID: testUuidString, - LpaUID: "M-1111-2222-3333", - CreatedAt: testNow, - Version: 1, + PK: dynamo.LpaKey(testUuidString), + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), + LpaID: testUuidString, + LpaUID: "M-1111-2222-3333", + CreatedAt: testNow, + Version: 1, + CertificateProviderInvitedAt: testNow, + }, + scheduled.Event{ + PK: dynamo.ScheduledDayKey(testNow.AddDate(0, 3, 0)), + SK: dynamo.ScheduledKey(testNow.AddDate(0, 3, 0), int(scheduled.ActionRemindCertificateProviderToComplete)), + CreatedAt: testNow, + At: testNow.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: dynamo.LpaKey(testUuidString), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), + LpaUID: "M-1111-2222-3333", }, dynamo.Keys{PK: dynamo.UIDKey("M-1111-2222-3333"), SK: dynamo.MetadataKey("")}, dynamo.Keys{PK: dynamo.LpaKey(testUuidString), SK: dynamo.ReservedKey(dynamo.DonorKey)}, diff --git a/cmd/schedule-runner/main.go b/cmd/schedule-runner/main.go index 370c09608c..23b3de0ff8 100644 --- a/cmd/schedule-runner/main.go +++ b/cmd/schedule-runner/main.go @@ -13,6 +13,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/cloudwatch" + "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" "github.com/ministryofjustice/opg-modernising-lpa/internal/event" @@ -81,8 +82,9 @@ func handleRunSchedule(ctx context.Context) error { return err } - donorStore := donor.NewStore(dynamoClient, eventClient, logger, searchClient) scheduledStore := scheduled.NewStore(dynamoClient) + donorStore := donor.NewStore(dynamoClient, eventClient, logger, searchClient) + certificateProviderStore := certificateprovider.NewStore(dynamoClient) if Tag == "" { Tag = os.Getenv("TAG") @@ -91,7 +93,7 @@ func handleRunSchedule(ctx context.Context) error { client := cloudwatch.NewFromConfig(cfg) metricsClient := telemetry.NewMetricsClient(client, Tag) - runner := scheduled.NewRunner(logger, scheduledStore, donorStore, notifyClient, metricsClient, metricsEnabled) + runner := scheduled.NewRunner(logger, scheduledStore, donorStore, certificateProviderStore, notifyClient, bundle, metricsClient, metricsEnabled) if err = runner.Run(ctx); err != nil { logger.Error("runner error", slog.Any("err", err)) diff --git a/internal/actor/type.go b/internal/actor/type.go index 2983802e12..a0edcdde02 100644 --- a/internal/actor/type.go +++ b/internal/actor/type.go @@ -15,4 +15,5 @@ const ( TypeTrustCorporation // trustCorporation TypeReplacementTrustCorporation // replacementTrustCorporation TypeVoucher // voucher + TypeCorrespondent // correspondent ) diff --git a/internal/app/app.go b/internal/app/app.go index cd7372ea01..ebee9b034e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -213,6 +213,7 @@ func App( lpaStoreResolvingService, donorStore, eventClient, + scheduledStore, appPublicURL, ) diff --git a/internal/certificateprovider/certificateproviderpage/mock_ScheduledStore_test.go b/internal/certificateprovider/certificateproviderpage/mock_ScheduledStore_test.go new file mode 100644 index 0000000000..267cf2be57 --- /dev/null +++ b/internal/certificateprovider/certificateproviderpage/mock_ScheduledStore_test.go @@ -0,0 +1,98 @@ +// Code generated by mockery. DO NOT EDIT. + +package certificateproviderpage + +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/certificateprovider/certificateproviderpage/mock_test.go b/internal/certificateprovider/certificateproviderpage/mock_test.go index 3348869335..065ac89770 100644 --- a/internal/certificateprovider/certificateproviderpage/mock_test.go +++ b/internal/certificateprovider/certificateproviderpage/mock_test.go @@ -2,6 +2,7 @@ package certificateproviderpage import ( "errors" + "time" "github.com/ministryofjustice/opg-modernising-lpa/internal/appcontext" "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" @@ -22,4 +23,6 @@ var ( LpaID: "lpa-id", Lang: localize.En, } + testNow = time.Now() + testNowFn = func() time.Time { return testNow } ) diff --git a/internal/certificateprovider/certificateproviderpage/provide_certificate.go b/internal/certificateprovider/certificateproviderpage/provide_certificate.go index 8616212d09..bc692155ff 100644 --- a/internal/certificateprovider/certificateproviderpage/provide_certificate.go +++ b/internal/certificateprovider/certificateproviderpage/provide_certificate.go @@ -13,6 +13,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/task" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" ) @@ -31,13 +32,10 @@ func ProvideCertificate( notifyClient NotifyClient, shareCodeSender ShareCodeSender, lpaStoreClient LpaStoreClient, + scheduledStore ScheduledStore, now func() time.Time, ) Handler { return func(appData appcontext.Data, w http.ResponseWriter, r *http.Request, certificateProvider *certificateproviderdata.Provided, lpa *lpadata.Lpa) error { - if !lpa.SignedForDonor() { - return certificateprovider.PathTaskList.Redirect(w, r, appData, lpa.LpaID) - } - if !certificateProvider.SignedAt.IsZero() { return certificateprovider.PathCertificateProvided.Redirect(w, r, appData, lpa.LpaID) } @@ -65,7 +63,7 @@ func ProvideCertificate( if lpa.CertificateProvider.SignedAt == nil || lpa.CertificateProvider.SignedAt.IsZero() { if err := lpaStoreClient.SendCertificateProvider(r.Context(), certificateProvider, lpa); err != nil { - return err + return fmt.Errorf("error sending certificate provider to lpa-store: %w", err) } } else { certificateProvider.SignedAt = *lpa.CertificateProvider.SignedAt @@ -82,11 +80,27 @@ func ProvideCertificate( } if err := shareCodeSender.SendAttorneys(r.Context(), appData, lpa); err != nil { - return err + return fmt.Errorf("error sending sharecode to attorneys: %w", err) + } + + if !certificateProvider.Tasks.ConfirmYourDetails.IsCompleted() { + if err := scheduledStore.Create(r.Context(), scheduled.Event{ + At: certificateProvider.SignedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToConfirmIdentity, + TargetLpaKey: certificateProvider.PK, + LpaUID: lpa.LpaUID, + }, scheduled.Event{ + At: lpa.SignedAt.AddDate(0, 21, 0), + Action: scheduled.ActionRemindCertificateProviderToConfirmIdentity, + TargetLpaKey: certificateProvider.PK, + LpaUID: lpa.LpaUID, + }); err != nil { + return fmt.Errorf("error scheduling certificate provider prompt: %w", err) + } } if err := certificateProviderStore.Put(r.Context(), certificateProvider); err != nil { - return err + return fmt.Errorf("error updating certificate provider: %w", err) } return certificateprovider.PathCertificateProvided.Redirect(w, r, appData, certificateProvider.LpaID) diff --git a/internal/certificateprovider/certificateproviderpage/provide_certificate_test.go b/internal/certificateprovider/certificateproviderpage/provide_certificate_test.go index 41011530ab..f1316513c9 100644 --- a/internal/certificateprovider/certificateproviderpage/provide_certificate_test.go +++ b/internal/certificateprovider/certificateproviderpage/provide_certificate_test.go @@ -15,6 +15,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" + scheduled "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" "github.com/stretchr/testify/assert" @@ -37,32 +38,20 @@ func TestGetProvideCertificate(t *testing.T) { }). Return(nil) - err := ProvideCertificate(template.Execute, nil, nil, nil, nil, time.Now)(testAppData, w, r, &certificateproviderdata.Provided{}, donor) + err := ProvideCertificate(template.Execute, nil, nil, nil, nil, nil, time.Now)(testAppData, w, r, &certificateproviderdata.Provided{}, donor) resp := w.Result() assert.Nil(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) } -func TestGetProvideCertificateRedirectsToStartOnLpaNotSubmitted(t *testing.T) { - w := httptest.NewRecorder() - r, _ := http.NewRequest(http.MethodGet, "/", nil) - - err := ProvideCertificate(nil, nil, nil, nil, nil, nil)(testAppData, w, r, nil, &lpadata.Lpa{LpaID: "lpa-id"}) - resp := w.Result() - - assert.Nil(t, err) - assert.Equal(t, http.StatusFound, resp.StatusCode) - assert.Equal(t, certificateprovider.PathTaskList.Format("lpa-id"), resp.Header.Get("Location")) -} - func TestGetProvideCertificateWhenAlreadyAgreed(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "/", nil) donor := &lpadata.Lpa{LpaID: "lpa-id", SignedAt: time.Now(), WitnessedByCertificateProviderAt: time.Now()} - err := ProvideCertificate(nil, nil, nil, nil, nil, time.Now)(testAppData, w, r, &certificateproviderdata.Provided{ + err := ProvideCertificate(nil, nil, nil, nil, nil, nil, time.Now)(testAppData, w, r, &certificateproviderdata.Provided{ SignedAt: time.Now(), }, donor) resp := w.Result() @@ -82,12 +71,10 @@ func TestPostProvideCertificate(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) - now := time.Now() - lpa := &lpadata.Lpa{ LpaUID: "lpa-uid", - SignedAt: now, - WitnessedByCertificateProviderAt: now, + SignedAt: testNow.AddDate(0, -1, 0), + WitnessedByCertificateProviderAt: testNow, CertificateProvider: lpadata.CertificateProvider{ Email: "cp@example.org", FirstNames: "a", @@ -99,7 +86,7 @@ func TestPostProvideCertificate(t *testing.T) { certificateProvider := &certificateproviderdata.Provided{ LpaID: "lpa-id", - SignedAt: now, + SignedAt: testNow, Tasks: certificateproviderdata.Tasks{ ProvideTheCertificate: task.StateCompleted, }, @@ -123,7 +110,7 @@ func TestPostProvideCertificate(t *testing.T) { T("property-and-affairs"). Return("the translated term") localizer.EXPECT(). - FormatDateTime(now). + FormatDateTime(testNow). Return("the formatted date") testAppData.Localizer = localizer @@ -149,7 +136,22 @@ func TestPostProvideCertificate(t *testing.T) { SendCertificateProvider(r.Context(), certificateProvider, lpa). Return(nil) - err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, func() time.Time { return now })(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(r.Context(), scheduled.Event{ + At: testNow.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToConfirmIdentity, + TargetLpaKey: certificateProvider.PK, + LpaUID: lpa.LpaUID, + }, scheduled.Event{ + At: lpa.SignedAt.AddDate(0, 21, 0), + Action: scheduled.ActionRemindCertificateProviderToConfirmIdentity, + TargetLpaKey: certificateProvider.PK, + LpaUID: lpa.LpaUID, + }). + 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) resp := w.Result() assert.Nil(t, err) @@ -167,13 +169,12 @@ func TestPostProvideCertificateWhenSignedInLpaStore(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) - now := time.Now() - signedAt := time.Now().Add(-5 * time.Minute) + signedAt := testNow.Add(-5 * time.Minute) lpa := &lpadata.Lpa{ LpaUID: "lpa-uid", - SignedAt: now, - WitnessedByCertificateProviderAt: now, + SignedAt: testNow.AddDate(0, -1, 0), + WitnessedByCertificateProviderAt: testNow, CertificateProvider: lpadata.CertificateProvider{ Email: "cp@example.org", FirstNames: "a", @@ -231,7 +232,22 @@ func TestPostProvideCertificateWhenSignedInLpaStore(t *testing.T) { SendAttorneys(r.Context(), testAppData, lpa). Return(nil) - err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, nil, func() time.Time { return now })(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com", ContactLanguagePreference: localize.En}, lpa) + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(r.Context(), scheduled.Event{ + At: signedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToConfirmIdentity, + TargetLpaKey: certificateProvider.PK, + LpaUID: lpa.LpaUID, + }, scheduled.Event{ + At: lpa.SignedAt.AddDate(0, 21, 0), + Action: scheduled.ActionRemindCertificateProviderToConfirmIdentity, + TargetLpaKey: certificateProvider.PK, + LpaUID: lpa.LpaUID, + }). + 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) resp := w.Result() assert.Nil(t, err) @@ -249,12 +265,10 @@ func TestPostProvideCertificateWhenCannotSubmit(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) - now := time.Now() - lpa := &lpadata.Lpa{ LpaUID: "lpa-uid", - SignedAt: now, - WitnessedByCertificateProviderAt: now, + SignedAt: testNow, + WitnessedByCertificateProviderAt: testNow, CertificateProvider: lpadata.CertificateProvider{ Email: "cp@example.org", FirstNames: "a", @@ -264,7 +278,7 @@ func TestPostProvideCertificateWhenCannotSubmit(t *testing.T) { Type: lpadata.LpaTypePropertyAndAffairs, } - err := ProvideCertificate(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)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id", Email: "a@example.com"}, lpa) resp := w.Result() assert.Nil(t, err) @@ -272,7 +286,7 @@ func TestPostProvideCertificateWhenCannotSubmit(t *testing.T) { assert.Equal(t, certificateprovider.PathConfirmDontWantToBeCertificateProvider.Format("lpa-id"), resp.Header.Get("Location")) } -func TestPostProvideCertificateOnStoreError(t *testing.T) { +func TestPostProvideCertificateWhenScheduledStoreErrors(t *testing.T) { form := url.Values{ "agree-to-statement": {"1"}, "submittable": {"can-submit"}, @@ -282,7 +296,65 @@ func TestPostProvideCertificateOnStoreError(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) - now := time.Now() + 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) + + 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) + assert.ErrorIs(t, err, expectedError) +} + +func TestPostProvideCertificateOnStoreError(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) lpaStoreClient := newMockLpaStoreClient(t) lpaStoreClient.EXPECT(). @@ -320,11 +392,13 @@ func TestPostProvideCertificateOnStoreError(t *testing.T) { SendAttorneys(r.Context(), testAppData, mock.Anything). Return(nil) - err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, func() time.Time { return now })(testAppData, w, r, &certificateproviderdata.Provided{}, &lpadata.Lpa{SignedAt: now, WitnessedByCertificateProviderAt: now}) - resp := w.Result() + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(nil) - assert.Equal(t, expectedError, err) - assert.Equal(t, http.StatusOK, resp.StatusCode) + err := ProvideCertificate(nil, certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{}, &lpadata.Lpa{SignedAt: testNow, WitnessedByCertificateProviderAt: testNow}) + assert.ErrorIs(t, err, expectedError) } func TestPostProvideCertificateWhenLpaStoreClientError(t *testing.T) { @@ -337,12 +411,10 @@ func TestPostProvideCertificateWhenLpaStoreClientError(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) - now := time.Now() - donor := &lpadata.Lpa{ LpaUID: "lpa-uid", - SignedAt: now, - WitnessedByCertificateProviderAt: now, + SignedAt: testNow, + WitnessedByCertificateProviderAt: testNow, CertificateProvider: lpadata.CertificateProvider{ Email: "cp@example.org", FirstNames: "a", @@ -357,8 +429,8 @@ func TestPostProvideCertificateWhenLpaStoreClientError(t *testing.T) { SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - err := ProvideCertificate(nil, nil, nil, nil, lpaStoreClient, func() time.Time { return now })(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, donor) - assert.Equal(t, expectedError, err) + err := ProvideCertificate(nil, nil, nil, nil, lpaStoreClient, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, donor) + assert.ErrorIs(t, err, expectedError) } func TestPostProvideCertificateOnNotifyClientError(t *testing.T) { @@ -371,8 +443,6 @@ func TestPostProvideCertificateOnNotifyClientError(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) - now := time.Now() - localizer := newMockLocalizer(t) localizer.EXPECT(). Possessive(mock.Anything). @@ -399,9 +469,9 @@ func TestPostProvideCertificateOnNotifyClientError(t *testing.T) { SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). Return(nil) - err := ProvideCertificate(nil, nil, notifyClient, nil, lpaStoreClient, func() time.Time { return now })(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, &lpadata.Lpa{ - SignedAt: now, - WitnessedByCertificateProviderAt: now, + err := ProvideCertificate(nil, nil, notifyClient, nil, lpaStoreClient, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, &lpadata.Lpa{ + SignedAt: testNow, + WitnessedByCertificateProviderAt: testNow, CertificateProvider: lpadata.CertificateProvider{ Email: "cp@example.org", FirstNames: "a", @@ -426,8 +496,6 @@ func TestPostProvideCertificateWhenShareCodeSenderErrors(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) - now := time.Now() - localizer := newMockLocalizer(t) localizer.EXPECT(). Possessive("c"). @@ -439,7 +507,7 @@ func TestPostProvideCertificateWhenShareCodeSenderErrors(t *testing.T) { T("property-and-affairs"). Return("the translated term") localizer.EXPECT(). - FormatDateTime(now). + FormatDateTime(testNow). Return("the formatted date") testAppData.Localizer = localizer @@ -459,13 +527,13 @@ func TestPostProvideCertificateWhenShareCodeSenderErrors(t *testing.T) { SendCertificateProvider(mock.Anything, mock.Anything, mock.Anything). Return(nil) - err := ProvideCertificate(nil, nil, notifyClient, shareCodeSender, lpaStoreClient, func() time.Time { return now })(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, &lpadata.Lpa{ - SignedAt: now, - WitnessedByCertificateProviderAt: now, + err := ProvideCertificate(nil, nil, notifyClient, shareCodeSender, lpaStoreClient, nil, testNowFn)(testAppData, w, r, &certificateproviderdata.Provided{LpaID: "lpa-id"}, &lpadata.Lpa{ + SignedAt: testNow, + WitnessedByCertificateProviderAt: testNow, Donor: lpadata.Donor{FirstNames: "c", LastName: "d"}, Type: lpadata.LpaTypePropertyAndAffairs, }) - assert.Equal(t, expectedError, err) + assert.ErrorIs(t, err, expectedError) } func TestPostProvideCertificateWhenValidationErrors(t *testing.T) { @@ -478,8 +546,6 @@ func TestPostProvideCertificateWhenValidationErrors(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) - now := time.Now() - template := newMockTemplate(t) template.EXPECT(). Execute(w, mock.MatchedBy(func(data *provideCertificateData) bool { @@ -487,7 +553,7 @@ func TestPostProvideCertificateWhenValidationErrors(t *testing.T) { })). Return(nil) - err := ProvideCertificate(template.Execute, nil, nil, nil, nil, func() time.Time { return now })(testAppData, w, r, &certificateproviderdata.Provided{}, &lpadata.Lpa{SignedAt: now, WitnessedByCertificateProviderAt: now}) + err := ProvideCertificate(template.Execute, 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 5c7e97880f..f2023ba0ae 100644 --- a/internal/certificateprovider/certificateproviderpage/register.go +++ b/internal/certificateprovider/certificateproviderpage/register.go @@ -24,6 +24,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/page" "github.com/ministryofjustice/opg-modernising-lpa/internal/place" "github.com/ministryofjustice/opg-modernising-lpa/internal/random" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sesh" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode/sharecodedata" ) @@ -110,6 +111,10 @@ type DonorStore interface { Put(ctx context.Context, donor *donordata.Provided) error } +type ScheduledStore interface { + Create(ctx context.Context, rows ...scheduled.Event) error +} + func Register( rootMux *http.ServeMux, logger Logger, @@ -127,6 +132,7 @@ func Register( lpaStoreResolvingService LpaStoreResolvingService, donorStore DonorStore, eventClient EventClient, + scheduledStore ScheduledStore, appPublicURL string, ) { handleRoot := makeHandle(rootMux, errorHandler) @@ -181,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, time.Now)) + ProvideCertificate(tmpls.Get("provide_certificate.gohtml"), certificateProviderStore, notifyClient, shareCodeSender, lpaStoreClient, scheduledStore, time.Now)) handleCertificateProvider(certificateprovider.PathCertificateProvided, page.None, Guidance(tmpls.Get("certificate_provided.gohtml"))) handleCertificateProvider(certificateprovider.PathConfirmDontWantToBeCertificateProvider, page.CanGoBack, diff --git a/internal/certificateprovider/certificateproviderpage/register_test.go b/internal/certificateprovider/certificateproviderpage/register_test.go index 29c384893e..56629f72e3 100644 --- a/internal/certificateprovider/certificateproviderpage/register_test.go +++ b/internal/certificateprovider/certificateproviderpage/register_test.go @@ -25,7 +25,7 @@ import ( func TestRegister(t *testing.T) { mux := http.NewServeMux() - Register(mux, &slog.Logger{}, template.Templates{}, template.Templates{}, nil, &onelogin.Client{}, nil, nil, nil, &place.Client{}, ¬ify.Client{}, nil, &mockDashboardStore{}, &lpastore.Client{}, &lpastore.ResolvingService{}, &mockDonorStore{}, &mockEventClient{}, "publicURL") + Register(mux, &slog.Logger{}, template.Templates{}, template.Templates{}, nil, &onelogin.Client{}, nil, nil, nil, &place.Client{}, ¬ify.Client{}, nil, &mockDashboardStore{}, &lpastore.Client{}, &lpastore.ResolvingService{}, &mockDonorStore{}, &mockEventClient{}, &mockScheduledStore{}, "publicURL") assert.Implements(t, (*http.Handler)(nil), mux) } diff --git a/internal/certificateprovider/store.go b/internal/certificateprovider/store.go index f38ee08ed8..99676f71dd 100644 --- a/internal/certificateprovider/store.go +++ b/internal/certificateprovider/store.go @@ -93,8 +93,12 @@ func (s *Store) GetAny(ctx context.Context) (*certificateproviderdata.Provided, return nil, errors.New("certificateProviderStore.GetAny requires LpaID") } + return s.One(ctx, dynamo.LpaKey(data.LpaID)) +} + +func (s *Store) One(ctx context.Context, pk dynamo.LpaKeyType) (*certificateproviderdata.Provided, error) { var certificateProvider certificateproviderdata.Provided - err = s.dynamoClient.OneByPartialSK(ctx, dynamo.LpaKey(data.LpaID), dynamo.CertificateProviderKey(""), &certificateProvider) + err := s.dynamoClient.OneByPartialSK(ctx, pk, dynamo.CertificateProviderKey(""), &certificateProvider) return &certificateProvider, err } diff --git a/internal/donor/donordata/provided.go b/internal/donor/donordata/provided.go index 3db592eba4..d02978a528 100644 --- a/internal/donor/donordata/provided.go +++ b/internal/donor/donordata/provided.go @@ -165,6 +165,10 @@ type Provided struct { // for, if applying for a repeat of an LPA with reference prefixed M. CostOfRepeatApplication pay.CostOfRepeatApplication + // CertificateProviderInvitedAt records when the invite is sent to the + // certificate provider to act. + CertificateProviderInvitedAt time.Time + HasSentApplicationUpdatedEvent bool `hash:"-"` } @@ -218,7 +222,8 @@ func (c toCheck) HashInclude(field string, _ any) (bool, error) { "WantVoucher", "Voucher", "FailedVouchAttempts", - "CostOfRepeatApplication": + "CostOfRepeatApplication", + "CertificateProviderInvitedAt": return false, nil } diff --git a/internal/donor/donordata/provided_test.go b/internal/donor/donordata/provided_test.go index f4dd08e764..cf9b42494f 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 = 0x2556115d1e0785cd + const modified uint64 = 0x31b4f1c0f521f1ab // 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: 0xf5086332ffe66299, + 0: 0xeae937046f784453, } for version, initial := range testcases { diff --git a/internal/donor/donorpage/check_your_lpa.go b/internal/donor/donorpage/check_your_lpa.go index c02d161c73..ea851de505 100644 --- a/internal/donor/donorpage/check_your_lpa.go +++ b/internal/donor/donorpage/check_your_lpa.go @@ -3,6 +3,7 @@ package donorpage import ( "context" "errors" + "fmt" "net/http" "net/url" "time" @@ -15,6 +16,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" "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" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" @@ -33,7 +35,9 @@ type checkYourLpaNotifier struct { notifyClient NotifyClient shareCodeSender ShareCodeSender certificateProviderStore CertificateProviderStore + scheduledStore ScheduledStore appPublicURL string + now func() time.Time } func (n *checkYourLpaNotifier) Notify(ctx context.Context, appData appcontext.Data, donor *donordata.Provided, wasCompleted bool) error { @@ -66,6 +70,18 @@ func (n *checkYourLpaNotifier) sendPaperNotification(ctx context.Context, appDat func (n *checkYourLpaNotifier) sendOnlineNotification(ctx context.Context, appData appcontext.Data, donor *donordata.Provided, wasCompleted bool) error { if !wasCompleted { + donor.CertificateProviderInvitedAt = n.now() + + if err := n.scheduledStore.Create(ctx, scheduled.Event{ + At: donor.CertificateProviderInvitedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: donor.PK, + TargetLpaOwnerKey: donor.SK, + LpaUID: donor.LpaUID, + }); err != nil { + return fmt.Errorf("could not schedule certificate provider prompt: %w", err) + } + return n.shareCodeSender.SendCertificateProviderInvite(ctx, appData, sharecode.CertificateProviderInvite{ LpaKey: donor.PK, LpaOwnerKey: donor.SK, @@ -101,12 +117,14 @@ func (n *checkYourLpaNotifier) sendOnlineNotification(ctx context.Context, appDa return n.notifyClient.SendActorSMS(ctx, notify.ToProvidedCertificateProvider(certificateProvider, donor.CertificateProvider), donor.LpaUID, sms) } -func CheckYourLpa(tmpl template.Template, donorStore DonorStore, shareCodeSender ShareCodeSender, notifyClient NotifyClient, certificateProviderStore CertificateProviderStore, now func() time.Time, appPublicURL string) Handler { +func CheckYourLpa(tmpl template.Template, donorStore DonorStore, shareCodeSender ShareCodeSender, notifyClient NotifyClient, certificateProviderStore CertificateProviderStore, scheduledStore ScheduledStore, now func() time.Time, appPublicURL string) Handler { notifier := &checkYourLpaNotifier{ notifyClient: notifyClient, shareCodeSender: shareCodeSender, certificateProviderStore: certificateProviderStore, appPublicURL: appPublicURL, + now: now, + scheduledStore: scheduledStore, } return func(appData appcontext.Data, w http.ResponseWriter, r *http.Request, provided *donordata.Provided) error { diff --git a/internal/donor/donorpage/check_your_lpa_test.go b/internal/donor/donorpage/check_your_lpa_test.go index 7d99412749..961cfd3a0c 100644 --- a/internal/donor/donorpage/check_your_lpa_test.go +++ b/internal/donor/donorpage/check_your_lpa_test.go @@ -15,6 +15,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" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" @@ -36,7 +37,7 @@ func TestGetCheckYourLpa(t *testing.T) { }). Return(nil) - err := CheckYourLpa(template.Execute, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{}) + err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{}) resp := w.Result() assert.Nil(t, err) @@ -64,7 +65,7 @@ func TestGetCheckYourLpaFromStore(t *testing.T) { }). Return(nil) - err := CheckYourLpa(template.Execute, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) + err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) resp := w.Result() assert.Nil(t, err) @@ -100,7 +101,7 @@ func TestPostCheckYourLpaWhenNotChanged(t *testing.T) { }). Return(nil) - err := CheckYourLpa(template.Execute, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) + err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) resp := w.Result() assert.Nil(t, err) @@ -132,11 +133,12 @@ func TestPostCheckYourLpaDigitalCertificateProviderOnFirstCheck(t *testing.T) { } updatedDonor := &donordata.Provided{ - LpaID: "lpa-id", - Hash: 5, - CheckedAt: testNow, - Tasks: donordata.Tasks{CheckYourLpa: task.StateCompleted}, - CertificateProvider: donordata.CertificateProvider{UID: uid, FirstNames: "John", LastName: "Smith", Email: "john@example.com", CarryOutBy: lpadata.ChannelOnline}, + LpaID: "lpa-id", + Hash: 5, + CheckedAt: testNow, + Tasks: donordata.Tasks{CheckYourLpa: task.StateCompleted}, + CertificateProvider: donordata.CertificateProvider{UID: uid, FirstNames: "John", LastName: "Smith", Email: "john@example.com", CarryOutBy: lpadata.ChannelOnline}, + CertificateProviderInvitedAt: testNow, } updatedDonor.UpdateCheckedHash() @@ -148,12 +150,23 @@ func TestPostCheckYourLpaDigitalCertificateProviderOnFirstCheck(t *testing.T) { }, notify.ToCertificateProvider(provided.CertificateProvider)). Return(nil) + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(r.Context(), scheduled.Event{ + At: updatedDonor.CertificateProviderInvitedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: updatedDonor.PK, + TargetLpaOwnerKey: updatedDonor.SK, + LpaUID: updatedDonor.LpaUID, + }). + Return(nil) + donorStore := newMockDonorStore(t) donorStore.EXPECT(). Put(r.Context(), updatedDonor). Return(nil) - err := CheckYourLpa(nil, donorStore, shareCodeSender, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) + err := CheckYourLpa(nil, donorStore, shareCodeSender, nil, nil, scheduledStore, testNowFn, "http://example.org")(testAppData, w, r, provided) resp := w.Result() assert.Nil(t, err) @@ -245,7 +258,7 @@ func TestPostCheckYourLpaDigitalCertificateProviderOnSubsequentChecks(t *testing GetAny(r.Context()). Return(certificateProvider, nil) - err := CheckYourLpa(nil, donorStore, nil, notifyClient, certificateProviderStore, testNowFn, "http://example.org")(testAppData, w, r, provided) + err := CheckYourLpa(nil, donorStore, nil, notifyClient, certificateProviderStore, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) resp := w.Result() assert.Nil(t, err) @@ -269,7 +282,7 @@ func TestPostCheckYourLpaDigitalCertificateProviderOnSubsequentChecksCertificate GetAny(r.Context()). Return(nil, expectedError) - err := CheckYourLpa(nil, nil, nil, nil, certificateProviderStore, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{ + err := CheckYourLpa(nil, nil, nil, nil, certificateProviderStore, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{ LpaID: "lpa-id", Hash: 5, Type: lpadata.LpaTypePropertyAndAffairs, @@ -336,7 +349,7 @@ func TestPostCheckYourLpaPaperCertificateProviderOnFirstCheck(t *testing.T) { }). Return(nil) - err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) + err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) resp := w.Result() assert.Nil(t, err) @@ -380,7 +393,7 @@ func TestPostCheckYourLpaPaperCertificateProviderOnSubsequentCheck(t *testing.T) }). Return(nil) - err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) + err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, provided) resp := w.Result() assert.Nil(t, err) @@ -418,13 +431,37 @@ func TestPostCheckYourLpaWhenStoreErrors(t *testing.T) { Put(r.Context(), mock.Anything). Return(expectedError) - err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) + err := CheckYourLpa(nil, donorStore, nil, notifyClient, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) resp := w.Result() assert.Equal(t, expectedError, err) assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestPostCheckYourLpaWhenScheduledStoreErrors(t *testing.T) { + form := url.Values{ + "checked-and-happy": {"1"}, + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + r.Header.Add("Content-Type", page.FormUrlEncoded) + + donor := &donordata.Provided{ + LpaID: "lpa-id", + Hash: 5, + Tasks: donordata.Tasks{CheckYourLpa: task.StateInProgress}, + } + + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything). + Return(expectedError) + + err := CheckYourLpa(nil, nil, nil, nil, nil, scheduledStore, testNowFn, "http://example.org")(testAppData, w, r, donor) + assert.ErrorIs(t, err, expectedError) +} + func TestPostCheckYourLpaWhenShareCodeSenderErrors(t *testing.T) { form := url.Values{ "checked-and-happy": {"1"}, @@ -440,12 +477,17 @@ func TestPostCheckYourLpaWhenShareCodeSenderErrors(t *testing.T) { Tasks: donordata.Tasks{CheckYourLpa: task.StateInProgress}, } + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything). + Return(nil) + shareCodeSender := newMockShareCodeSender(t) shareCodeSender.EXPECT(). SendCertificateProviderInvite(r.Context(), testAppData, mock.Anything, mock.Anything). Return(expectedError) - err := CheckYourLpa(nil, nil, shareCodeSender, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, donor) + err := CheckYourLpa(nil, nil, shareCodeSender, nil, nil, scheduledStore, testNowFn, "http://example.org")(testAppData, w, r, donor) resp := w.Result() assert.Equal(t, expectedError, err) @@ -473,7 +515,7 @@ func TestPostCheckYourLpaWhenNotifyClientErrors(t *testing.T) { SendActorSMS(mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(expectedError) - err := CheckYourLpa(nil, nil, nil, notifyClient, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{Hash: 5, CertificateProvider: donordata.CertificateProvider{CarryOutBy: lpadata.ChannelPaper}}) + err := CheckYourLpa(nil, nil, nil, notifyClient, nil, nil, testNowFn, "http://example.org")(testAppData, w, r, &donordata.Provided{Hash: 5, CertificateProvider: donordata.CertificateProvider{CarryOutBy: lpadata.ChannelPaper}}) resp := w.Result() assert.Equal(t, expectedError, err) @@ -496,7 +538,7 @@ func TestPostCheckYourLpaWhenValidationErrors(t *testing.T) { })). Return(nil) - err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, "http://example.org")(testAppData, w, r, &donordata.Provided{Hash: 5}) + err := CheckYourLpa(template.Execute, nil, nil, nil, nil, nil, nil, "http://example.org")(testAppData, w, r, &donordata.Provided{Hash: 5}) resp := w.Result() assert.Nil(t, err) diff --git a/internal/donor/donorpage/mock_ScheduledStore_test.go b/internal/donor/donorpage/mock_ScheduledStore_test.go index cbf5610744..73e23fafad 100644 --- a/internal/donor/donorpage/mock_ScheduledStore_test.go +++ b/internal/donor/donorpage/mock_ScheduledStore_test.go @@ -22,17 +22,24 @@ func (_m *mockScheduledStore) EXPECT() *mockScheduledStore_Expecter { return &mockScheduledStore_Expecter{mock: &_m.Mock} } -// Create provides a mock function with given fields: ctx, row -func (_m *mockScheduledStore) Create(ctx context.Context, row scheduled.Event) error { - ret := _m.Called(ctx, row) +// 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, row) + if rf, ok := ret.Get(0).(func(context.Context, ...scheduled.Event) error); ok { + r0 = rf(ctx, rows...) } else { r0 = ret.Error(0) } @@ -47,14 +54,21 @@ type mockScheduledStore_Create_Call struct { // Create is a helper method to define mock.On call // - ctx context.Context -// - row scheduled.Event -func (_e *mockScheduledStore_Expecter) Create(ctx interface{}, row interface{}) *mockScheduledStore_Create_Call { - return &mockScheduledStore_Create_Call{Call: _e.mock.On("Create", ctx, row)} +// - 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, row scheduled.Event)) *mockScheduledStore_Create_Call { +func (_c *mockScheduledStore_Create_Call) Run(run func(ctx context.Context, rows ...scheduled.Event)) *mockScheduledStore_Create_Call { _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(scheduled.Event)) + 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 } @@ -64,7 +78,7 @@ func (_c *mockScheduledStore_Create_Call) Return(_a0 error) *mockScheduledStore_ return _c } -func (_c *mockScheduledStore_Create_Call) RunAndReturn(run func(context.Context, scheduled.Event) error) *mockScheduledStore_Create_Call { +func (_c *mockScheduledStore_Create_Call) RunAndReturn(run func(context.Context, ...scheduled.Event) error) *mockScheduledStore_Create_Call { _c.Call.Return(run) return _c } diff --git a/internal/donor/donorpage/register.go b/internal/donor/donorpage/register.go index 0ffb18b496..69150fbc99 100644 --- a/internal/donor/donorpage/register.go +++ b/internal/donor/donorpage/register.go @@ -165,7 +165,7 @@ type ShareCodeStore interface { } type ScheduledStore interface { - Create(ctx context.Context, row scheduled.Event) error + Create(ctx context.Context, rows ...scheduled.Event) error } type ErrorHandler func(http.ResponseWriter, *http.Request, error) @@ -356,7 +356,7 @@ func Register( handleWithDonor(donor.PathConfirmYourCertificateProviderIsNotRelated, page.CanGoBack, ConfirmYourCertificateProviderIsNotRelated(tmpls.Get("confirm_your_certificate_provider_is_not_related.gohtml"), donorStore, time.Now)) handleWithDonor(donor.PathCheckYourLpa, page.CanGoBack, - CheckYourLpa(tmpls.Get("check_your_lpa.gohtml"), donorStore, shareCodeSender, notifyClient, certificateProviderStore, time.Now, appPublicURL)) + CheckYourLpa(tmpls.Get("check_your_lpa.gohtml"), donorStore, shareCodeSender, notifyClient, certificateProviderStore, scheduledStore, time.Now, appPublicURL)) handleWithDonor(donor.PathLpaDetailsSaved, page.CanGoBack, LpaDetailsSaved(tmpls.Get("lpa_details_saved.gohtml"))) @@ -440,9 +440,9 @@ func Register( handleWithDonor(donor.PathLpaYourLegalRightsAndResponsibilities, page.CanGoBack, Guidance(tmpls.Get("your_legal_rights_and_responsibilities.gohtml"))) handleWithDonor(donor.PathSignYourLpa, page.CanGoBack, - SignYourLpa(tmpls.Get("sign_your_lpa.gohtml"), donorStore, time.Now)) + SignYourLpa(tmpls.Get("sign_your_lpa.gohtml"), donorStore, scheduledStore, time.Now)) handleWithDonor(donor.PathSignTheLpaOnBehalf, page.CanGoBack, - SignYourLpa(tmpls.Get("sign_the_lpa_on_behalf.gohtml"), donorStore, time.Now)) + SignYourLpa(tmpls.Get("sign_the_lpa_on_behalf.gohtml"), donorStore, scheduledStore, time.Now)) handleWithDonor(donor.PathWitnessingYourSignature, page.None, WitnessingYourSignature(tmpls.Get("witnessing_your_signature.gohtml"), witnessCodeSender, donorStore)) handleWithDonor(donor.PathWitnessingAsIndependentWitness, page.None, diff --git a/internal/donor/donorpage/sign_your_lpa.go b/internal/donor/donorpage/sign_your_lpa.go index ba767cfb7b..61b310c2de 100644 --- a/internal/donor/donorpage/sign_your_lpa.go +++ b/internal/donor/donorpage/sign_your_lpa.go @@ -1,6 +1,7 @@ package donorpage import ( + "fmt" "net/http" "time" @@ -8,6 +9,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/appcontext" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" ) @@ -25,7 +27,7 @@ const ( WantToApplyForLpa = "want-to-apply" ) -func SignYourLpa(tmpl template.Template, donorStore DonorStore, now func() time.Time) Handler { +func SignYourLpa(tmpl template.Template, donorStore DonorStore, scheduledStore ScheduledStore, now func() time.Time) Handler { return func(appData appcontext.Data, w http.ResponseWriter, r *http.Request, provided *donordata.Provided) error { if !provided.SignedAt.IsZero() { return donor.PathWitnessingYourSignature.Redirect(w, r, appData, provided) @@ -51,6 +53,22 @@ func SignYourLpa(tmpl template.Template, donorStore DonorStore, now func() time. provided.WantToSignLpa = data.Form.WantToSign provided.SignedAt = now() + if err := scheduledStore.Create(r.Context(), scheduled.Event{ + At: provided.SignedAt.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: provided.PK, + TargetLpaOwnerKey: provided.SK, + LpaUID: provided.LpaUID, + }, scheduled.Event{ + At: provided.SignedAt.AddDate(0, 21, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + TargetLpaKey: provided.PK, + TargetLpaOwnerKey: provided.SK, + LpaUID: provided.LpaUID, + }); err != nil { + return fmt.Errorf("could not schedule certificate provider prompt: %w", err) + } + if err := donorStore.Put(r.Context(), provided); err != nil { return err } diff --git a/internal/donor/donorpage/sign_your_lpa_test.go b/internal/donor/donorpage/sign_your_lpa_test.go index e1e8dd947d..5764655756 100644 --- a/internal/donor/donorpage/sign_your_lpa_test.go +++ b/internal/donor/donorpage/sign_your_lpa_test.go @@ -12,6 +12,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -32,7 +33,7 @@ func TestGetSignYourLpa(t *testing.T) { }). Return(nil) - err := SignYourLpa(template.Execute, nil, testNowFn)(testAppData, w, r, &donordata.Provided{}) + err := SignYourLpa(template.Execute, nil, nil, testNowFn)(testAppData, w, r, &donordata.Provided{}) resp := w.Result() assert.Nil(t, err) @@ -43,7 +44,7 @@ func TestGetSignYourLpaWhenSigned(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "/", nil) - err := SignYourLpa(nil, nil, testNowFn)(testAppData, w, r, &donordata.Provided{ + err := SignYourLpa(nil, nil, nil, testNowFn)(testAppData, w, r, &donordata.Provided{ LpaID: "lpa-id", IdentityUserData: identity.UserData{Status: identity.StatusConfirmed}, SignedAt: time.Now(), @@ -78,7 +79,7 @@ func TestGetSignYourLpaFromStore(t *testing.T) { }). Return(nil) - err := SignYourLpa(template.Execute, nil, testNowFn)(testAppData, w, r, donor) + err := SignYourLpa(template.Execute, nil, nil, testNowFn)(testAppData, w, r, donor) resp := w.Result() assert.Nil(t, err) @@ -105,7 +106,18 @@ func TestPostSignYourLpa(t *testing.T) { }). Return(nil) - err := SignYourLpa(nil, donorStore, testNowFn)(testAppData, w, r, &donordata.Provided{LpaID: "lpa-id", IdentityUserData: identity.UserData{Status: identity.StatusConfirmed}}) + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(r.Context(), scheduled.Event{ + At: testNow.AddDate(0, 3, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + }, scheduled.Event{ + At: testNow.AddDate(0, 21, 0), + Action: scheduled.ActionRemindCertificateProviderToComplete, + }). + Return(nil) + + err := SignYourLpa(nil, donorStore, scheduledStore, testNowFn)(testAppData, w, r, &donordata.Provided{LpaID: "lpa-id", IdentityUserData: identity.UserData{Status: identity.StatusConfirmed}}) resp := w.Result() assert.Nil(t, err) @@ -113,7 +125,25 @@ func TestPostSignYourLpa(t *testing.T) { assert.Equal(t, donor.PathWitnessingYourSignature.Format("lpa-id"), resp.Header.Get("Location")) } -func TestPostSignYourLpaWhenStoreErrors(t *testing.T) { +func TestPostSignYourLpaWhenScheduledStoreErrors(t *testing.T) { + form := url.Values{ + "sign-lpa": {"want-to-sign", "want-to-apply"}, + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + r.Header.Add("Content-Type", page.FormUrlEncoded) + + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(expectedError) + + err := SignYourLpa(nil, nil, scheduledStore, testNowFn)(testAppData, w, r, &donordata.Provided{}) + assert.ErrorIs(t, err, expectedError) +} + +func TestPostSignYourLpaWhenDonorStoreErrors(t *testing.T) { form := url.Values{ "sign-lpa": {"want-to-sign", "want-to-apply"}, } @@ -122,13 +152,17 @@ func TestPostSignYourLpaWhenStoreErrors(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) r.Header.Add("Content-Type", page.FormUrlEncoded) + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Create(mock.Anything, mock.Anything, mock.Anything). + Return(nil) + donorStore := newMockDonorStore(t) donorStore.EXPECT(). Put(r.Context(), mock.Anything). Return(expectedError) - err := SignYourLpa(nil, donorStore, testNowFn)(testAppData, w, r, &donordata.Provided{}) - + err := SignYourLpa(nil, donorStore, scheduledStore, testNowFn)(testAppData, w, r, &donordata.Provided{}) assert.Equal(t, expectedError, err) } @@ -148,7 +182,7 @@ func TestPostSignYourLpaWhenValidationErrors(t *testing.T) { })). Return(nil) - err := SignYourLpa(template.Execute, nil, testNowFn)(testAppData, w, r, &donordata.Provided{}) + err := SignYourLpa(template.Execute, nil, nil, testNowFn)(testAppData, w, r, &donordata.Provided{}) resp := w.Result() assert.Nil(t, err) diff --git a/internal/event/client.go b/internal/event/client.go index 27325f5e96..754d673c0b 100644 --- a/internal/event/client.go +++ b/internal/event/client.go @@ -26,6 +26,7 @@ var events = map[any]string{ (*IdentityCheckMismatched)(nil): "identity-check-mismatched", (*CorrespondentUpdated)(nil): "correspondent-updated", (*LpaAccessGranted)(nil): "lpa-access-granted", + (*LetterRequested)(nil): "letter-requested", } type eventbridgeClient interface { @@ -92,6 +93,10 @@ func (c *Client) SendLpaAccessGranted(ctx context.Context, event LpaAccessGrante return send[LpaAccessGranted](ctx, c, event) } +func (c *Client) SendLetterRequested(ctx context.Context, event LetterRequested) error { + return send[LetterRequested](ctx, c, event) +} + func send[T any](ctx context.Context, c *Client, detail any) error { detailType, ok := events[(*T)(nil)] if !ok { diff --git a/internal/event/client_test.go b/internal/event/client_test.go index a092a3d4d1..7e93d5f94d 100644 --- a/internal/event/client_test.go +++ b/internal/event/client_test.go @@ -83,6 +83,11 @@ func TestClientSendEvents(t *testing.T) { return func(client *Client) error { return client.SendLpaAccessGranted(ctx, event) }, event }, + "letter-requested": func() (func(*Client) error, any) { + event := LetterRequested{UID: "a"} + + return func(client *Client) error { return client.SendLetterRequested(ctx, event) }, event + }, } for eventName, setup := range testcases { diff --git a/internal/event/events.go b/internal/event/events.go index cd277150ae..8bdfd1c6fc 100644 --- a/internal/event/events.go +++ b/internal/event/events.go @@ -3,6 +3,7 @@ package event import ( "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/date" "github.com/ministryofjustice/opg-modernising-lpa/internal/place" @@ -107,3 +108,10 @@ type LpaAccessGrantedActor struct { ActorUID string `json:"actorUid"` SubjectID string `json:"subjectId"` } + +type LetterRequested struct { + UID string `json:"uid"` + LetterType string `json:"letterType"` + ActorType actor.Type `json:"actorType"` + ActorUID actoruid.UID `json:"actorUID"` +} diff --git a/internal/event/events_test.go b/internal/event/events_test.go index b0619daa32..cfaeb1ff17 100644 --- a/internal/event/events_test.go +++ b/internal/event/events_test.go @@ -7,6 +7,7 @@ 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/date" "github.com/ministryofjustice/opg-modernising-lpa/internal/place" @@ -177,6 +178,14 @@ var eventTests = map[string]map[string]any{ }}, }, }, + "letter-requested": { + "valid": LetterRequested{ + UID: "M-1111-2222-3333", + LetterType: "INFORM_DONOR_CERTIFICATE_PROVIDER_HAS_NOT_ACTED", + ActorType: actor.TypeDonor, + ActorUID: actoruid.New(), + }, + }, } func TestEventSchema(t *testing.T) { diff --git a/internal/event/testdata/letter-requested.json b/internal/event/testdata/letter-requested.json new file mode 100644 index 0000000000..ad0bc8dc47 --- /dev/null +++ b/internal/event/testdata/letter-requested.json @@ -0,0 +1,31 @@ +{ + "$id": "https://opg.service.justice.gov.uk/opg.poas.sirius/letter-requested.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "opg.poas.sirius/letter-requested", + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "The UID of the LPA", + "pattern": "^M(-[A-Z0-9]{4}){3}$" + }, + "letterType": { + "description": "The type of letter to send", + "enum": [ + "ADVISE_CERTIFICATE_PROVIDER_TO_SIGN_OR_OPT_OUT", + "INFORM_DONOR_CERTIFICATE_PROVIDER_HAS_NOT_ACTED" + ] + }, + "actorType": { + "type": "string", + "description": "The type of actor to send the letter to", + "enum": ["donor", "correspondent", "certificateProvider", "attorney", "replacementAttorney", "trustCorporation", "replacementTrustCorporation"] + }, + "actorUID": { + "type": "string", + "description": "The UID of the actor to send the letter to", + "pattern": "^([a-z0-9]{8}-)([a-z0-9]{4}-){3}([a-z0-9]{12})$" + } + }, + "required": ["uid", "letterType", "actorType", "actorUID"] +} diff --git a/internal/lpastore/lpadata/correspondent.go b/internal/lpastore/lpadata/correspondent.go index d2b5f9dccd..82601594fe 100644 --- a/internal/lpastore/lpadata/correspondent.go +++ b/internal/lpastore/lpadata/correspondent.go @@ -1,10 +1,17 @@ package lpadata +import ( + "github.com/ministryofjustice/opg-modernising-lpa/internal/actor/actoruid" + "github.com/ministryofjustice/opg-modernising-lpa/internal/place" +) + type Correspondent struct { + UID actoruid.UID FirstNames string LastName string Email string Phone string + Address place.Address } func (c Correspondent) FullName() string { diff --git a/internal/lpastore/lpadata/lpa.go b/internal/lpastore/lpadata/lpa.go index 782cdd557c..acbaec9c3e 100644 --- a/internal/lpastore/lpadata/lpa.go +++ b/internal/lpastore/lpadata/lpa.go @@ -76,6 +76,10 @@ type Lpa struct { // Voucher is set using the data provided by the donor for online // applications, but is not set for paper applications. Voucher Voucher + + // CertificateProviderInvitedAt is when the certificate provider's share + // code is first sent, it is only set with the resolving service. + CertificateProviderInvitedAt time.Time } // SignedForDonor returns true if the Lpa has been signed and witnessed for the donor. @@ -218,3 +222,16 @@ func (l *Lpa) Attorney(uid actoruid.UID) (string, string, actor.Type) { return "", "", actor.TypeNone } + +// ExpiresAt gives the date the LPA expires. +func (l *Lpa) ExpiresAt() time.Time { + if l.Submitted && l.Donor.IdentityCheck != nil && !l.Donor.IdentityCheck.CheckedAt.IsZero() { + return l.SignedAt.AddDate(2, 0, 0) + } + + if !l.SignedAt.IsZero() { + return l.SignedAt.AddDate(0, 6, 0) + } + + return time.Time{} +} diff --git a/internal/lpastore/lpadata/lpa_test.go b/internal/lpastore/lpadata/lpa_test.go index 8eee8bfc5b..1139757216 100644 --- a/internal/lpastore/lpadata/lpa_test.go +++ b/internal/lpastore/lpadata/lpa_test.go @@ -291,3 +291,25 @@ func TestAttorney(t *testing.T) { }) } } + +func TestExpiresAt(t *testing.T) { + t.Run("when not signed", func(t *testing.T) { + provided := &Lpa{} + assert.True(t, provided.ExpiresAt().IsZero()) + }) + + t.Run("when signed", func(t *testing.T) { + provided := &Lpa{SignedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)} + assert.Equal(t, time.Date(2000, time.July, 1, 0, 0, 0, 0, time.UTC), provided.ExpiresAt()) + }) + + t.Run("when submitted", func(t *testing.T) { + provided := &Lpa{ + Donor: Donor{IdentityCheck: &IdentityCheck{CheckedAt: time.Now()}}, + Submitted: true, + SignedAt: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC), + WitnessedByCertificateProviderAt: time.Date(2000, time.March, 1, 0, 0, 0, 0, time.UTC), + } + assert.Equal(t, time.Date(2002, time.January, 1, 0, 0, 0, 0, time.UTC), provided.ExpiresAt()) + }) +} diff --git a/internal/lpastore/resolving_service.go b/internal/lpastore/resolving_service.go index 6b23fccbd2..8b6499e7ed 100644 --- a/internal/lpastore/resolving_service.go +++ b/internal/lpastore/resolving_service.go @@ -49,6 +49,17 @@ func (s *ResolvingService) Get(ctx context.Context) (*lpadata.Lpa, error) { return s.merge(lpa, donor), nil } +func (s *ResolvingService) Resolve(ctx context.Context, donor *donordata.Provided) (*lpadata.Lpa, error) { + lpa, err := s.client.Lpa(ctx, donor.LpaUID) + if errors.Is(err, ErrNotFound) { + lpa = LpaFromDonorProvided(donor) + } else if err != nil { + return nil, err + } + + return s.merge(lpa, donor), nil +} + func (s *ResolvingService) ResolveList(ctx context.Context, donors []*donordata.Provided) ([]*lpadata.Lpa, error) { lpaUIDs := make([]string, len(donors)) for i, donor := range donors { @@ -83,6 +94,8 @@ func (s *ResolvingService) merge(lpa *lpadata.Lpa, donor *donordata.Provided) *l lpa.LpaID = donor.LpaID lpa.LpaUID = donor.LpaUID lpa.StatutoryWaitingPeriodAt = donor.StatutoryWaitingPeriodAt + lpa.CertificateProviderInvitedAt = donor.CertificateProviderInvitedAt + if donor.SK.Equals(dynamo.DonorKey("PAPER")) { lpa.Drafted = true lpa.Submitted = true @@ -96,8 +109,16 @@ func (s *ResolvingService) merge(lpa *lpadata.Lpa, donor *donordata.Provided) *l lpa.Submitted = !donor.SubmittedAt.IsZero() lpa.Paid = donor.Tasks.PayForLpa.IsCompleted() _, lpa.IsOrganisationDonor = donor.SK.Organisation() + lpa.Donor.Channel = lpadata.ChannelOnline lpa.Donor.Mobile = donor.Donor.Mobile + if lpa.Donor.IdentityCheck == nil && donor.IdentityUserData.Status.IsConfirmed() { + lpa.Donor.IdentityCheck = &lpadata.IdentityCheck{ + CheckedAt: donor.IdentityUserData.CheckedAt, + Type: "one-login", + } + } + lpa.Correspondent = lpadata.Correspondent{ FirstNames: donor.Correspondent.FirstNames, LastName: donor.Correspondent.LastName, diff --git a/internal/lpastore/resolving_service_test.go b/internal/lpastore/resolving_service_test.go index 7690fa05b6..6a87b54762 100644 --- a/internal/lpastore/resolving_service_test.go +++ b/internal/lpastore/resolving_service_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/mock" ) +var testNow = time.Now() + func TestResolvingServiceGet(t *testing.T) { actorUID := actoruid.New() @@ -40,7 +42,7 @@ func TestResolvingServiceGet(t *testing.T) { }, IdentityUserData: identity.UserData{ Status: identity.StatusConfirmed, - CheckedAt: time.Now(), + CheckedAt: testNow, }, Correspondent: donordata.Correspondent{Email: "x"}, AuthorisedSignatory: donordata.AuthorisedSignatory{UID: actorUID, FirstNames: "A", LastName: "S"}, @@ -65,7 +67,13 @@ func TestResolvingServiceGet(t *testing.T) { FirstNames: "Paul", Relationship: lpadata.Personally, }, - Donor: lpadata.Donor{Channel: lpadata.ChannelOnline}, + Donor: lpadata.Donor{ + Channel: lpadata.ChannelOnline, + IdentityCheck: &lpadata.IdentityCheck{ + Type: "one-login", + CheckedAt: testNow, + }, + }, Correspondent: lpadata.Correspondent{Email: "x"}, Voucher: lpadata.Voucher{Email: "y"}, }, @@ -285,6 +293,163 @@ func TestResolvingServiceGetWhenLpaClientErrors(t *testing.T) { assert.Equal(t, expectedError, err) } +func TestResolvingServiceResolve(t *testing.T) { + testcases := map[string]struct { + donor *donordata.Provided + resolved *lpadata.Lpa + error error + expected *lpadata.Lpa + }{ + "online with all true": { + donor: &donordata.Provided{ + SK: dynamo.LpaOwnerKey(dynamo.OrganisationKey("S")), + LpaID: "1", + LpaUID: "M-1111", + SubmittedAt: time.Now(), + CertificateProvider: donordata.CertificateProvider{ + FirstNames: "Barry", + Relationship: lpadata.Personally, + }, + Tasks: donordata.Tasks{ + CheckYourLpa: task.StateCompleted, + PayForLpa: task.PaymentStateCompleted, + }, + IdentityUserData: identity.UserData{ + Status: identity.StatusConfirmed, + CheckedAt: testNow, + }, + }, + resolved: &lpadata.Lpa{ + LpaID: "1", + LpaUID: "M-1111", + CertificateProvider: lpadata.CertificateProvider{ + FirstNames: "Paul", + }, + }, + expected: &lpadata.Lpa{ + LpaOwnerKey: dynamo.LpaOwnerKey(dynamo.OrganisationKey("S")), + LpaID: "1", + LpaUID: "M-1111", + Drafted: true, + Submitted: true, + Paid: true, + IsOrganisationDonor: true, + CertificateProvider: lpadata.CertificateProvider{ + FirstNames: "Paul", + Relationship: lpadata.Personally, + }, + Donor: lpadata.Donor{ + Channel: lpadata.ChannelOnline, + IdentityCheck: &lpadata.IdentityCheck{ + CheckedAt: testNow, + Type: "one-login", + }, + }, + }, + }, + "online with no lpastore record": { + donor: &donordata.Provided{ + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("S")), + LpaUID: "M-1111", + CertificateProvider: donordata.CertificateProvider{ + FirstNames: "John", + Relationship: lpadata.Personally, + }, + Donor: donordata.Donor{Channel: lpadata.ChannelOnline}, + Attorneys: donordata.Attorneys{ + Attorneys: []donordata.Attorney{{FirstNames: "a"}}, + TrustCorporation: donordata.TrustCorporation{Name: "b"}, + }, + ReplacementAttorneys: donordata.Attorneys{ + Attorneys: []donordata.Attorney{{FirstNames: "c"}}, + TrustCorporation: donordata.TrustCorporation{Name: "d"}, + }, + }, + error: ErrNotFound, + expected: &lpadata.Lpa{ + LpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("S")), + LpaUID: "M-1111", + CertificateProvider: lpadata.CertificateProvider{ + FirstNames: "John", + Relationship: lpadata.Personally, + }, + Donor: lpadata.Donor{Channel: lpadata.ChannelOnline}, + Attorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{FirstNames: "a"}}, + TrustCorporation: lpadata.TrustCorporation{Name: "b"}, + }, + ReplacementAttorneys: lpadata.Attorneys{ + Attorneys: []lpadata.Attorney{{FirstNames: "c"}}, + TrustCorporation: lpadata.TrustCorporation{Name: "d"}, + }, + }, + }, + "online with all false": { + donor: &donordata.Provided{ + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("S")), + LpaID: "1", + LpaUID: "M-1111", + }, + resolved: &lpadata.Lpa{LpaID: "1", LpaUID: "M-1111"}, + expected: &lpadata.Lpa{ + LpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("S")), + LpaID: "1", + LpaUID: "M-1111", + Donor: lpadata.Donor{Channel: lpadata.ChannelOnline}, + }, + }, + "paper": { + donor: &donordata.Provided{ + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), + LpaID: "1", + LpaUID: "M-1111", + }, + resolved: &lpadata.Lpa{LpaID: "1", LpaUID: "M-1111"}, + expected: &lpadata.Lpa{ + LpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("PAPER")), + LpaID: "1", + LpaUID: "M-1111", + Drafted: true, + Submitted: true, + Paid: true, + CertificateProvider: lpadata.CertificateProvider{ + Relationship: lpadata.Professionally, + }, + Donor: lpadata.Donor{Channel: lpadata.ChannelPaper}, + }, + }, + } + + for name, tc := range testcases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + + lpaClient := newMockLpaClient(t) + lpaClient.EXPECT(). + Lpa(ctx, tc.donor.LpaUID). + Return(tc.resolved, tc.error) + + service := NewResolvingService(nil, lpaClient) + lpa, err := service.Resolve(ctx, tc.donor) + + assert.Nil(t, err) + assert.Equal(t, tc.expected, lpa) + }) + } +} + +func TestResolvingServiceResolveWhenLpaClientErrors(t *testing.T) { + lpaClient := newMockLpaClient(t) + lpaClient.EXPECT(). + Lpa(mock.Anything, mock.Anything). + Return(nil, expectedError) + + service := NewResolvingService(nil, lpaClient) + _, err := service.Resolve(context.Background(), &donordata.Provided{LpaUID: "lpa-uid"}) + + assert.Equal(t, expectedError, err) +} + func TestResolvingServiceResolveList(t *testing.T) { testcases := map[string]struct { donors []*donordata.Provided @@ -308,7 +473,8 @@ func TestResolvingServiceResolveList(t *testing.T) { PayForLpa: task.PaymentStateCompleted, }, IdentityUserData: identity.UserData{ - Status: identity.StatusConfirmed, + Status: identity.StatusConfirmed, + CheckedAt: testNow, }, }}, uids: []string{"M-1111"}, @@ -331,7 +497,13 @@ func TestResolvingServiceResolveList(t *testing.T) { FirstNames: "Paul", Relationship: lpadata.Personally, }, - Donor: lpadata.Donor{Channel: lpadata.ChannelOnline}, + Donor: lpadata.Donor{ + Channel: lpadata.ChannelOnline, + IdentityCheck: &lpadata.IdentityCheck{ + CheckedAt: testNow, + Type: "one-login", + }, + }, }}, }, "online with no lpastore record": { diff --git a/internal/notify/email.go b/internal/notify/email.go index 318f7db5ed..7a64eaa5e7 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -404,3 +404,51 @@ func (e VoucherInformedTheyAreNoLongerNeededToVouchEmail) emailID(isProduction b return "00ad14c6-f6df-4d7f-ae44-d7e27f6a9187" } + +type AdviseCertificateProviderToSignOrOptOutEmail struct { + DonorFullName string + LpaType string + CertificateProviderFullName string + InvitedDate string + DeadlineDate string +} + +func (e AdviseCertificateProviderToSignOrOptOutEmail) emailID(isProduction bool, lang localize.Lang) string { + if isProduction { + if lang.IsCy() { + return "TODO" + } + + return "TODO" + } + + if lang.IsCy() { + return "22a19484-cd44-4476-a7b6-7826af5932ae" + } + + return "d9b3e36a-5814-4e6b-84b1-baf763c49220" +} + +type InformDonorCertificateProviderHasNotActedEmail struct { + CertificateProviderFullName string + LpaType string + DonorFullName string + InvitedDate string + DeadlineDate string +} + +func (e InformDonorCertificateProviderHasNotActedEmail) emailID(isProduction bool, lang localize.Lang) string { + if isProduction { + if lang.IsCy() { + return "TODO" + } + + return "TODO" + } + + if lang.IsCy() { + return "4fc578f0-5cce-4082-a926-957aebb824bd" + } + + return "0f7cbfed-1ffa-43d7-92c0-8d162aadc0ea" +} diff --git a/internal/notify/to.go b/internal/notify/to.go index 304b506dae..08aa7c685a 100644 --- a/internal/notify/to.go +++ b/internal/notify/to.go @@ -88,10 +88,15 @@ func ToProvidedCertificateProvider(provided *certificateproviderdata.Provided, c } func ToLpaCertificateProvider(provided *certificateproviderdata.Provided, lpa *lpadata.Lpa) To { + lang := lpa.CertificateProvider.ContactLanguagePreference + if provided != nil && !provided.ContactLanguagePreference.Empty() { + lang = provided.ContactLanguagePreference + } + return to{ mobile: lpa.CertificateProvider.Phone, email: lpa.CertificateProvider.Email, - lang: provided.ContactLanguagePreference, + lang: lang, } } diff --git a/internal/scheduled/action.go b/internal/scheduled/action.go index b58f0c1f98..9a77766c1d 100644 --- a/internal/scheduled/action.go +++ b/internal/scheduled/action.go @@ -8,4 +8,16 @@ const ( // their LPA, and if so remove their identity data and notify them of the // change. ActionExpireDonorIdentity Action = iota + 1 + + // ActionRemindCertificateProviderToComplete will check that the target + // certificate provider has neither provided the certificate nor opted-out, + // and if so send them a reminder email or letter, plus another to the donor + // (or correspondent, if set). + ActionRemindCertificateProviderToComplete + + // ActionRemindCertificateProviderToConfirmIdentity will check that the target + // certificate provider has not confirmed their identity, and if so send them + // a reminder email or letter, plus another to the donor (or correspondent, if + // set). + ActionRemindCertificateProviderToConfirmIdentity ) diff --git a/internal/scheduled/mock_Bundle_test.go b/internal/scheduled/mock_Bundle_test.go new file mode 100644 index 0000000000..4c2c4a80f7 --- /dev/null +++ b/internal/scheduled/mock_Bundle_test.go @@ -0,0 +1,83 @@ +// Code generated by mockery. DO NOT EDIT. + +package scheduled + +import ( + localize "github.com/ministryofjustice/opg-modernising-lpa/internal/localize" + mock "github.com/stretchr/testify/mock" +) + +// mockBundle is an autogenerated mock type for the Bundle type +type mockBundle struct { + mock.Mock +} + +type mockBundle_Expecter struct { + mock *mock.Mock +} + +func (_m *mockBundle) EXPECT() *mockBundle_Expecter { + return &mockBundle_Expecter{mock: &_m.Mock} +} + +// For provides a mock function with given fields: lang +func (_m *mockBundle) For(lang localize.Lang) *localize.Localizer { + ret := _m.Called(lang) + + if len(ret) == 0 { + panic("no return value specified for For") + } + + var r0 *localize.Localizer + if rf, ok := ret.Get(0).(func(localize.Lang) *localize.Localizer); ok { + r0 = rf(lang) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*localize.Localizer) + } + } + + return r0 +} + +// mockBundle_For_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'For' +type mockBundle_For_Call struct { + *mock.Call +} + +// For is a helper method to define mock.On call +// - lang localize.Lang +func (_e *mockBundle_Expecter) For(lang interface{}) *mockBundle_For_Call { + return &mockBundle_For_Call{Call: _e.mock.On("For", lang)} +} + +func (_c *mockBundle_For_Call) Run(run func(lang localize.Lang)) *mockBundle_For_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(localize.Lang)) + }) + return _c +} + +func (_c *mockBundle_For_Call) Return(_a0 *localize.Localizer) *mockBundle_For_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockBundle_For_Call) RunAndReturn(run func(localize.Lang) *localize.Localizer) *mockBundle_For_Call { + _c.Call.Return(run) + return _c +} + +// newMockBundle creates a new instance of mockBundle. 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 newMockBundle(t interface { + mock.TestingT + Cleanup(func()) +}) *mockBundle { + mock := &mockBundle{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_CertificateProviderStore_test.go b/internal/scheduled/mock_CertificateProviderStore_test.go new file mode 100644 index 0000000000..458d06840e --- /dev/null +++ b/internal/scheduled/mock_CertificateProviderStore_test.go @@ -0,0 +1,99 @@ +// Code generated by mockery. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + certificateproviderdata "github.com/ministryofjustice/opg-modernising-lpa/internal/certificateprovider/certificateproviderdata" + + dynamo "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + + mock "github.com/stretchr/testify/mock" +) + +// mockCertificateProviderStore is an autogenerated mock type for the CertificateProviderStore type +type mockCertificateProviderStore struct { + mock.Mock +} + +type mockCertificateProviderStore_Expecter struct { + mock *mock.Mock +} + +func (_m *mockCertificateProviderStore) EXPECT() *mockCertificateProviderStore_Expecter { + return &mockCertificateProviderStore_Expecter{mock: &_m.Mock} +} + +// One provides a mock function with given fields: ctx, pk +func (_m *mockCertificateProviderStore) One(ctx context.Context, pk dynamo.LpaKeyType) (*certificateproviderdata.Provided, error) { + ret := _m.Called(ctx, pk) + + if len(ret) == 0 { + panic("no return value specified for One") + } + + var r0 *certificateproviderdata.Provided + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, dynamo.LpaKeyType) (*certificateproviderdata.Provided, error)); ok { + return rf(ctx, pk) + } + if rf, ok := ret.Get(0).(func(context.Context, dynamo.LpaKeyType) *certificateproviderdata.Provided); ok { + r0 = rf(ctx, pk) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*certificateproviderdata.Provided) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, dynamo.LpaKeyType) error); ok { + r1 = rf(ctx, pk) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockCertificateProviderStore_One_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'One' +type mockCertificateProviderStore_One_Call struct { + *mock.Call +} + +// One is a helper method to define mock.On call +// - ctx context.Context +// - pk dynamo.LpaKeyType +func (_e *mockCertificateProviderStore_Expecter) One(ctx interface{}, pk interface{}) *mockCertificateProviderStore_One_Call { + return &mockCertificateProviderStore_One_Call{Call: _e.mock.On("One", ctx, pk)} +} + +func (_c *mockCertificateProviderStore_One_Call) Run(run func(ctx context.Context, pk dynamo.LpaKeyType)) *mockCertificateProviderStore_One_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(dynamo.LpaKeyType)) + }) + return _c +} + +func (_c *mockCertificateProviderStore_One_Call) Return(_a0 *certificateproviderdata.Provided, _a1 error) *mockCertificateProviderStore_One_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockCertificateProviderStore_One_Call) RunAndReturn(run func(context.Context, dynamo.LpaKeyType) (*certificateproviderdata.Provided, error)) *mockCertificateProviderStore_One_Call { + _c.Call.Return(run) + return _c +} + +// newMockCertificateProviderStore creates a new instance of mockCertificateProviderStore. 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 newMockCertificateProviderStore(t interface { + mock.TestingT + Cleanup(func()) +}) *mockCertificateProviderStore { + mock := &mockCertificateProviderStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_DynamoClient_test.go b/internal/scheduled/mock_DynamoClient_test.go index 68b55cee05..c8d34ebb4b 100644 --- a/internal/scheduled/mock_DynamoClient_test.go +++ b/internal/scheduled/mock_DynamoClient_test.go @@ -119,53 +119,6 @@ func (_c *mockDynamoClient_AnyByPK_Call) RunAndReturn(run func(context.Context, return _c } -// Create provides a mock function with given fields: ctx, v -func (_m *mockDynamoClient) Create(ctx context.Context, v interface{}) error { - ret := _m.Called(ctx, v) - - if len(ret) == 0 { - panic("no return value specified for Create") - } - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, interface{}) error); ok { - r0 = rf(ctx, v) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// mockDynamoClient_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' -type mockDynamoClient_Create_Call struct { - *mock.Call -} - -// Create is a helper method to define mock.On call -// - ctx context.Context -// - v interface{} -func (_e *mockDynamoClient_Expecter) Create(ctx interface{}, v interface{}) *mockDynamoClient_Create_Call { - return &mockDynamoClient_Create_Call{Call: _e.mock.On("Create", ctx, v)} -} - -func (_c *mockDynamoClient_Create_Call) Run(run func(ctx context.Context, v interface{})) *mockDynamoClient_Create_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(interface{})) - }) - return _c -} - -func (_c *mockDynamoClient_Create_Call) Return(_a0 error) *mockDynamoClient_Create_Call { - _c.Call.Return(_a0) - return _c -} - -func (_c *mockDynamoClient_Create_Call) RunAndReturn(run func(context.Context, interface{}) error) *mockDynamoClient_Create_Call { - _c.Call.Return(run) - return _c -} - // DeleteKeys provides a mock function with given fields: ctx, keys func (_m *mockDynamoClient) DeleteKeys(ctx context.Context, keys []dynamo.Keys) error { ret := _m.Called(ctx, keys) @@ -261,6 +214,53 @@ func (_c *mockDynamoClient_Move_Call) RunAndReturn(run func(context.Context, dyn return _c } +// WriteTransaction provides a mock function with given fields: ctx, transaction +func (_m *mockDynamoClient) WriteTransaction(ctx context.Context, transaction *dynamo.Transaction) error { + ret := _m.Called(ctx, transaction) + + if len(ret) == 0 { + panic("no return value specified for WriteTransaction") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *dynamo.Transaction) error); ok { + r0 = rf(ctx, transaction) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockDynamoClient_WriteTransaction_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WriteTransaction' +type mockDynamoClient_WriteTransaction_Call struct { + *mock.Call +} + +// WriteTransaction is a helper method to define mock.On call +// - ctx context.Context +// - transaction *dynamo.Transaction +func (_e *mockDynamoClient_Expecter) WriteTransaction(ctx interface{}, transaction interface{}) *mockDynamoClient_WriteTransaction_Call { + return &mockDynamoClient_WriteTransaction_Call{Call: _e.mock.On("WriteTransaction", ctx, transaction)} +} + +func (_c *mockDynamoClient_WriteTransaction_Call) Run(run func(ctx context.Context, transaction *dynamo.Transaction)) *mockDynamoClient_WriteTransaction_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*dynamo.Transaction)) + }) + return _c +} + +func (_c *mockDynamoClient_WriteTransaction_Call) Return(_a0 error) *mockDynamoClient_WriteTransaction_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockDynamoClient_WriteTransaction_Call) RunAndReturn(run func(context.Context, *dynamo.Transaction) error) *mockDynamoClient_WriteTransaction_Call { + _c.Call.Return(run) + return _c +} + // newMockDynamoClient creates a new instance of mockDynamoClient. 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 newMockDynamoClient(t interface { diff --git a/internal/scheduled/mock_EventClient_test.go b/internal/scheduled/mock_EventClient_test.go new file mode 100644 index 0000000000..b619d8c578 --- /dev/null +++ b/internal/scheduled/mock_EventClient_test.go @@ -0,0 +1,84 @@ +// Code generated by mockery. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + event "github.com/ministryofjustice/opg-modernising-lpa/internal/event" + mock "github.com/stretchr/testify/mock" +) + +// mockEventClient is an autogenerated mock type for the EventClient type +type mockEventClient struct { + mock.Mock +} + +type mockEventClient_Expecter struct { + mock *mock.Mock +} + +func (_m *mockEventClient) EXPECT() *mockEventClient_Expecter { + return &mockEventClient_Expecter{mock: &_m.Mock} +} + +// SendLetterRequested provides a mock function with given fields: ctx, _a1 +func (_m *mockEventClient) SendLetterRequested(ctx context.Context, _a1 event.LetterRequested) error { + ret := _m.Called(ctx, _a1) + + if len(ret) == 0 { + panic("no return value specified for SendLetterRequested") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, event.LetterRequested) error); ok { + r0 = rf(ctx, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockEventClient_SendLetterRequested_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendLetterRequested' +type mockEventClient_SendLetterRequested_Call struct { + *mock.Call +} + +// SendLetterRequested is a helper method to define mock.On call +// - ctx context.Context +// - _a1 event.LetterRequested +func (_e *mockEventClient_Expecter) SendLetterRequested(ctx interface{}, _a1 interface{}) *mockEventClient_SendLetterRequested_Call { + return &mockEventClient_SendLetterRequested_Call{Call: _e.mock.On("SendLetterRequested", ctx, _a1)} +} + +func (_c *mockEventClient_SendLetterRequested_Call) Run(run func(ctx context.Context, _a1 event.LetterRequested)) *mockEventClient_SendLetterRequested_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(event.LetterRequested)) + }) + return _c +} + +func (_c *mockEventClient_SendLetterRequested_Call) Return(_a0 error) *mockEventClient_SendLetterRequested_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockEventClient_SendLetterRequested_Call) RunAndReturn(run func(context.Context, event.LetterRequested) error) *mockEventClient_SendLetterRequested_Call { + _c.Call.Return(run) + return _c +} + +// newMockEventClient creates a new instance of mockEventClient. 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 newMockEventClient(t interface { + mock.TestingT + Cleanup(func()) +}) *mockEventClient { + mock := &mockEventClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_LpaStoreResolvingService_test.go b/internal/scheduled/mock_LpaStoreResolvingService_test.go new file mode 100644 index 0000000000..7d6dfceb8a --- /dev/null +++ b/internal/scheduled/mock_LpaStoreResolvingService_test.go @@ -0,0 +1,98 @@ +// Code generated by mockery. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + donordata "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" + lpadata "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" + + mock "github.com/stretchr/testify/mock" +) + +// mockLpaStoreResolvingService is an autogenerated mock type for the LpaStoreResolvingService type +type mockLpaStoreResolvingService struct { + mock.Mock +} + +type mockLpaStoreResolvingService_Expecter struct { + mock *mock.Mock +} + +func (_m *mockLpaStoreResolvingService) EXPECT() *mockLpaStoreResolvingService_Expecter { + return &mockLpaStoreResolvingService_Expecter{mock: &_m.Mock} +} + +// Resolve provides a mock function with given fields: ctx, provided +func (_m *mockLpaStoreResolvingService) Resolve(ctx context.Context, provided *donordata.Provided) (*lpadata.Lpa, error) { + ret := _m.Called(ctx, provided) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 *lpadata.Lpa + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *donordata.Provided) (*lpadata.Lpa, error)); ok { + return rf(ctx, provided) + } + if rf, ok := ret.Get(0).(func(context.Context, *donordata.Provided) *lpadata.Lpa); ok { + r0 = rf(ctx, provided) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*lpadata.Lpa) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *donordata.Provided) error); ok { + r1 = rf(ctx, provided) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockLpaStoreResolvingService_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type mockLpaStoreResolvingService_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - ctx context.Context +// - provided *donordata.Provided +func (_e *mockLpaStoreResolvingService_Expecter) Resolve(ctx interface{}, provided interface{}) *mockLpaStoreResolvingService_Resolve_Call { + return &mockLpaStoreResolvingService_Resolve_Call{Call: _e.mock.On("Resolve", ctx, provided)} +} + +func (_c *mockLpaStoreResolvingService_Resolve_Call) Run(run func(ctx context.Context, provided *donordata.Provided)) *mockLpaStoreResolvingService_Resolve_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*donordata.Provided)) + }) + return _c +} + +func (_c *mockLpaStoreResolvingService_Resolve_Call) Return(_a0 *lpadata.Lpa, _a1 error) *mockLpaStoreResolvingService_Resolve_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockLpaStoreResolvingService_Resolve_Call) RunAndReturn(run func(context.Context, *donordata.Provided) (*lpadata.Lpa, error)) *mockLpaStoreResolvingService_Resolve_Call { + _c.Call.Return(run) + return _c +} + +// newMockLpaStoreResolvingService creates a new instance of mockLpaStoreResolvingService. 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 newMockLpaStoreResolvingService(t interface { + mock.TestingT + Cleanup(func()) +}) *mockLpaStoreResolvingService { + mock := &mockLpaStoreResolvingService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/runner.go b/internal/scheduled/runner.go index 748a756173..df6f687098 100644 --- a/internal/scheduled/runner.go +++ b/internal/scheduled/runner.go @@ -10,9 +10,13 @@ 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/actor" + "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" "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" + "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/task" @@ -32,6 +36,10 @@ type DonorStore interface { Put(ctx context.Context, provided *donordata.Provided) error } +type CertificateProviderStore interface { + One(ctx context.Context, pk dynamo.LpaKeyType) (*certificateproviderdata.Provided, error) +} + type NotifyClient interface { SendActorEmail(ctx context.Context, to notify.ToEmail, lpaUID string, email notify.Email) error } @@ -50,38 +58,52 @@ type MetricsClient interface { PutMetrics(ctx context.Context, input *cloudwatch.PutMetricDataInput) error } -type LpaStoreClient interface { - Lpa(ctx context.Context, lpaUID string) (*lpadata.Lpa, error) +type Bundle interface { + For(lang localize.Lang) *localize.Localizer +} + +type EventClient interface { + SendLetterRequested(ctx context.Context, event event.LetterRequested) error +} + +type LpaStoreResolvingService interface { + Resolve(ctx context.Context, provided *donordata.Provided) (*lpadata.Lpa, error) } type Runner struct { - logger Logger - store ScheduledStore - now func() time.Time - since func(time.Time) time.Duration - donorStore DonorStore - notifyClient NotifyClient - actions map[Action]ActionFunc - waiter Waiter - metricsClient MetricsClient - processed float64 - ignored float64 - errored float64 + logger Logger + store ScheduledStore + now func() time.Time + since func(time.Time) time.Duration + donorStore DonorStore + certificateProviderStore CertificateProviderStore + lpaStoreResolvingService LpaStoreResolvingService + notifyClient NotifyClient + eventClient EventClient + bundle Bundle + actions map[Action]ActionFunc + waiter Waiter + metricsClient MetricsClient + processed float64 + ignored float64 + errored float64 // TODO remove in MLPAB-2690 metricsEnabled bool } -func NewRunner(logger Logger, store ScheduledStore, donorStore DonorStore, notifyClient NotifyClient, metricsClient MetricsClient, metricsEnabled bool) *Runner { +func NewRunner(logger Logger, store ScheduledStore, donorStore DonorStore, certificateProviderStore CertificateProviderStore, notifyClient NotifyClient, bundle Bundle, metricsClient MetricsClient, metricsEnabled bool) *Runner { r := &Runner{ - logger: logger, - store: store, - now: time.Now, - since: time.Since, - donorStore: donorStore, - notifyClient: notifyClient, - waiter: &waiter{backoff: time.Second, sleep: time.Sleep, maxRetries: 10}, - metricsClient: metricsClient, - metricsEnabled: metricsEnabled, + logger: logger, + store: store, + now: time.Now, + since: time.Since, + donorStore: donorStore, + certificateProviderStore: certificateProviderStore, + notifyClient: notifyClient, + bundle: bundle, + waiter: &waiter{backoff: time.Second, sleep: time.Sleep, maxRetries: 10}, + metricsClient: metricsClient, + metricsEnabled: metricsEnabled, } r.actions = map[Action]ActionFunc{ @@ -220,3 +242,96 @@ func (r *Runner) stepCancelDonorIdentity(ctx context.Context, row *Event) error return nil } + +func (r *Runner) stepRemindCertificateProviderToComplete(ctx context.Context, row *Event) error { + certificateProvider, err := r.certificateProviderStore.One(ctx, row.TargetLpaKey) + if err != nil && !errors.Is(err, dynamo.NotFoundError{}) { + return fmt.Errorf("error retrieving certificate provider: %w", err) + } + + if certificateProvider != nil && certificateProvider.Tasks.ProvideTheCertificate.IsCompleted() { + return errStepIgnored + } + + 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.CertificateProviderInvitedAt.AddDate(0, 3, 0) + + if r.now().Before(afterInvite) || r.now().Before(beforeExpiry) { + return errStepIgnored + } + + if lpa.CertificateProvider.Channel.IsPaper() { + letterRequest := event.LetterRequested{ + UID: lpa.LpaUID, + LetterType: "ADVISE_CERTIFICATE_PROVIDER_TO_SIGN_OR_OPT_OUT", + ActorType: actor.TypeCertificateProvider, + ActorUID: lpa.CertificateProvider.UID, + } + + if err := r.eventClient.SendLetterRequested(ctx, letterRequest); err != nil { + return fmt.Errorf("could not send certificate provider letter request: %w", err) + } + } else { + var localizer *localize.Localizer + if certificateProvider != nil && !certificateProvider.ContactLanguagePreference.Empty() { + localizer = r.bundle.For(certificateProvider.ContactLanguagePreference) + } else { + localizer = r.bundle.For(lpa.CertificateProvider.ContactLanguagePreference) + } + + toCertificateProviderEmail := notify.ToLpaCertificateProvider(certificateProvider, lpa) + + if err := r.notifyClient.SendActorEmail(ctx, toCertificateProviderEmail, lpa.LpaUID, notify.AdviseCertificateProviderToSignOrOptOutEmail{ + DonorFullName: lpa.Donor.FullName(), + LpaType: localizer.T(lpa.Type.String()), + CertificateProviderFullName: lpa.CertificateProvider.FullName(), + InvitedDate: localizer.FormatDate(lpa.CertificateProviderInvitedAt), + DeadlineDate: localizer.FormatDate(lpa.ExpiresAt()), + }); err != nil { + return fmt.Errorf("could not send certificate provider email: %w", err) + } + } + + if lpa.Donor.Channel.IsPaper() { + letterRequest := event.LetterRequested{ + UID: lpa.LpaUID, + LetterType: "INFORM_DONOR_CERTIFICATE_PROVIDER_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 certificate provider 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.InformDonorCertificateProviderHasNotActedEmail{ + CertificateProviderFullName: lpa.CertificateProvider.FullName(), + LpaType: localizer.T(lpa.Type.String()), + DonorFullName: lpa.Donor.FullName(), + InvitedDate: localizer.FormatDate(lpa.CertificateProviderInvitedAt), + DeadlineDate: localizer.FormatDate(lpa.ExpiresAt()), + }); err != nil { + return fmt.Errorf("could not send donor email: %w", err) + } + } + + return nil +} diff --git a/internal/scheduled/runner_test.go b/internal/scheduled/runner_test.go index 017fb629c7..e0d11eda0d 100644 --- a/internal/scheduled/runner_test.go +++ b/internal/scheduled/runner_test.go @@ -10,11 +10,18 @@ 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/actor" + "github.com/ministryofjustice/opg-modernising-lpa/internal/actor/actoruid" + 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" "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" "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/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -22,7 +29,7 @@ import ( var ( ctx = context.WithValue(context.Background(), (*string)(nil), "value") expectedError = errors.New("hey") - testNow = time.Now() + testNow = time.Date(2000, time.January, 2, 12, 13, 14, 15, time.UTC) testNowFn = func() time.Time { return testNow } testSinceDuration = time.Millisecond * 5 testSinceFn = func(t time.Time) time.Duration { return testSinceDuration } @@ -54,10 +61,12 @@ func TestNewRunner(t *testing.T) { logger := newMockLogger(t) store := newMockScheduledStore(t) donorStore := newMockDonorStore(t) + certificateProviderStore := newMockCertificateProviderStore(t) notifyClient := newMockNotifyClient(t) metricsClient := newMockMetricsClient(t) + bundle := newMockBundle(t) - runner := NewRunner(logger, store, donorStore, notifyClient, metricsClient, true) + runner := NewRunner(logger, store, donorStore, certificateProviderStore, notifyClient, bundle, metricsClient, true) assert.Equal(t, logger, runner.logger) assert.Equal(t, store, runner.store) @@ -604,3 +613,475 @@ func TestRunnerCancelDonorIdentityWhenDonorStorePutErrors(t *testing.T) { assert.ErrorIs(t, err, expectedError) } + +func TestRunnerRemindCertificateProviderToComplete(t *testing.T) { + testcases := map[string]struct { + certificateProvider *certificateproviderdata.Provided + certificateProviderError error + }{ + "certificate provider not started": { + certificateProviderError: dynamo.NotFoundError{}, + }, + "certificate provider started": { + certificateProvider: &certificateproviderdata.Provided{ + 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, + }, + CertificateProvider: lpadata.CertificateProvider{ + FirstNames: "c", + LastName: "d", + ContactLanguagePreference: localize.En, + }, + CertificateProviderInvitedAt: 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) + + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(ctx, row.TargetLpaKey). + Return(tc.certificateProvider, tc.certificateProviderError) + + lpaStoreResolvingService := newMockLpaStoreResolvingService(t) + lpaStoreResolvingService.EXPECT(). + Resolve(ctx, donor). + Return(lpa, nil) + + notifyClient := newMockNotifyClient(t) + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaCertificateProvider(nil, lpa), "lpa-uid", notify.AdviseCertificateProviderToSignOrOptOutEmail{ + DonorFullName: "a b", + LpaType: "personal-welfare", + CertificateProviderFullName: "c d", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + }). + Return(nil) + notifyClient.EXPECT(). + SendActorEmail(ctx, notify.ToLpaDonor(lpa), "lpa-uid", notify.InformDonorCertificateProviderHasNotActedEmail{ + CertificateProviderFullName: "c d", + LpaType: "personal-welfare", + DonorFullName: "a b", + InvitedDate: "1 October 1999", + DeadlineDate: "2 April 2000", + }). + Return(nil) + + localizer := &localize.Localizer{} + + bundle := newMockBundle(t) + bundle.EXPECT(). + For(localize.En). + Return(localizer) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + certificateProviderStore: certificateProviderStore, + notifyClient: notifyClient, + bundle: bundle, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, row) + assert.Nil(t, err) + }) + } +} + +func TestRunnerRemindCertificateProviderToCompleteWhenOnPaper(t *testing.T) { + donorUID := actoruid.New() + correspondentUID := 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, + }, + CertificateProvider: lpadata.CertificateProvider{ + FirstNames: "c", + LastName: "d", + Channel: lpadata.ChannelPaper, + }, + SignedAt: testNow.AddDate(0, -3, 0).Add(-time.Second), + }, + donorLetterRequest: event.LetterRequested{ + UID: "lpa-uid", + LetterType: "INFORM_DONOR_CERTIFICATE_PROVIDER_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, + }, + CertificateProvider: lpadata.CertificateProvider{ + FirstNames: "c", + LastName: "d", + 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_CERTIFICATE_PROVIDER_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) + + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(ctx, row.TargetLpaKey). + Return(nil, dynamo.NotFoundError{}) + + eventClient := newMockEventClient(t) + eventClient.EXPECT(). + SendLetterRequested(ctx, event.LetterRequested{ + UID: "lpa-uid", + LetterType: "ADVISE_CERTIFICATE_PROVIDER_TO_SIGN_OR_OPT_OUT", + ActorType: actor.TypeCertificateProvider, + ActorUID: tc.lpa.CertificateProvider.UID, + }). + Return(nil) + eventClient.EXPECT(). + SendLetterRequested(ctx, tc.donorLetterRequest). + Return(nil) + + runner := &Runner{ + donorStore: donorStore, + lpaStoreResolvingService: lpaStoreResolvingService, + certificateProviderStore: certificateProviderStore, + eventClient: eventClient, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, row) + assert.Nil(t, err) + }) + } +} + +func TestRunnerRemindCertificateProviderToCompleteWhenCertificateProviderAlreadyCompleted(t *testing.T) { + row := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + certificateProvider := &certificateproviderdata.Provided{ + Tasks: certificateproviderdata.Tasks{ProvideTheCertificate: task.StateCompleted}, + } + + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(ctx, row.TargetLpaKey). + Return(certificateProvider, nil) + + runner := &Runner{ + certificateProviderStore: certificateProviderStore, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, row) + assert.Equal(t, errStepIgnored, err) +} + +func TestRunnerRemindCertificateProviderToCompleteWhenNotValidTime(t *testing.T) { + testcases := map[string]*lpadata.Lpa{ + "invite sent almost 3 months ago": { + CertificateProviderInvitedAt: testNow.AddDate(0, -3, 1), + SignedAt: testNow.AddDate(0, -3, 0), + }, + "expiry almost 3 months ago": { + CertificateProviderInvitedAt: testNow.AddDate(0, -3, 0), + SignedAt: testNow.AddDate(0, -3, 1), + }, + "submitted expiry almost 3 months ago": { + Donor: lpadata.Donor{ + IdentityCheck: &lpadata.IdentityCheck{ + CheckedAt: testNow, + }, + }, + CertificateProviderInvitedAt: 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", + } + + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(mock.Anything, mock.Anything). + Return(nil, dynamo.NotFoundError{}) + + 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, + certificateProviderStore: certificateProviderStore, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, &Event{}) + assert.Equal(t, errStepIgnored, err) + }) + } +} + +func TestRunnerRemindCertificateProviderToCompleteWhenCertificateProviderStoreErrors(t *testing.T) { + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(mock.Anything, mock.Anything). + Return(nil, expectedError) + + runner := &Runner{ + certificateProviderStore: certificateProviderStore, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, &Event{}) + assert.ErrorIs(t, err, expectedError) +} + +func TestRunnerRemindCertificateProviderToCompleteWhenDonorStoreErrors(t *testing.T) { + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(mock.Anything, mock.Anything). + Return(nil, dynamo.NotFoundError{}) + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(nil, expectedError) + + runner := &Runner{ + donorStore: donorStore, + certificateProviderStore: certificateProviderStore, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, &Event{}) + assert.ErrorIs(t, err, expectedError) +} + +func TestRunnerRemindCertificateProviderToCompleteWhenLpaStoreResolvingServiceErrors(t *testing.T) { + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(mock.Anything, mock.Anything). + Return(nil, dynamo.NotFoundError{}) + + 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, + certificateProviderStore: certificateProviderStore, + lpaStoreResolvingService: lpaStoreResolvingService, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, &Event{}) + assert.ErrorIs(t, err, expectedError) +} + +func TestRunnerRemindCertificateProviderToCompleteWhenNotifyClientErrors(t *testing.T) { + testcases := map[string]func(*mockNotifyClient){ + "first": func(m *mockNotifyClient) { + m.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedError). + Once() + }, + "second": func(m *mockNotifyClient) { + m.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil). + Once() + m.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedError). + Once() + }, + } + + for name, setupNotifyClient := range testcases { + t.Run(name, func(t *testing.T) { + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(mock.Anything, mock.Anything). + Return(nil, dynamo.NotFoundError{}) + + 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) + + notifyClient := newMockNotifyClient(t) + setupNotifyClient(notifyClient) + + localizer := &localize.Localizer{} + + bundle := newMockBundle(t) + bundle.EXPECT(). + For(mock.Anything). + Return(localizer) + + runner := &Runner{ + donorStore: donorStore, + certificateProviderStore: certificateProviderStore, + lpaStoreResolvingService: lpaStoreResolvingService, + notifyClient: notifyClient, + bundle: bundle, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, &Event{}) + assert.ErrorIs(t, err, expectedError) + }) + } +} + +func TestRunnerRemindCertificateProviderToCompleteWhenEventClientErrors(t *testing.T) { + testcases := map[string]func(*mockEventClient){ + "first": func(m *mockEventClient) { + m.EXPECT(). + SendLetterRequested(mock.Anything, mock.Anything). + Return(expectedError). + Once() + }, + "second": func(m *mockEventClient) { + m.EXPECT(). + SendLetterRequested(mock.Anything, mock.Anything). + Return(nil). + Once() + m.EXPECT(). + SendLetterRequested(mock.Anything, mock.Anything). + Return(expectedError). + Once() + }, + } + + for name, setupEventClient := range testcases { + t.Run(name, func(t *testing.T) { + certificateProviderStore := newMockCertificateProviderStore(t) + certificateProviderStore.EXPECT(). + One(mock.Anything, mock.Anything). + Return(nil, dynamo.NotFoundError{}) + + 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{ + Donor: lpadata.Donor{Channel: lpadata.ChannelPaper}, + CertificateProvider: lpadata.CertificateProvider{Channel: lpadata.ChannelPaper}, + }, nil) + + eventClient := newMockEventClient(t) + setupEventClient(eventClient) + + runner := &Runner{ + donorStore: donorStore, + certificateProviderStore: certificateProviderStore, + lpaStoreResolvingService: lpaStoreResolvingService, + eventClient: eventClient, + now: testNowFn, + } + + err := runner.stepRemindCertificateProviderToComplete(ctx, &Event{}) + assert.ErrorIs(t, err, expectedError) + }) + } +} diff --git a/internal/scheduled/store.go b/internal/scheduled/store.go index 0df050fa3c..5fb440ee0f 100644 --- a/internal/scheduled/store.go +++ b/internal/scheduled/store.go @@ -13,7 +13,7 @@ type DynamoClient interface { AnyByPK(ctx context.Context, pk dynamo.PK, v interface{}) error Move(ctx context.Context, oldKeys dynamo.Keys, value any) error DeleteKeys(ctx context.Context, keys []dynamo.Keys) error - Create(ctx context.Context, v interface{}) error + WriteTransaction(ctx context.Context, transaction *dynamo.Transaction) error } type Store struct { @@ -44,12 +44,18 @@ func (s *Store) Pop(ctx context.Context, day time.Time) (*Event, error) { return &row, nil } -func (s *Store) Create(ctx context.Context, row Event) error { - row.PK = dynamo.ScheduledDayKey(row.At) - row.SK = dynamo.ScheduledKey(row.At, int(row.Action)) - row.CreatedAt = s.now() +func (s *Store) Create(ctx context.Context, rows ...Event) error { + transaction := dynamo.NewTransaction() - return s.dynamoClient.Create(ctx, row) + for _, row := range rows { + row.PK = dynamo.ScheduledDayKey(row.At) + row.SK = dynamo.ScheduledKey(row.At, int(row.Action)) + row.CreatedAt = s.now() + + transaction.Put(row) + } + + return s.dynamoClient.WriteTransaction(ctx, transaction) } func (s *Store) DeleteAllByUID(ctx context.Context, uid string) error { diff --git a/internal/scheduled/store_test.go b/internal/scheduled/store_test.go index 600f2c6f8f..1fdb9448cb 100644 --- a/internal/scheduled/store_test.go +++ b/internal/scheduled/store_test.go @@ -80,20 +80,32 @@ func TestStorePopWhenDeleteOneErrors(t *testing.T) { func TestStoreCreate(t *testing.T) { at := time.Date(2024, time.January, 1, 12, 13, 14, 5, time.UTC) + at2 := time.Date(2024, time.February, 1, 12, 13, 14, 5, time.UTC) dynamoClient := newMockDynamoClient(t) dynamoClient.EXPECT(). - Create(ctx, Event{ - PK: dynamo.ScheduledDayKey(at), - SK: dynamo.ScheduledKey(at, 99), - CreatedAt: testNow, - At: at, - Action: 99, + WriteTransaction(ctx, &dynamo.Transaction{ + Puts: []any{ + Event{ + PK: dynamo.ScheduledDayKey(at), + SK: dynamo.ScheduledKey(at, 99), + CreatedAt: testNow, + At: at, + Action: 99, + }, + Event{ + PK: dynamo.ScheduledDayKey(at2), + SK: dynamo.ScheduledKey(at2, 100), + CreatedAt: testNow, + At: at2, + Action: 100, + }, + }, }). Return(expectedError) store := &Store{dynamoClient: dynamoClient, now: testNowFn} - err := store.Create(ctx, Event{At: at, Action: 99}) + err := store.Create(ctx, Event{At: at, Action: 99}, Event{At: at2, Action: 100}) assert.Equal(t, expectedError, err) } diff --git a/scripts/get_event_schemas.sh b/scripts/get_event_schemas.sh index b994edc685..d011d685d1 100644 --- a/scripts/get_event_schemas.sh +++ b/scripts/get_event_schemas.sh @@ -14,7 +14,8 @@ for v in uid-requested \ attorney-started \ identity-check-mismatched \ correspondent-updated \ - lpa-access-granted + lpa-access-granted \ + letter-requested do echo $v curl -o internal/event/testdata/$v.json "https://raw.githubusercontent.com/ministryofjustice/opg-event-store/main/domains/POAS/events/$v/schema.json"