diff --git a/.mockery.yaml b/.mockery.yaml index 33da8a7219..4c92ee760d 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -29,6 +29,7 @@ packages: github.com/ministryofjustice/opg-modernising-lpa/internal/pay: github.com/ministryofjustice/opg-modernising-lpa/internal/place: github.com/ministryofjustice/opg-modernising-lpa/internal/s3: + github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled: github.com/ministryofjustice/opg-modernising-lpa/internal/search: github.com/ministryofjustice/opg-modernising-lpa/internal/secrets: github.com/ministryofjustice/opg-modernising-lpa/internal/sesh: diff --git a/cmd/event-received/mock_lpaStoreClient_test.go b/cmd/event-received/mock_LpaStoreClient_test.go similarity index 100% rename from cmd/event-received/mock_lpaStoreClient_test.go rename to cmd/event-received/mock_LpaStoreClient_test.go diff --git a/cmd/event-received/mock_secretsClient_test.go b/cmd/event-received/mock_SecretsClient_test.go similarity index 100% rename from cmd/event-received/mock_secretsClient_test.go rename to cmd/event-received/mock_SecretsClient_test.go diff --git a/cmd/event-received/mock_shareCodeSender_test.go b/cmd/event-received/mock_ShareCodeSender_test.go similarity index 100% rename from cmd/event-received/mock_shareCodeSender_test.go rename to cmd/event-received/mock_ShareCodeSender_test.go diff --git a/cmd/mlpa/main.go b/cmd/mlpa/main.go index 3ebac95e83..b94ccf4580 100644 --- a/cmd/mlpa/main.go +++ b/cmd/mlpa/main.go @@ -1,6 +1,7 @@ package main import ( + "cmp" "context" "crypto/ecdsa" "encoding/base64" @@ -22,10 +23,10 @@ import ( "github.com/aws/aws-sdk-go-v2/config" "github.com/golang-jwt/jwt/v5" "github.com/gorilla/handlers" - "github.com/ministryofjustice/opg-go-common/env" "github.com/ministryofjustice/opg-go-common/template" "github.com/ministryofjustice/opg-modernising-lpa/internal/actor" "github.com/ministryofjustice/opg-modernising-lpa/internal/app" + "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" "github.com/ministryofjustice/opg-modernising-lpa/internal/event" "github.com/ministryofjustice/opg-modernising-lpa/internal/lambda" @@ -37,6 +38,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/pay" "github.com/ministryofjustice/opg-modernising-lpa/internal/place" "github.com/ministryofjustice/opg-modernising-lpa/internal/s3" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/search" "github.com/ministryofjustice/opg-modernising-lpa/internal/secrets" "github.com/ministryofjustice/opg-modernising-lpa/internal/sesh" @@ -85,39 +87,45 @@ func main() { func run(ctx context.Context, logger *slog.Logger) error { var ( - devMode = env.Get("DEV_MODE", "") == "1" - appPublicURL = env.Get("APP_PUBLIC_URL", "http://localhost:5050") - authRedirectBaseURL = env.Get("AUTH_REDIRECT_BASE_URL", "http://localhost:5050") - webDir = env.Get("WEB_DIR", "web") - awsBaseURL = env.Get("AWS_BASE_URL", "") - clientID = env.Get("CLIENT_ID", "client-id-value") - issuer = env.Get("ISSUER", "http://mock-onelogin:8080") - dynamoTableLpas = env.Get("DYNAMODB_TABLE_LPAS", "lpas") - notifyBaseURL = env.Get("GOVUK_NOTIFY_BASE_URL", "http://mock-notify:8080") - notifyIsProduction = env.Get("GOVUK_NOTIFY_IS_PRODUCTION", "") == "1" - ordnanceSurveyBaseURL = env.Get("ORDNANCE_SURVEY_BASE_URL", "http://mock-os-api:8080") - payBaseURL = env.Get("GOVUK_PAY_BASE_URL", "http://mock-pay:8080") - port = env.Get("APP_PORT", "8080") - xrayEnabled = env.Get("XRAY_ENABLED", "") == "1" + devMode = os.Getenv("DEV_MODE") == "1" + appPublicURL = cmp.Or(os.Getenv("APP_PUBLIC_URL"), "http://localhost:5050") + authRedirectBaseURL = cmp.Or(os.Getenv("AUTH_REDIRECT_BASE_URL"), "http://localhost:5050") + webDir = cmp.Or(os.Getenv("WEB_DIR"), "web") + awsBaseURL = os.Getenv("AWS_BASE_URL") + clientID = cmp.Or(os.Getenv("CLIENT_ID"), "client-id-value") + issuer = cmp.Or(os.Getenv("ISSUER"), "http://mock-onelogin:8080") + dynamoTableLpas = cmp.Or(os.Getenv("DYNAMODB_TABLE_LPAS"), "lpas") + notifyBaseURL = cmp.Or(os.Getenv("GOVUK_NOTIFY_BASE_URL"), "http://mock-notify:8080") + notifyIsProduction = os.Getenv("GOVUK_NOTIFY_IS_PRODUCTION") == "1" + ordnanceSurveyBaseURL = cmp.Or(os.Getenv("ORDNANCE_SURVEY_BASE_URL"), "http://mock-os-api:8080") + payBaseURL = cmp.Or(os.Getenv("GOVUK_PAY_BASE_URL"), "http://mock-pay:8080") + port = cmp.Or(os.Getenv("APP_PORT"), "8080") + xrayEnabled = os.Getenv("XRAY_ENABLED") == "1" rumConfig = templatefn.RumConfig{ - GuestRoleArn: env.Get("AWS_RUM_GUEST_ROLE_ARN", ""), - Endpoint: env.Get("AWS_RUM_ENDPOINT", ""), - ApplicationRegion: env.Get("AWS_RUM_APPLICATION_REGION", ""), - IdentityPoolID: env.Get("AWS_RUM_IDENTITY_POOL_ID", ""), - ApplicationID: env.Get("AWS_RUM_APPLICATION_ID", ""), + GuestRoleArn: os.Getenv("AWS_RUM_GUEST_ROLE_ARN"), + Endpoint: os.Getenv("AWS_RUM_ENDPOINT"), + ApplicationRegion: os.Getenv("AWS_RUM_APPLICATION_REGION"), + IdentityPoolID: os.Getenv("AWS_RUM_IDENTITY_POOL_ID"), + ApplicationID: os.Getenv("AWS_RUM_APPLICATION_ID"), } - uidBaseURL = env.Get("UID_BASE_URL", "http://mock-uid:8080") - lpaStoreBaseURL = env.Get("LPA_STORE_BASE_URL", "http://mock-lpa-store:8080") - metadataURL = env.Get("ECS_CONTAINER_METADATA_URI_V4", "") - oneloginURL = env.Get("ONELOGIN_URL", "https://home.integration.account.gov.uk") - evidenceBucketName = env.Get("UPLOADS_S3_BUCKET_NAME", "evidence") - eventBusName = env.Get("EVENT_BUS_NAME", "default") - mockIdentityPublicKey = env.Get("MOCK_IDENTITY_PUBLIC_KEY", "") - searchEndpoint = env.Get("SEARCH_ENDPOINT", "") - searchIndexName = env.Get("SEARCH_INDEX_NAME", "lpas") - searchIndexingEnabled = env.Get("SEARCH_INDEXING_DISABLED", "") != "1" + uidBaseURL = cmp.Or(os.Getenv("UID_BASE_URL"), "http://mock-uid:8080") + lpaStoreBaseURL = cmp.Or(os.Getenv("LPA_STORE_BASE_URL"), "http://mock-lpa-store:8080") + metadataURL = os.Getenv("ECS_CONTAINER_METADATA_URI_V4") + oneloginURL = cmp.Or(os.Getenv("ONELOGIN_URL"), "https://home.integration.account.gov.uk") + evidenceBucketName = cmp.Or(os.Getenv("UPLOADS_S3_BUCKET_NAME"), "evidence") + eventBusName = cmp.Or(os.Getenv("EVENT_BUS_NAME"), "default") + mockIdentityPublicKey = os.Getenv("MOCK_IDENTITY_PUBLIC_KEY") + searchEndpoint = os.Getenv("SEARCH_ENDPOINT") + searchIndexName = cmp.Or(os.Getenv("SEARCH_INDEX_NAME"), "lpas") + searchIndexingEnabled = os.Getenv("SEARCH_INDEXING_DISABLED") != "1" + scheduledRunnerPeriod = cmp.Or(os.Getenv("SCHEDULED_RUNNER_PERIOD"), "6h") ) + scheduledRunnerPeriodDur, err := time.ParseDuration(scheduledRunnerPeriod) + if err != nil { + return err + } + staticHash, err := dirhash.HashDir(webDir+"/static", webDir, dirhash.DefaultHash) if err != nil { return err @@ -357,6 +365,17 @@ func run(ctx context.Context, logger *slog.Logger) error { handler = telemetry.WrapHandler(mux) } + donorStore := donor.NewStore(lpasDynamoClient, eventClient, logger, searchClient) + scheduledStore := scheduled.NewStore(lpasDynamoClient) + + runner := scheduled.NewRunner(logger, scheduledStore, donorStore, notifyClient, scheduledRunnerPeriodDur) + go func() { + if err := runner.Run(ctx); err != nil { + logger.Error("runner error", slog.Any("err", err)) + os.Exit(1) + } + }() + server := &http.Server{ Addr: ":" + port, Handler: page.Recover(tmpls.Get("error-500.gohtml"), logger, bundle, handler), diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index ed4b410f69..dc4b802af4 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -27,6 +27,7 @@ services: - SEARCH_ENDPOINT=http://my-domain.eu-west-1.opensearch.localhost.localstack.cloud:4566 - SEARCH_INDEXING_ENABLED=1 - DEV_MODE=1 + - SCHEDULED_RUNNER_PERIOD=1m event-logger: build: diff --git a/internal/app/app.go b/internal/app/app.go index e886053527..03628c2af4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -29,6 +29,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/pay" "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/search" "github.com/ministryofjustice/opg-modernising-lpa/internal/sesh" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode" @@ -48,22 +49,23 @@ type Logger interface { } type DynamoClient interface { - One(ctx context.Context, pk dynamo.PK, sk dynamo.SK, v interface{}) error - OneByPK(ctx context.Context, pk dynamo.PK, v interface{}) error - OneByPartialSK(ctx context.Context, pk dynamo.PK, partialSK dynamo.SK, v interface{}) error + AllByKeys(ctx context.Context, keys []dynamo.Keys) ([]map[string]dynamodbtypes.AttributeValue, error) AllByPartialSK(ctx context.Context, pk dynamo.PK, partialSK dynamo.SK, v interface{}) error - LatestForActor(ctx context.Context, sk dynamo.SK, v interface{}) error AllBySK(ctx context.Context, sk dynamo.SK, v interface{}) error - AllByKeys(ctx context.Context, keys []dynamo.Keys) ([]map[string]dynamodbtypes.AttributeValue, error) AllKeysByPK(ctx context.Context, pk dynamo.PK) ([]dynamo.Keys, error) - Put(ctx context.Context, v interface{}) error + BatchPut(ctx context.Context, items []interface{}) error Create(ctx context.Context, v interface{}) error DeleteKeys(ctx context.Context, keys []dynamo.Keys) error DeleteOne(ctx context.Context, pk dynamo.PK, sk dynamo.SK) error - Update(ctx context.Context, pk dynamo.PK, sk dynamo.SK, values map[string]dynamodbtypes.AttributeValue, expression string) error - BatchPut(ctx context.Context, items []interface{}) error + LatestForActor(ctx context.Context, sk dynamo.SK, v interface{}) error + Move(ctx context.Context, oldKeys dynamo.Keys, value any) error + One(ctx context.Context, pk dynamo.PK, sk dynamo.SK, v interface{}) error + OneByPK(ctx context.Context, pk dynamo.PK, v interface{}) error + OneByPartialSK(ctx context.Context, pk dynamo.PK, partialSK dynamo.SK, v interface{}) error OneBySK(ctx context.Context, sk dynamo.SK, v interface{}) error OneByUID(ctx context.Context, uid string, v interface{}) error + Put(ctx context.Context, v interface{}) error + Update(ctx context.Context, pk dynamo.PK, sk dynamo.SK, values map[string]dynamodbtypes.AttributeValue, expression string) error WriteTransaction(ctx context.Context, transaction *dynamo.Transaction) error } @@ -107,6 +109,7 @@ func App( organisationStore := supporter.NewOrganisationStore(lpaDynamoClient) memberStore := supporter.NewMemberStore(lpaDynamoClient) voucherStore := voucher.NewStore(lpaDynamoClient) + scheduledStore := scheduled.NewStore(lpaDynamoClient) progressTracker := task.ProgressTracker{Localizer: localizer} shareCodeSender := sharecode.NewSender(shareCodeStore, notifyClient, appPublicURL, random.String, eventClient) @@ -247,6 +250,7 @@ func App( shareCodeStore, progressTracker, lpaStoreResolvingService, + scheduledStore, ) return withAppData(page.ValidateCsrf(rootMux, sessionStore, random.String, errorHandler), localizer, lang) diff --git a/internal/app/mock_DynamoClient_test.go b/internal/app/mock_DynamoClient_test.go index 9eb0b991b7..5798b70b8d 100644 --- a/internal/app/mock_DynamoClient_test.go +++ b/internal/app/mock_DynamoClient_test.go @@ -476,6 +476,54 @@ func (_c *mockDynamoClient_LatestForActor_Call) RunAndReturn(run func(context.Co return _c } +// Move provides a mock function with given fields: ctx, oldKeys, value +func (_m *mockDynamoClient) Move(ctx context.Context, oldKeys dynamo.Keys, value interface{}) error { + ret := _m.Called(ctx, oldKeys, value) + + if len(ret) == 0 { + panic("no return value specified for Move") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, dynamo.Keys, interface{}) error); ok { + r0 = rf(ctx, oldKeys, value) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockDynamoClient_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move' +type mockDynamoClient_Move_Call struct { + *mock.Call +} + +// Move is a helper method to define mock.On call +// - ctx context.Context +// - oldKeys dynamo.Keys +// - value interface{} +func (_e *mockDynamoClient_Expecter) Move(ctx interface{}, oldKeys interface{}, value interface{}) *mockDynamoClient_Move_Call { + return &mockDynamoClient_Move_Call{Call: _e.mock.On("Move", ctx, oldKeys, value)} +} + +func (_c *mockDynamoClient_Move_Call) Run(run func(ctx context.Context, oldKeys dynamo.Keys, value interface{})) *mockDynamoClient_Move_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(dynamo.Keys), args[2].(interface{})) + }) + return _c +} + +func (_c *mockDynamoClient_Move_Call) Return(_a0 error) *mockDynamoClient_Move_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockDynamoClient_Move_Call) RunAndReturn(run func(context.Context, dynamo.Keys, interface{}) error) *mockDynamoClient_Move_Call { + _c.Call.Return(run) + return _c +} + // One provides a mock function with given fields: ctx, pk, sk, v func (_m *mockDynamoClient) One(ctx context.Context, pk dynamo.PK, sk dynamo.SK, v interface{}) error { ret := _m.Called(ctx, pk, sk, v) diff --git a/internal/donor/donordata/provided.go b/internal/donor/donordata/provided.go index b3177b7d3d..4b472bed64 100644 --- a/internal/donor/donordata/provided.go +++ b/internal/donor/donordata/provided.go @@ -291,6 +291,14 @@ func (l *Provided) Under18ActorDetails() []Under18ActorDetails { return data } +func (l *Provided) CorrespondentEmail() string { + if l.Correspondent.Email == "" { + return l.Donor.Email + } + + return l.Correspondent.Email +} + func (l *Provided) ActorAddresses() []place.Address { var addresses []place.Address diff --git a/internal/donor/donordata/provided_test.go b/internal/donor/donordata/provided_test.go index cea662c33e..12a2ae0ca0 100644 --- a/internal/donor/donordata/provided_test.go +++ b/internal/donor/donordata/provided_test.go @@ -245,6 +245,21 @@ func TestUnder18ActorDetails(t *testing.T) { }, actors) } +func TestProvidedCorrespondentEmail(t *testing.T) { + lpa := &Provided{ + Donor: Donor{Email: "donor"}, + } + assert.Equal(t, "donor", lpa.CorrespondentEmail()) +} + +func TestProvidedCorrespondentEmailWhenCorrespondentProvided(t *testing.T) { + lpa := &Provided{ + Donor: Donor{Email: "donor"}, + Correspondent: Correspondent{Email: "correspondent"}, + } + assert.Equal(t, "correspondent", lpa.CorrespondentEmail()) +} + func TestActorAddresses(t *testing.T) { donor := &Provided{ Donor: Donor{Address: place.Address{Line1: "1"}}, diff --git a/internal/donor/donorpage/identity_with_one_login_callback.go b/internal/donor/donorpage/identity_with_one_login_callback.go index b09af1b3c6..96b924b283 100644 --- a/internal/donor/donorpage/identity_with_one_login_callback.go +++ b/internal/donor/donorpage/identity_with_one_login_callback.go @@ -8,10 +8,11 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" ) -func IdentityWithOneLoginCallback(oneLoginClient OneLoginClient, sessionStore SessionStore, donorStore DonorStore) Handler { +func IdentityWithOneLoginCallback(oneLoginClient OneLoginClient, sessionStore SessionStore, donorStore DonorStore, scheduledStore ScheduledStore) Handler { return func(appData appcontext.Data, w http.ResponseWriter, r *http.Request, provided *donordata.Provided) error { if provided.DonorIdentityConfirmed() { return donor.PathOneLoginIdentityDetails.Redirect(w, r, appData, provided) @@ -59,6 +60,15 @@ func IdentityWithOneLoginCallback(oneLoginClient OneLoginClient, sessionStore Se case identity.StatusInsufficientEvidence: return donor.PathUnableToConfirmIdentity.Redirect(w, r, appData, provided) default: + if err := scheduledStore.Put(r.Context(), scheduled.Event{ + At: userData.RetrievedAt.AddDate(0, 6, 0), + Action: scheduled.ActionExpireDonorIdentity, + TargetLpaKey: provided.PK, + TargetLpaOwnerKey: provided.SK, + }); err != nil { + return err + } + return donor.PathOneLoginIdentityDetails.Redirect(w, r, appData, provided) } } diff --git a/internal/donor/donorpage/identity_with_one_login_callback_test.go b/internal/donor/donorpage/identity_with_one_login_callback_test.go index 2498097cfc..c783212b39 100644 --- a/internal/donor/donorpage/identity_with_one_login_callback_test.go +++ b/internal/donor/donorpage/identity_with_one_login_callback_test.go @@ -9,8 +9,10 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" "github.com/ministryofjustice/opg-modernising-lpa/internal/onelogin" + "github.com/ministryofjustice/opg-modernising-lpa/internal/scheduled" "github.com/ministryofjustice/opg-modernising-lpa/internal/sesh" "github.com/ministryofjustice/opg-modernising-lpa/internal/task" "github.com/stretchr/testify/assert" @@ -25,6 +27,8 @@ func TestGetIdentityWithOneLoginCallback(t *testing.T) { userInfo := onelogin.UserInfo{CoreIdentityJWT: "an-identity-jwt"} userData := identity.UserData{Status: identity.StatusConfirmed, FirstNames: "John", LastName: "Doe", RetrievedAt: now} updatedDonor := &donordata.Provided{ + PK: dynamo.LpaKey("hey"), + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("oh")), LpaID: "lpa-id", Donor: donordata.Donor{FirstNames: "John", LastName: "Doe"}, DonorIdentityUserData: userData, @@ -52,7 +56,19 @@ func TestGetIdentityWithOneLoginCallback(t *testing.T) { ParseIdentityClaim(r.Context(), userInfo). Return(userData, nil) - err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore)(testAppData, w, r, &donordata.Provided{ + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Put(r.Context(), scheduled.Event{ + At: now.AddDate(0, 6, 0), + Action: scheduled.ActionExpireDonorIdentity, + TargetLpaKey: dynamo.LpaKey("hey"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("oh")), + }). + Return(nil) + + err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore, scheduledStore)(testAppData, w, r, &donordata.Provided{ + PK: dynamo.LpaKey("hey"), + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("oh")), LpaID: "lpa-id", Donor: donordata.Donor{FirstNames: "John", LastName: "Doe"}, }) @@ -63,6 +79,50 @@ func TestGetIdentityWithOneLoginCallback(t *testing.T) { assert.Equal(t, donor.PathOneLoginIdentityDetails.Format("lpa-id"), resp.Header.Get("Location")) } +func TestGetIdentityWithOneLoginCallbackWhenScheduledStoreErrors(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/?code=a-code", nil) + now := time.Now() + + userInfo := onelogin.UserInfo{CoreIdentityJWT: "an-identity-jwt"} + userData := identity.UserData{Status: identity.StatusConfirmed, FirstNames: "John", LastName: "Doe", RetrievedAt: now} + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + Put(mock.Anything, mock.Anything). + Return(nil) + + sessionStore := newMockSessionStore(t) + sessionStore.EXPECT(). + OneLogin(mock.Anything). + Return(&sesh.OneLoginSession{State: "a-state", Nonce: "a-nonce", Redirect: "/redirect"}, nil) + + oneLoginClient := newMockOneLoginClient(t) + oneLoginClient.EXPECT(). + Exchange(mock.Anything, mock.Anything, mock.Anything). + Return("id-token", "a-jwt", nil) + oneLoginClient.EXPECT(). + UserInfo(mock.Anything, mock.Anything). + Return(userInfo, nil) + oneLoginClient.EXPECT(). + ParseIdentityClaim(mock.Anything, mock.Anything). + Return(userData, nil) + + scheduledStore := newMockScheduledStore(t) + scheduledStore.EXPECT(). + Put(mock.Anything, mock.Anything). + Return(expectedError) + + err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore, scheduledStore)(testAppData, w, r, &donordata.Provided{ + PK: dynamo.LpaKey("hey"), + SK: dynamo.LpaOwnerKey(dynamo.DonorKey("oh")), + LpaID: "lpa-id", + Donor: donordata.Donor{FirstNames: "John", LastName: "Doe"}, + }) + + assert.Equal(t, expectedError, err) +} + func TestGetIdentityWithOneLoginCallbackWhenIdentityNotConfirmed(t *testing.T) { userInfo := onelogin.UserInfo{CoreIdentityJWT: "an-identity-jwt"} @@ -182,7 +242,7 @@ func TestGetIdentityWithOneLoginCallbackWhenIdentityNotConfirmed(t *testing.T) { sessionStore := tc.sessionStore(t) oneLoginClient := tc.oneLoginClient(t) - err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, tc.donorStore(t))(testAppData, w, r, &donordata.Provided{}) + err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, tc.donorStore(t), nil)(testAppData, w, r, &donordata.Provided{}) resp := w.Result() assert.Equal(t, tc.error, err) @@ -222,7 +282,7 @@ func TestGetIdentityWithOneLoginCallbackWhenInsufficientEvidenceReturnCodeClaimP ParseIdentityClaim(mock.Anything, mock.Anything). Return(identity.UserData{Status: identity.StatusInsufficientEvidence}, nil) - err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore)(testAppData, w, r, &donordata.Provided{ + err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore, nil)(testAppData, w, r, &donordata.Provided{ Donor: donordata.Donor{FirstNames: "John", LastName: "Doe"}, LpaID: "lpa-id", }) @@ -264,7 +324,7 @@ func TestGetIdentityWithOneLoginCallbackWhenAnyOtherReturnCodeClaimPresent(t *te ParseIdentityClaim(mock.Anything, mock.Anything). Return(identity.UserData{Status: identity.StatusFailed}, nil) - err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore)(testAppData, w, r, &donordata.Provided{ + err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore, nil)(testAppData, w, r, &donordata.Provided{ Donor: donordata.Donor{FirstNames: "John", LastName: "Doe"}, LpaID: "lpa-id", }) @@ -301,7 +361,7 @@ func TestGetIdentityWithOneLoginCallbackWhenPutDonorStoreError(t *testing.T) { ParseIdentityClaim(mock.Anything, mock.Anything). Return(identity.UserData{Status: identity.StatusConfirmed}, nil) - err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore)(testAppData, w, r, &donordata.Provided{}) + err := IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore, nil)(testAppData, w, r, &donordata.Provided{}) assert.Equal(t, expectedError, err) } @@ -312,7 +372,7 @@ func TestGetIdentityWithOneLoginCallbackWhenReturning(t *testing.T) { now := time.Date(2012, time.January, 1, 2, 3, 4, 5, time.UTC) userData := identity.UserData{Status: identity.StatusConfirmed, FirstNames: "first-name", LastName: "last-name", RetrievedAt: now} - err := IdentityWithOneLoginCallback(nil, nil, nil)(testAppData, w, r, &donordata.Provided{ + err := IdentityWithOneLoginCallback(nil, nil, nil, nil)(testAppData, w, r, &donordata.Provided{ LpaID: "lpa-id", Donor: donordata.Donor{FirstNames: "first-name", LastName: "last-name"}, DonorIdentityUserData: userData, diff --git a/internal/donor/donorpage/mock_ScheduledStore_test.go b/internal/donor/donorpage/mock_ScheduledStore_test.go new file mode 100644 index 0000000000..2858fa701c --- /dev/null +++ b/internal/donor/donorpage/mock_ScheduledStore_test.go @@ -0,0 +1,84 @@ +// Code generated by mockery v2.45.0. DO NOT EDIT. + +package donorpage + +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} +} + +// Put provides a mock function with given fields: ctx, row +func (_m *mockScheduledStore) Put(ctx context.Context, row scheduled.Event) error { + ret := _m.Called(ctx, row) + + if len(ret) == 0 { + panic("no return value specified for Put") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, scheduled.Event) error); ok { + r0 = rf(ctx, row) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockScheduledStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' +type mockScheduledStore_Put_Call struct { + *mock.Call +} + +// Put is a helper method to define mock.On call +// - ctx context.Context +// - row scheduled.Event +func (_e *mockScheduledStore_Expecter) Put(ctx interface{}, row interface{}) *mockScheduledStore_Put_Call { + return &mockScheduledStore_Put_Call{Call: _e.mock.On("Put", ctx, row)} +} + +func (_c *mockScheduledStore_Put_Call) Run(run func(ctx context.Context, row scheduled.Event)) *mockScheduledStore_Put_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(scheduled.Event)) + }) + return _c +} + +func (_c *mockScheduledStore_Put_Call) Return(_a0 error) *mockScheduledStore_Put_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockScheduledStore_Put_Call) RunAndReturn(run func(context.Context, scheduled.Event) error) *mockScheduledStore_Put_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/donor/donorpage/register.go b/internal/donor/donorpage/register.go index 68d177ab1a..d65673db60 100644 --- a/internal/donor/donorpage/register.go +++ b/internal/donor/donorpage/register.go @@ -25,6 +25,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/pay" "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" "github.com/ministryofjustice/opg-modernising-lpa/internal/sharecode/sharecodedata" @@ -158,6 +159,10 @@ type ShareCodeStore interface { Get(ctx context.Context, actorType actor.Type, code string) (sharecodedata.Link, error) } +type ScheduledStore interface { + Put(ctx context.Context, row scheduled.Event) error +} + type ErrorHandler func(http.ResponseWriter, *http.Request, error) type ProgressTracker interface { @@ -187,6 +192,7 @@ func Register( shareCodeStore ShareCodeStore, progressTracker ProgressTracker, lpaStoreResolvingService LpaStoreResolvingService, + scheduledStore ScheduledStore, ) { payer := Pay(logger, sessionStore, donorStore, payClient, random.String, appPublicURL) @@ -382,7 +388,7 @@ func Register( handleWithDonor(donor.PathIdentityWithOneLogin, page.CanGoBack, IdentityWithOneLogin(oneLoginClient, sessionStore, random.String)) handleWithDonor(donor.PathIdentityWithOneLoginCallback, page.CanGoBack, - IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore)) + IdentityWithOneLoginCallback(oneLoginClient, sessionStore, donorStore, scheduledStore)) handleWithDonor(donor.PathOneLoginIdentityDetails, page.CanGoBack, OneLoginIdentityDetails(tmpls.Get("onelogin_identity_details.gohtml"), donorStore)) handleWithDonor(donor.PathRegisterWithCourtOfProtection, page.None, @@ -404,6 +410,8 @@ func Register( Guidance(tmpls.Get("we_have_contacted_voucher.gohtml"))) handleWithDonor(donor.PathWhatYouCanDoNow, page.CanGoBack, WhatYouCanDoNow(tmpls.Get("what_you_can_do_now.gohtml"), donorStore)) + handleWithDonor(donor.PathWhatYouCanDoNowExpired, page.CanGoBack, + WhatYouCanDoNow(tmpls.Get("what_you_can_do_now_expired.gohtml"), donorStore)) handleWithDonor(donor.PathWhatHappensNextRegisteringWithCourtOfProtection, page.None, Guidance(tmpls.Get("what_happens_next_registering_with_court_of_protection.gohtml"))) diff --git a/internal/donor/donorpage/register_test.go b/internal/donor/donorpage/register_test.go index bcf9155267..004b298fa9 100644 --- a/internal/donor/donorpage/register_test.go +++ b/internal/donor/donorpage/register_test.go @@ -13,7 +13,7 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/date" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor" "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" - lpastore "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore" + "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore" "github.com/ministryofjustice/opg-modernising-lpa/internal/lpastore/lpadata" "github.com/ministryofjustice/opg-modernising-lpa/internal/onelogin" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" @@ -27,7 +27,7 @@ import ( func TestRegister(t *testing.T) { mux := http.NewServeMux() - Register(mux, &slog.Logger{}, template.Templates{}, &mockSessionStore{}, &mockDonorStore{}, &onelogin.Client{}, &place.Client{}, "http://example.org", &pay.Client{}, &mockShareCodeSender{}, &mockWitnessCodeSender{}, nil, &mockCertificateProviderStore{}, &mockNotifyClient{}, &mockEvidenceReceivedStore{}, &mockDocumentStore{}, &mockEventClient{}, &mockDashboardStore{}, &mockLpaStoreClient{}, &mockShareCodeStore{}, &mockProgressTracker{}, &lpastore.ResolvingService{}) + Register(mux, &slog.Logger{}, template.Templates{}, &mockSessionStore{}, &mockDonorStore{}, &onelogin.Client{}, &place.Client{}, "http://example.org", &pay.Client{}, &mockShareCodeSender{}, &mockWitnessCodeSender{}, nil, &mockCertificateProviderStore{}, &mockNotifyClient{}, &mockEvidenceReceivedStore{}, &mockDocumentStore{}, &mockEventClient{}, &mockDashboardStore{}, &mockLpaStoreClient{}, &mockShareCodeStore{}, &mockProgressTracker{}, &lpastore.ResolvingService{}, &mockScheduledStore{}) assert.Implements(t, (*http.Handler)(nil), mux) } diff --git a/internal/donor/donorpage/task_list.go b/internal/donor/donorpage/task_list.go index 2d3f84c9e4..f6328dc3f0 100644 --- a/internal/donor/donorpage/task_list.go +++ b/internal/donor/donorpage/task_list.go @@ -192,6 +192,9 @@ func taskListSignSection(provided *donordata.Provided) taskListSection { case identity.StatusFailed: signPath = donor.PathRegisterWithCourtOfProtection + case identity.StatusExpired: + signPath = donor.PathWhatYouCanDoNowExpired + case identity.StatusInsufficientEvidence: if !provided.SignedAt.IsZero() { signPath = donor.PathYouHaveSubmittedYourLpa diff --git a/internal/donor/donorpage/task_list_test.go b/internal/donor/donorpage/task_list_test.go index a56f6bd3d1..efcb47e966 100644 --- a/internal/donor/donorpage/task_list_test.go +++ b/internal/donor/donorpage/task_list_test.go @@ -144,6 +144,21 @@ func TestGetTaskList(t *testing.T) { return sections }, }, + "expired identity": { + appData: testAppData, + donor: &donordata.Provided{ + LpaID: "lpa-id", + Donor: donordata.Donor{LastName: "a", Address: place.Address{Line1: "x"}}, + DonorIdentityUserData: identity.UserData{Status: identity.StatusExpired, LastName: "a"}, + }, + expected: func(sections []taskListSection) []taskListSection { + sections[2].Items = []taskListItem{ + {Name: "confirmYourIdentityAndSign", Path: donor.PathWhatYouCanDoNowExpired.Format("lpa-id")}, + } + + return sections + }, + }, "insufficient evidence for identity": { appData: testAppData, donor: &donordata.Provided{ diff --git a/internal/donor/path.go b/internal/donor/path.go index c6f0d96230..5f16fb8323 100644 --- a/internal/donor/path.go +++ b/internal/donor/path.go @@ -103,6 +103,7 @@ const ( PathWhatHappensNextRegisteringWithCourtOfProtection = Path("/what-happens-next-registering-with-court-of-protection") PathWhatIsVouching = Path("/what-is-vouching") PathWhatYouCanDoNow = Path("/what-you-can-do-now") + PathWhatYouCanDoNowExpired = Path("/what-you-can-do-now-expired") PathWhenCanTheLpaBeUsed = Path("/when-can-the-lpa-be-used") PathWhichFeeTypeAreYouApplyingFor = Path("/which-fee-type-are-you-applying-for") PathWithdrawThisLpa = Path("/withdraw-this-lpa") diff --git a/internal/donor/store.go b/internal/donor/store.go index 3db283473f..f5ef5c67c0 100644 --- a/internal/donor/store.go +++ b/internal/donor/store.go @@ -250,16 +250,21 @@ func (s *Store) Get(ctx context.Context) (*donordata.Provided, error) { sk = dynamo.OrganisationKey(data.OrganisationID) } + return s.One(ctx, dynamo.LpaKey(data.LpaID), sk) +} + +func (s *Store) One(ctx context.Context, pk dynamo.LpaKeyType, sk dynamo.SK) (*donordata.Provided, error) { var donor struct { donordata.Provided ReferencedSK dynamo.OrganisationKeyType } - if err := s.dynamoClient.One(ctx, dynamo.LpaKey(data.LpaID), sk, &donor); err != nil { + err := s.dynamoClient.One(ctx, pk, sk, &donor) + if err != nil { return nil, err } if donor.ReferencedSK != "" { - err = s.dynamoClient.One(ctx, dynamo.LpaKey(data.LpaID), donor.ReferencedSK, &donor) + err = s.dynamoClient.One(ctx, pk, donor.ReferencedSK, &donor) } return &donor.Provided, err diff --git a/internal/dynamo/client.go b/internal/dynamo/client.go index e52f122cb9..235338ead5 100644 --- a/internal/dynamo/client.go +++ b/internal/dynamo/client.go @@ -427,3 +427,47 @@ func (c *Client) BatchPut(ctx context.Context, values []interface{}) error { return err } + +func (c *Client) Move(ctx context.Context, oldKeys Keys, value any) error { + v, err := attributevalue.MarshalMap(value) + if err != nil { + return err + } + + _, err = c.svc.TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{ + TransactItems: []types.TransactWriteItem{ + { + Delete: &types.Delete{ + TableName: aws.String(c.table), + Key: map[string]types.AttributeValue{ + "PK": &types.AttributeValueMemberS{Value: oldKeys.PK.PK()}, + "SK": &types.AttributeValueMemberS{Value: oldKeys.SK.SK()}, + }, + ConditionExpression: aws.String("attribute_exists(PK) and attribute_exists(SK)"), + }, + }, + { + Put: &types.Put{ + TableName: aws.String(c.table), + Item: v, + }, + }, + }, + }) + + var conflictException *types.TransactionConflictException + if errors.As(err, &conflictException) { + return ConditionalCheckFailedError{} + } + + var canceledException *types.TransactionCanceledException + if errors.As(err, &canceledException) { + for _, reason := range canceledException.CancellationReasons { + if *reason.Code == "ConditionalCheckFailed" { + return ConditionalCheckFailedError{} + } + } + } + + return err +} diff --git a/internal/dynamo/client_test.go b/internal/dynamo/client_test.go index eb0e4b872b..ab6667e52f 100644 --- a/internal/dynamo/client_test.go +++ b/internal/dynamo/client_test.go @@ -12,7 +12,7 @@ import ( "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" "github.com/aws/smithy-go" "github.com/stretchr/testify/assert" - mock "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/mock" ) type testPK string @@ -23,7 +23,10 @@ type testSK string func (k testSK) SK() string { return string(k) } -var expectedError = errors.New("err") +var ( + ctx = context.Background() + expectedError = errors.New("err") +) func TestOne(t *testing.T) { ctx := context.Background() @@ -973,3 +976,78 @@ func TestOneBySkWhenQueryError(t *testing.T) { assert.Equal(t, expectedError, err) } + +func TestMove(t *testing.T) { + dynamoDB := newMockDynamoDB(t) + dynamoDB.EXPECT(). + TransactWriteItems(ctx, &dynamodb.TransactWriteItemsInput{ + TransactItems: []types.TransactWriteItem{ + { + Delete: &types.Delete{ + TableName: aws.String("this"), + Key: map[string]types.AttributeValue{ + "PK": &types.AttributeValueMemberS{Value: "a-pk"}, + "SK": &types.AttributeValueMemberS{Value: "an-sk"}, + }, + ConditionExpression: aws.String("attribute_exists(PK) and attribute_exists(SK)"), + }, + }, + { + Put: &types.Put{ + TableName: aws.String("this"), + Item: map[string]types.AttributeValue{ + "hey": &types.AttributeValueMemberS{Value: "hi"}, + }, + }, + }, + }, + }). + Return(nil, nil) + + c := &Client{table: "this", svc: dynamoDB} + err := c.Move(ctx, Keys{PK: testPK("a-pk"), SK: testSK("an-sk")}, map[string]string{"hey": "hi"}) + assert.Nil(t, err) +} + +func TestMoveWhenConflict(t *testing.T) { + dynamoDB := newMockDynamoDB(t) + dynamoDB.EXPECT(). + TransactWriteItems(mock.Anything, mock.Anything). + Return(nil, &types.TransactionConflictException{}) + + c := &Client{table: "this", svc: dynamoDB} + err := c.Move(ctx, Keys{PK: testPK("a-pk"), SK: testSK("an-sk")}, map[string]string{"hey": "hi"}) + assert.Equal(t, ConditionalCheckFailedError{}, err) +} + +func TestMoveWhenConditionalCheckFailed(t *testing.T) { + dynamoDB := newMockDynamoDB(t) + dynamoDB.EXPECT(). + TransactWriteItems(mock.Anything, mock.Anything). + Return(nil, &types.TransactionCanceledException{ + CancellationReasons: []types.CancellationReason{ + {Code: aws.String("ConditionalCheckFailed")}, + }, + }) + + c := &Client{table: "this", svc: dynamoDB} + err := c.Move(ctx, Keys{PK: testPK("a-pk"), SK: testSK("an-sk")}, map[string]string{"hey": "hi"}) + assert.Equal(t, ConditionalCheckFailedError{}, err) +} + +func TestMoveWhenOtherCancellation(t *testing.T) { + canceledException := &types.TransactionCanceledException{ + CancellationReasons: []types.CancellationReason{ + {Code: aws.String("What")}, + }, + } + + dynamoDB := newMockDynamoDB(t) + dynamoDB.EXPECT(). + TransactWriteItems(mock.Anything, mock.Anything). + Return(nil, canceledException) + + c := &Client{table: "this", svc: dynamoDB} + err := c.Move(ctx, Keys{PK: testPK("a-pk"), SK: testSK("an-sk")}, map[string]string{"hey": "hi"}) + assert.Equal(t, canceledException, err) +} diff --git a/internal/dynamo/keys.go b/internal/dynamo/keys.go index 8d3e7557ae..538206ab0e 100644 --- a/internal/dynamo/keys.go +++ b/internal/dynamo/keys.go @@ -4,7 +4,9 @@ import ( "encoding/base64" "errors" "fmt" + "strconv" "strings" + "time" ) const ( @@ -26,6 +28,8 @@ const ( certificateProviderSharePrefix = "CERTIFICATEPROVIDERSHARE" attorneySharePrefix = "ATTORNEYSHARE" voucherSharePrefix = "VOUCHERSHARE" + scheduledDayPrefix = "SCHEDULEDDAY" + scheduledPrefix = "SCHEDULED" ) func readKey(s string) (any, error) { @@ -71,6 +75,10 @@ func readKey(s string) (any, error) { return DonorInviteKeyType(s), nil case voucherPrefix: return VoucherKeyType(s), nil + case scheduledDayPrefix: + return ScheduledDayKeyType(s), nil + case scheduledPrefix: + return ScheduledKeyType(s), nil default: return nil, errors.New("unknown key prefix") } @@ -267,3 +275,25 @@ func (t VoucherShareKeyType) share() {} // mark as usable with ShareKey func VoucherShareKey(code string) VoucherShareKeyType { return VoucherShareKeyType(voucherSharePrefix + "#" + code) } + +type ScheduledDayKeyType string + +func (t ScheduledDayKeyType) PK() string { return string(t) } + +// ScheduledDayKey is used as the PK for a scheduled.Event. +func ScheduledDayKey(at time.Time) ScheduledDayKeyType { + return ScheduledDayKeyType(scheduledDayPrefix + "#" + at.Format(time.DateOnly)) +} + +func (t ScheduledDayKeyType) Handled() ScheduledDayKeyType { + return ScheduledDayKeyType(string(t) + "#HANDLED") +} + +type ScheduledKeyType string + +func (t ScheduledKeyType) SK() string { return string(t) } + +// ScheduledKey is used as the SK for a scheduled.Event. +func ScheduledKey(at time.Time, action int) ScheduledKeyType { + return ScheduledKeyType(scheduledPrefix + "#" + at.Format(time.RFC3339) + "#" + strconv.Itoa(action)) +} diff --git a/internal/dynamo/keys_test.go b/internal/dynamo/keys_test.go index 2f355fc0a9..73bd013a6b 100644 --- a/internal/dynamo/keys_test.go +++ b/internal/dynamo/keys_test.go @@ -3,6 +3,7 @@ package dynamo import ( "encoding/json" "testing" + "time" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" @@ -35,6 +36,7 @@ func TestPK(t *testing.T) { "CertificateProviderShareKey": {CertificateProviderShareKey("S"), "CERTIFICATEPROVIDERSHARE#S"}, "AttorneyShareKey": {AttorneyShareKey("S"), "ATTORNEYSHARE#S"}, "VoucherShareKey": {VoucherShareKey("S"), "VOUCHERSHARE#S"}, + "ScheduledDayKey": {ScheduledDayKey(time.Date(2024, time.January, 2, 12, 13, 14, 15, time.UTC)), "SCHEDULEDDAY#2024-01-02"}, } for name, tc := range testcases { @@ -80,6 +82,7 @@ func TestSK(t *testing.T) { "MetadataKey": {MetadataKey("S"), "METADATA#S"}, "DonorInviteKey": {DonorInviteKey(OrganisationKey("org-id"), LpaKey("lpa-id")), "DONORINVITE#org-id#lpa-id"}, "VoucherKey": {VoucherKey("S"), "VOUCHER#S"}, + "ScheduledKey": {ScheduledKey(time.Date(2024, time.January, 2, 12, 13, 14, 15, time.UTC), 99), "SCHEDULED#2024-01-02T12:13:14Z#99"}, } for name, tc := range testcases { @@ -124,3 +127,9 @@ func TestShareSortKeyTypes(t *testing.T) { key.shareSort() } } + +func TestScheduledDayKeyTypeHandled(t *testing.T) { + key := ScheduledDayKey(time.Now()) + + assert.Equal(t, key.PK()+"#HANDLED", key.Handled().PK()) +} diff --git a/internal/identity/enum_status.go b/internal/identity/enum_status.go index e7a4777c5a..211974aa65 100644 --- a/internal/identity/enum_status.go +++ b/internal/identity/enum_status.go @@ -15,11 +15,12 @@ func _() { _ = x[StatusConfirmed-1] _ = x[StatusFailed-2] _ = x[StatusInsufficientEvidence-3] + _ = x[StatusExpired-4] } -const _Status_name = "unknownconfirmedfailedinsufficient-evidence" +const _Status_name = "unknownconfirmedfailedinsufficient-evidenceexpired" -var _Status_index = [...]uint8{0, 7, 16, 22, 43} +var _Status_index = [...]uint8{0, 7, 16, 22, 43, 50} func (i Status) String() string { if i >= Status(len(_Status_index)-1) { @@ -58,6 +59,10 @@ func (i Status) IsInsufficientEvidence() bool { return i == StatusInsufficientEvidence } +func (i Status) IsExpired() bool { + return i == StatusExpired +} + func ParseStatus(s string) (Status, error) { switch s { case "unknown": @@ -68,6 +73,8 @@ func ParseStatus(s string) (Status, error) { return StatusFailed, nil case "insufficient-evidence": return StatusInsufficientEvidence, nil + case "expired": + return StatusExpired, nil default: return Status(0), fmt.Errorf("invalid Status '%s'", s) } @@ -78,6 +85,7 @@ type StatusOptions struct { Confirmed Status Failed Status InsufficientEvidence Status + Expired Status } var StatusValues = StatusOptions{ @@ -85,4 +93,5 @@ var StatusValues = StatusOptions{ Confirmed: StatusConfirmed, Failed: StatusFailed, InsufficientEvidence: StatusInsufficientEvidence, + Expired: StatusExpired, } diff --git a/internal/identity/status.go b/internal/identity/status.go index 8ae86aac96..55c48b0ba3 100644 --- a/internal/identity/status.go +++ b/internal/identity/status.go @@ -8,4 +8,5 @@ const ( StatusConfirmed // confirmed StatusFailed // failed StatusInsufficientEvidence // insufficient-evidence + StatusExpired // expired ) diff --git a/internal/notify/email.go b/internal/notify/email.go index 450c2e25ea..dfcd0f3334 100644 --- a/internal/notify/email.go +++ b/internal/notify/email.go @@ -221,3 +221,9 @@ type VoucherFailedIdentityCheckEmail struct { func (e VoucherFailedIdentityCheckEmail) emailID(isProduction bool) string { return "TODO" } + +type DonorIdentityCheckExpiredEmail struct{} + +func (e DonorIdentityCheckExpiredEmail) emailID(isProduction bool) string { + return "TODO" +} diff --git a/internal/scheduled/action.go b/internal/scheduled/action.go new file mode 100644 index 0000000000..b58f0c1f98 --- /dev/null +++ b/internal/scheduled/action.go @@ -0,0 +1,11 @@ +package scheduled + +//go:generate enumerator -type Action -trimprefix +type Action uint8 + +const ( + // ActionExpireDonorIdentity will check that the target donor has not signed + // their LPA, and if so remove their identity data and notify them of the + // change. + ActionExpireDonorIdentity Action = iota + 1 +) diff --git a/internal/scheduled/enum_action.go b/internal/scheduled/enum_action.go new file mode 100644 index 0000000000..735f2123e4 --- /dev/null +++ b/internal/scheduled/enum_action.go @@ -0,0 +1,62 @@ +// Code generated by "enumerator -type Action -trimprefix"; DO NOT EDIT. + +package scheduled + +import ( + "fmt" + "strconv" +) + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[ActionExpireDonorIdentity-1] +} + +const _Action_name = "ExpireDonorIdentity" + +var _Action_index = [...]uint8{0, 19} + +func (i Action) String() string { + i -= 1 + if i >= Action(len(_Action_index)-1) { + return "Action(" + strconv.FormatInt(int64(i+1), 10) + ")" + } + return _Action_name[_Action_index[i]:_Action_index[i+1]] +} + +func (i Action) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +func (i *Action) UnmarshalText(text []byte) error { + val, err := ParseAction(string(text)) + if err != nil { + return err + } + + *i = val + return nil +} + +func (i Action) IsExpireDonorIdentity() bool { + return i == ActionExpireDonorIdentity +} + +func ParseAction(s string) (Action, error) { + switch s { + case "ExpireDonorIdentity": + return ActionExpireDonorIdentity, nil + default: + return Action(0), fmt.Errorf("invalid Action '%s'", s) + } +} + +type ActionOptions struct { + ExpireDonorIdentity Action +} + +var ActionValues = ActionOptions{ + ExpireDonorIdentity: ActionExpireDonorIdentity, +} diff --git a/internal/scheduled/event.go b/internal/scheduled/event.go new file mode 100644 index 0000000000..ffee5e63ec --- /dev/null +++ b/internal/scheduled/event.go @@ -0,0 +1,23 @@ +package scheduled + +import ( + "time" + + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" +) + +// A Event specifies an action to take in the future. +type Event struct { + PK dynamo.ScheduledDayKeyType + SK dynamo.ScheduledKeyType + // CreatedAt is when the event was created + CreatedAt time.Time + // At is when the action should be done + At time.Time + // Action is what to do when run + Action Action + // TargetLpaKey is used to specify the target of the action + TargetLpaKey dynamo.LpaKeyType + // TargetLpaOwnerKey is used to specify the target of the action + TargetLpaOwnerKey dynamo.LpaOwnerKeyType +} diff --git a/internal/scheduled/mock_ActionFunc_test.go b/internal/scheduled/mock_ActionFunc_test.go new file mode 100644 index 0000000000..dfe0404e65 --- /dev/null +++ b/internal/scheduled/mock_ActionFunc_test.go @@ -0,0 +1,83 @@ +// Code generated by mockery v2.45.0. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// mockActionFunc is an autogenerated mock type for the ActionFunc type +type mockActionFunc struct { + mock.Mock +} + +type mockActionFunc_Expecter struct { + mock *mock.Mock +} + +func (_m *mockActionFunc) EXPECT() *mockActionFunc_Expecter { + return &mockActionFunc_Expecter{mock: &_m.Mock} +} + +// Execute provides a mock function with given fields: ctx, row +func (_m *mockActionFunc) Execute(ctx context.Context, row *Event) error { + ret := _m.Called(ctx, row) + + if len(ret) == 0 { + panic("no return value specified for Execute") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *Event) error); ok { + r0 = rf(ctx, row) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockActionFunc_Execute_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Execute' +type mockActionFunc_Execute_Call struct { + *mock.Call +} + +// Execute is a helper method to define mock.On call +// - ctx context.Context +// - row *Event +func (_e *mockActionFunc_Expecter) Execute(ctx interface{}, row interface{}) *mockActionFunc_Execute_Call { + return &mockActionFunc_Execute_Call{Call: _e.mock.On("Execute", ctx, row)} +} + +func (_c *mockActionFunc_Execute_Call) Run(run func(ctx context.Context, row *Event)) *mockActionFunc_Execute_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*Event)) + }) + return _c +} + +func (_c *mockActionFunc_Execute_Call) Return(_a0 error) *mockActionFunc_Execute_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockActionFunc_Execute_Call) RunAndReturn(run func(context.Context, *Event) error) *mockActionFunc_Execute_Call { + _c.Call.Return(run) + return _c +} + +// newMockActionFunc creates a new instance of mockActionFunc. 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 newMockActionFunc(t interface { + mock.TestingT + Cleanup(func()) +}) *mockActionFunc { + mock := &mockActionFunc{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_DonorStore_test.go b/internal/scheduled/mock_DonorStore_test.go new file mode 100644 index 0000000000..555918ce3d --- /dev/null +++ b/internal/scheduled/mock_DonorStore_test.go @@ -0,0 +1,146 @@ +// Code generated by mockery v2.45.0. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + donordata "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" + dynamo "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + + mock "github.com/stretchr/testify/mock" +) + +// mockDonorStore is an autogenerated mock type for the DonorStore type +type mockDonorStore struct { + mock.Mock +} + +type mockDonorStore_Expecter struct { + mock *mock.Mock +} + +func (_m *mockDonorStore) EXPECT() *mockDonorStore_Expecter { + return &mockDonorStore_Expecter{mock: &_m.Mock} +} + +// One provides a mock function with given fields: ctx, pk, sk +func (_m *mockDonorStore) One(ctx context.Context, pk dynamo.LpaKeyType, sk dynamo.SK) (*donordata.Provided, error) { + ret := _m.Called(ctx, pk, sk) + + if len(ret) == 0 { + panic("no return value specified for One") + } + + var r0 *donordata.Provided + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, dynamo.LpaKeyType, dynamo.SK) (*donordata.Provided, error)); ok { + return rf(ctx, pk, sk) + } + if rf, ok := ret.Get(0).(func(context.Context, dynamo.LpaKeyType, dynamo.SK) *donordata.Provided); ok { + r0 = rf(ctx, pk, sk) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*donordata.Provided) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, dynamo.LpaKeyType, dynamo.SK) error); ok { + r1 = rf(ctx, pk, sk) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockDonorStore_One_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'One' +type mockDonorStore_One_Call struct { + *mock.Call +} + +// One is a helper method to define mock.On call +// - ctx context.Context +// - pk dynamo.LpaKeyType +// - sk dynamo.SK +func (_e *mockDonorStore_Expecter) One(ctx interface{}, pk interface{}, sk interface{}) *mockDonorStore_One_Call { + return &mockDonorStore_One_Call{Call: _e.mock.On("One", ctx, pk, sk)} +} + +func (_c *mockDonorStore_One_Call) Run(run func(ctx context.Context, pk dynamo.LpaKeyType, sk dynamo.SK)) *mockDonorStore_One_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(dynamo.LpaKeyType), args[2].(dynamo.SK)) + }) + return _c +} + +func (_c *mockDonorStore_One_Call) Return(_a0 *donordata.Provided, _a1 error) *mockDonorStore_One_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockDonorStore_One_Call) RunAndReturn(run func(context.Context, dynamo.LpaKeyType, dynamo.SK) (*donordata.Provided, error)) *mockDonorStore_One_Call { + _c.Call.Return(run) + return _c +} + +// Put provides a mock function with given fields: ctx, provided +func (_m *mockDonorStore) Put(ctx context.Context, provided *donordata.Provided) error { + ret := _m.Called(ctx, provided) + + if len(ret) == 0 { + panic("no return value specified for Put") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *donordata.Provided) error); ok { + r0 = rf(ctx, provided) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockDonorStore_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' +type mockDonorStore_Put_Call struct { + *mock.Call +} + +// Put is a helper method to define mock.On call +// - ctx context.Context +// - provided *donordata.Provided +func (_e *mockDonorStore_Expecter) Put(ctx interface{}, provided interface{}) *mockDonorStore_Put_Call { + return &mockDonorStore_Put_Call{Call: _e.mock.On("Put", ctx, provided)} +} + +func (_c *mockDonorStore_Put_Call) Run(run func(ctx context.Context, provided *donordata.Provided)) *mockDonorStore_Put_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*donordata.Provided)) + }) + return _c +} + +func (_c *mockDonorStore_Put_Call) Return(_a0 error) *mockDonorStore_Put_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockDonorStore_Put_Call) RunAndReturn(run func(context.Context, *donordata.Provided) error) *mockDonorStore_Put_Call { + _c.Call.Return(run) + return _c +} + +// newMockDonorStore creates a new instance of mockDonorStore. 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 newMockDonorStore(t interface { + mock.TestingT + Cleanup(func()) +}) *mockDonorStore { + mock := &mockDonorStore{} + 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 new file mode 100644 index 0000000000..f6125293ed --- /dev/null +++ b/internal/scheduled/mock_DynamoClient_test.go @@ -0,0 +1,180 @@ +// Code generated by mockery v2.45.0. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + dynamo "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + mock "github.com/stretchr/testify/mock" +) + +// mockDynamoClient is an autogenerated mock type for the DynamoClient type +type mockDynamoClient struct { + mock.Mock +} + +type mockDynamoClient_Expecter struct { + mock *mock.Mock +} + +func (_m *mockDynamoClient) EXPECT() *mockDynamoClient_Expecter { + return &mockDynamoClient_Expecter{mock: &_m.Mock} +} + +// Move provides a mock function with given fields: ctx, oldKeys, value +func (_m *mockDynamoClient) Move(ctx context.Context, oldKeys dynamo.Keys, value interface{}) error { + ret := _m.Called(ctx, oldKeys, value) + + if len(ret) == 0 { + panic("no return value specified for Move") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, dynamo.Keys, interface{}) error); ok { + r0 = rf(ctx, oldKeys, value) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockDynamoClient_Move_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Move' +type mockDynamoClient_Move_Call struct { + *mock.Call +} + +// Move is a helper method to define mock.On call +// - ctx context.Context +// - oldKeys dynamo.Keys +// - value interface{} +func (_e *mockDynamoClient_Expecter) Move(ctx interface{}, oldKeys interface{}, value interface{}) *mockDynamoClient_Move_Call { + return &mockDynamoClient_Move_Call{Call: _e.mock.On("Move", ctx, oldKeys, value)} +} + +func (_c *mockDynamoClient_Move_Call) Run(run func(ctx context.Context, oldKeys dynamo.Keys, value interface{})) *mockDynamoClient_Move_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(dynamo.Keys), args[2].(interface{})) + }) + return _c +} + +func (_c *mockDynamoClient_Move_Call) Return(_a0 error) *mockDynamoClient_Move_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockDynamoClient_Move_Call) RunAndReturn(run func(context.Context, dynamo.Keys, interface{}) error) *mockDynamoClient_Move_Call { + _c.Call.Return(run) + return _c +} + +// OneByPK provides a mock function with given fields: ctx, pk, v +func (_m *mockDynamoClient) OneByPK(ctx context.Context, pk dynamo.PK, v interface{}) error { + ret := _m.Called(ctx, pk, v) + + if len(ret) == 0 { + panic("no return value specified for OneByPK") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, dynamo.PK, interface{}) error); ok { + r0 = rf(ctx, pk, v) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockDynamoClient_OneByPK_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OneByPK' +type mockDynamoClient_OneByPK_Call struct { + *mock.Call +} + +// OneByPK is a helper method to define mock.On call +// - ctx context.Context +// - pk dynamo.PK +// - v interface{} +func (_e *mockDynamoClient_Expecter) OneByPK(ctx interface{}, pk interface{}, v interface{}) *mockDynamoClient_OneByPK_Call { + return &mockDynamoClient_OneByPK_Call{Call: _e.mock.On("OneByPK", ctx, pk, v)} +} + +func (_c *mockDynamoClient_OneByPK_Call) Run(run func(ctx context.Context, pk dynamo.PK, v interface{})) *mockDynamoClient_OneByPK_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(dynamo.PK), args[2].(interface{})) + }) + return _c +} + +func (_c *mockDynamoClient_OneByPK_Call) Return(_a0 error) *mockDynamoClient_OneByPK_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockDynamoClient_OneByPK_Call) RunAndReturn(run func(context.Context, dynamo.PK, interface{}) error) *mockDynamoClient_OneByPK_Call { + _c.Call.Return(run) + return _c +} + +// Put provides a mock function with given fields: ctx, v +func (_m *mockDynamoClient) Put(ctx context.Context, v interface{}) error { + ret := _m.Called(ctx, v) + + if len(ret) == 0 { + panic("no return value specified for Put") + } + + 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_Put_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Put' +type mockDynamoClient_Put_Call struct { + *mock.Call +} + +// Put is a helper method to define mock.On call +// - ctx context.Context +// - v interface{} +func (_e *mockDynamoClient_Expecter) Put(ctx interface{}, v interface{}) *mockDynamoClient_Put_Call { + return &mockDynamoClient_Put_Call{Call: _e.mock.On("Put", ctx, v)} +} + +func (_c *mockDynamoClient_Put_Call) Run(run func(ctx context.Context, v interface{})) *mockDynamoClient_Put_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(interface{})) + }) + return _c +} + +func (_c *mockDynamoClient_Put_Call) Return(_a0 error) *mockDynamoClient_Put_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockDynamoClient_Put_Call) RunAndReturn(run func(context.Context, interface{}) error) *mockDynamoClient_Put_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 { + mock.TestingT + Cleanup(func()) +}) *mockDynamoClient { + mock := &mockDynamoClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_Logger_test.go b/internal/scheduled/mock_Logger_test.go new file mode 100644 index 0000000000..451cc016e4 --- /dev/null +++ b/internal/scheduled/mock_Logger_test.go @@ -0,0 +1,126 @@ +// Code generated by mockery v2.45.0. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// mockLogger is an autogenerated mock type for the Logger type +type mockLogger struct { + mock.Mock +} + +type mockLogger_Expecter struct { + mock *mock.Mock +} + +func (_m *mockLogger) EXPECT() *mockLogger_Expecter { + return &mockLogger_Expecter{mock: &_m.Mock} +} + +// ErrorContext provides a mock function with given fields: ctx, msg, args +func (_m *mockLogger) ErrorContext(ctx context.Context, msg string, args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, ctx, msg) + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// mockLogger_ErrorContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ErrorContext' +type mockLogger_ErrorContext_Call struct { + *mock.Call +} + +// ErrorContext is a helper method to define mock.On call +// - ctx context.Context +// - msg string +// - args ...interface{} +func (_e *mockLogger_Expecter) ErrorContext(ctx interface{}, msg interface{}, args ...interface{}) *mockLogger_ErrorContext_Call { + return &mockLogger_ErrorContext_Call{Call: _e.mock.On("ErrorContext", + append([]interface{}{ctx, msg}, args...)...)} +} + +func (_c *mockLogger_ErrorContext_Call) Run(run func(ctx context.Context, msg string, args ...interface{})) *mockLogger_ErrorContext_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(context.Context), args[1].(string), variadicArgs...) + }) + return _c +} + +func (_c *mockLogger_ErrorContext_Call) Return() *mockLogger_ErrorContext_Call { + _c.Call.Return() + return _c +} + +func (_c *mockLogger_ErrorContext_Call) RunAndReturn(run func(context.Context, string, ...interface{})) *mockLogger_ErrorContext_Call { + _c.Call.Return(run) + return _c +} + +// InfoContext provides a mock function with given fields: ctx, msg, args +func (_m *mockLogger) InfoContext(ctx context.Context, msg string, args ...interface{}) { + var _ca []interface{} + _ca = append(_ca, ctx, msg) + _ca = append(_ca, args...) + _m.Called(_ca...) +} + +// mockLogger_InfoContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InfoContext' +type mockLogger_InfoContext_Call struct { + *mock.Call +} + +// InfoContext is a helper method to define mock.On call +// - ctx context.Context +// - msg string +// - args ...interface{} +func (_e *mockLogger_Expecter) InfoContext(ctx interface{}, msg interface{}, args ...interface{}) *mockLogger_InfoContext_Call { + return &mockLogger_InfoContext_Call{Call: _e.mock.On("InfoContext", + append([]interface{}{ctx, msg}, args...)...)} +} + +func (_c *mockLogger_InfoContext_Call) Run(run func(ctx context.Context, msg string, args ...interface{})) *mockLogger_InfoContext_Call { + _c.Call.Run(func(args mock.Arguments) { + variadicArgs := make([]interface{}, len(args)-2) + for i, a := range args[2:] { + if a != nil { + variadicArgs[i] = a.(interface{}) + } + } + run(args[0].(context.Context), args[1].(string), variadicArgs...) + }) + return _c +} + +func (_c *mockLogger_InfoContext_Call) Return() *mockLogger_InfoContext_Call { + _c.Call.Return() + return _c +} + +func (_c *mockLogger_InfoContext_Call) RunAndReturn(run func(context.Context, string, ...interface{})) *mockLogger_InfoContext_Call { + _c.Call.Return(run) + return _c +} + +// newMockLogger creates a new instance of mockLogger. 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 newMockLogger(t interface { + mock.TestingT + Cleanup(func()) +}) *mockLogger { + mock := &mockLogger{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_NotifyClient_test.go b/internal/scheduled/mock_NotifyClient_test.go new file mode 100644 index 0000000000..ae6ae835d9 --- /dev/null +++ b/internal/scheduled/mock_NotifyClient_test.go @@ -0,0 +1,86 @@ +// Code generated by mockery v2.45.0. DO NOT EDIT. + +package scheduled + +import ( + context "context" + + notify "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" + mock "github.com/stretchr/testify/mock" +) + +// mockNotifyClient is an autogenerated mock type for the NotifyClient type +type mockNotifyClient struct { + mock.Mock +} + +type mockNotifyClient_Expecter struct { + mock *mock.Mock +} + +func (_m *mockNotifyClient) EXPECT() *mockNotifyClient_Expecter { + return &mockNotifyClient_Expecter{mock: &_m.Mock} +} + +// SendActorEmail provides a mock function with given fields: ctx, to, lpaUID, email +func (_m *mockNotifyClient) SendActorEmail(ctx context.Context, to string, lpaUID string, email notify.Email) error { + ret := _m.Called(ctx, to, lpaUID, email) + + if len(ret) == 0 { + panic("no return value specified for SendActorEmail") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, notify.Email) error); ok { + r0 = rf(ctx, to, lpaUID, email) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockNotifyClient_SendActorEmail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendActorEmail' +type mockNotifyClient_SendActorEmail_Call struct { + *mock.Call +} + +// SendActorEmail is a helper method to define mock.On call +// - ctx context.Context +// - to string +// - lpaUID string +// - email notify.Email +func (_e *mockNotifyClient_Expecter) SendActorEmail(ctx interface{}, to interface{}, lpaUID interface{}, email interface{}) *mockNotifyClient_SendActorEmail_Call { + return &mockNotifyClient_SendActorEmail_Call{Call: _e.mock.On("SendActorEmail", ctx, to, lpaUID, email)} +} + +func (_c *mockNotifyClient_SendActorEmail_Call) Run(run func(ctx context.Context, to string, lpaUID string, email notify.Email)) *mockNotifyClient_SendActorEmail_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(notify.Email)) + }) + return _c +} + +func (_c *mockNotifyClient_SendActorEmail_Call) Return(_a0 error) *mockNotifyClient_SendActorEmail_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockNotifyClient_SendActorEmail_Call) RunAndReturn(run func(context.Context, string, string, notify.Email) error) *mockNotifyClient_SendActorEmail_Call { + _c.Call.Return(run) + return _c +} + +// newMockNotifyClient creates a new instance of mockNotifyClient. 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 newMockNotifyClient(t interface { + mock.TestingT + Cleanup(func()) +}) *mockNotifyClient { + mock := &mockNotifyClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/mock_ScheduledStore_test.go b/internal/scheduled/mock_ScheduledStore_test.go new file mode 100644 index 0000000000..665e5b0a7d --- /dev/null +++ b/internal/scheduled/mock_ScheduledStore_test.go @@ -0,0 +1,96 @@ +// Code generated by mockery v2.45.0. DO NOT EDIT. + +package scheduled + +import ( + context "context" + time "time" + + 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} +} + +// Pop provides a mock function with given fields: ctx, at +func (_m *mockScheduledStore) Pop(ctx context.Context, at time.Time) (*Event, error) { + ret := _m.Called(ctx, at) + + if len(ret) == 0 { + panic("no return value specified for Pop") + } + + var r0 *Event + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, time.Time) (*Event, error)); ok { + return rf(ctx, at) + } + if rf, ok := ret.Get(0).(func(context.Context, time.Time) *Event); ok { + r0 = rf(ctx, at) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Event) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, time.Time) error); ok { + r1 = rf(ctx, at) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// mockScheduledStore_Pop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pop' +type mockScheduledStore_Pop_Call struct { + *mock.Call +} + +// Pop is a helper method to define mock.On call +// - ctx context.Context +// - at time.Time +func (_e *mockScheduledStore_Expecter) Pop(ctx interface{}, at interface{}) *mockScheduledStore_Pop_Call { + return &mockScheduledStore_Pop_Call{Call: _e.mock.On("Pop", ctx, at)} +} + +func (_c *mockScheduledStore_Pop_Call) Run(run func(ctx context.Context, at time.Time)) *mockScheduledStore_Pop_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(time.Time)) + }) + return _c +} + +func (_c *mockScheduledStore_Pop_Call) Return(_a0 *Event, _a1 error) *mockScheduledStore_Pop_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *mockScheduledStore_Pop_Call) RunAndReturn(run func(context.Context, time.Time) (*Event, error)) *mockScheduledStore_Pop_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/scheduled/mock_Waiter_test.go b/internal/scheduled/mock_Waiter_test.go new file mode 100644 index 0000000000..15a87ee470 --- /dev/null +++ b/internal/scheduled/mock_Waiter_test.go @@ -0,0 +1,109 @@ +// Code generated by mockery v2.45.0. DO NOT EDIT. + +package scheduled + +import mock "github.com/stretchr/testify/mock" + +// mockWaiter is an autogenerated mock type for the Waiter type +type mockWaiter struct { + mock.Mock +} + +type mockWaiter_Expecter struct { + mock *mock.Mock +} + +func (_m *mockWaiter) EXPECT() *mockWaiter_Expecter { + return &mockWaiter_Expecter{mock: &_m.Mock} +} + +// Reset provides a mock function with given fields: +func (_m *mockWaiter) Reset() { + _m.Called() +} + +// mockWaiter_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset' +type mockWaiter_Reset_Call struct { + *mock.Call +} + +// Reset is a helper method to define mock.On call +func (_e *mockWaiter_Expecter) Reset() *mockWaiter_Reset_Call { + return &mockWaiter_Reset_Call{Call: _e.mock.On("Reset")} +} + +func (_c *mockWaiter_Reset_Call) Run(run func()) *mockWaiter_Reset_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockWaiter_Reset_Call) Return() *mockWaiter_Reset_Call { + _c.Call.Return() + return _c +} + +func (_c *mockWaiter_Reset_Call) RunAndReturn(run func()) *mockWaiter_Reset_Call { + _c.Call.Return(run) + return _c +} + +// Wait provides a mock function with given fields: +func (_m *mockWaiter) Wait() error { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Wait") + } + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// mockWaiter_Wait_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Wait' +type mockWaiter_Wait_Call struct { + *mock.Call +} + +// Wait is a helper method to define mock.On call +func (_e *mockWaiter_Expecter) Wait() *mockWaiter_Wait_Call { + return &mockWaiter_Wait_Call{Call: _e.mock.On("Wait")} +} + +func (_c *mockWaiter_Wait_Call) Run(run func()) *mockWaiter_Wait_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *mockWaiter_Wait_Call) Return(_a0 error) *mockWaiter_Wait_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *mockWaiter_Wait_Call) RunAndReturn(run func() error) *mockWaiter_Wait_Call { + _c.Call.Return(run) + return _c +} + +// newMockWaiter creates a new instance of mockWaiter. 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 newMockWaiter(t interface { + mock.TestingT + Cleanup(func()) +}) *mockWaiter { + mock := &mockWaiter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/scheduled/runner.go b/internal/scheduled/runner.go new file mode 100644 index 0000000000..3c4f926aa5 --- /dev/null +++ b/internal/scheduled/runner.go @@ -0,0 +1,170 @@ +package scheduled + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" + "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" + "github.com/ministryofjustice/opg-modernising-lpa/internal/task" +) + +// errStepIgnored is returned by steps when they don't require processing +var errStepIgnored = errors.New("step ignored") + +type ActionFunc func(ctx context.Context, row *Event) error + +type ScheduledStore interface { + Pop(ctx context.Context, at time.Time) (*Event, error) +} + +type DonorStore interface { + One(ctx context.Context, pk dynamo.LpaKeyType, sk dynamo.SK) (*donordata.Provided, error) + Put(ctx context.Context, provided *donordata.Provided) error +} + +type NotifyClient interface { + SendActorEmail(ctx context.Context, to, lpaUID string, email notify.Email) error +} + +type Logger interface { + InfoContext(ctx context.Context, msg string, args ...any) + ErrorContext(ctx context.Context, msg string, args ...any) +} + +type Waiter interface { + Reset() + Wait() error +} + +type Runner struct { + logger Logger + store ScheduledStore + now func() time.Time + period time.Duration + donorStore DonorStore + notifyClient NotifyClient + actions map[Action]ActionFunc + waiter Waiter +} + +func NewRunner(logger Logger, store ScheduledStore, donorStore DonorStore, notifyClient NotifyClient, period time.Duration) *Runner { + r := &Runner{ + logger: logger, + store: store, + now: time.Now, + period: period, + donorStore: donorStore, + notifyClient: notifyClient, + waiter: &waiter{backoff: time.Second, sleep: time.Sleep, maxRetries: 10}, + } + + r.actions = map[Action]ActionFunc{ + ActionExpireDonorIdentity: r.stepCancelDonorIdentity, + } + + return r +} + +// Run the Runner, it is expected to be called in a Go routine. +func (r *Runner) Run(ctx context.Context) error { + ticker := time.Tick(r.period) + + for { + innerCtx, cancel := context.WithTimeout(ctx, r.period) + defer cancel() + + r.logger.InfoContext(ctx, "runner step started") + if err := r.step(innerCtx); err != nil { + r.logger.ErrorContext(ctx, "runner step error", slog.Any("err", err)) + } + r.logger.InfoContext(ctx, "runner step finished") + + select { + case <-ctx.Done(): + return nil + case <-ticker: + continue + } + } +} + +func (r *Runner) step(ctx context.Context) error { + r.waiter.Reset() + + for { + row, err := r.store.Pop(ctx, r.now()) + if errors.Is(err, dynamo.NotFoundError{}) { + return nil + } else if errors.Is(err, dynamo.ConditionalCheckFailedError{}) { + r.logger.InfoContext(ctx, "runner conditional check failed") + if err := r.waiter.Wait(); err != nil { + return err + } + continue + } else if err != nil { + return err + } + + r.waiter.Reset() + r.logger.InfoContext(ctx, "runner action", slog.String("action", row.Action.String())) + + if fn, ok := r.actions[row.Action]; ok { + if err := fn(ctx, row); err != nil { + if errors.Is(err, errStepIgnored) { + r.logger.InfoContext(ctx, "runner action ignored", + slog.String("action", row.Action.String()), + slog.String("target_pk", row.TargetLpaKey.PK()), + slog.String("target_sk", row.TargetLpaOwnerKey.SK())) + } else { + r.logger.ErrorContext(ctx, "runner action error", + slog.String("action", row.Action.String()), + slog.String("target_pk", row.TargetLpaKey.PK()), + slog.String("target_sk", row.TargetLpaOwnerKey.SK()), + slog.Any("err", err)) + } + } else { + r.logger.InfoContext(ctx, "runner action success", + slog.String("action", row.Action.String()), + slog.String("target_pk", row.TargetLpaKey.PK()), + slog.String("target_sk", row.TargetLpaOwnerKey.SK())) + } + } + + select { + case <-ctx.Done(): + return nil + default: + continue + } + } +} + +func (r *Runner) stepCancelDonorIdentity(ctx context.Context, row *Event) error { + provided, err := r.donorStore.One(ctx, row.TargetLpaKey, row.TargetLpaOwnerKey) + if err != nil { + return fmt.Errorf("error retrieving donor: %w", err) + } + + if !provided.DonorIdentityUserData.Status.IsConfirmed() || !provided.SignedAt.IsZero() { + return errStepIgnored + } + + provided.DonorIdentityUserData = identity.UserData{Status: identity.StatusExpired} + provided.Tasks.ConfirmYourIdentityAndSign = task.IdentityStateNotStarted + + if err := r.notifyClient.SendActorEmail(ctx, provided.CorrespondentEmail(), provided.LpaUID, notify.DonorIdentityCheckExpiredEmail{}); err != nil { + return fmt.Errorf("error sending email: %w", err) + } + + if err := r.donorStore.Put(ctx, provided); err != nil { + return fmt.Errorf("error updating donor: %w", err) + } + + return nil +} diff --git a/internal/scheduled/runner_test.go b/internal/scheduled/runner_test.go new file mode 100644 index 0000000000..33f440651a --- /dev/null +++ b/internal/scheduled/runner_test.go @@ -0,0 +1,544 @@ +package scheduled + +import ( + "context" + "errors" + "log/slog" + "testing" + "time" + + "github.com/ministryofjustice/opg-modernising-lpa/internal/donor/donordata" + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/ministryofjustice/opg-modernising-lpa/internal/identity" + "github.com/ministryofjustice/opg-modernising-lpa/internal/notify" + "github.com/ministryofjustice/opg-modernising-lpa/internal/task" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var ( + ctx = context.WithValue(context.Background(), (*string)(nil), "value") + expectedError = errors.New("hey") + testNow = time.Now() + testNowFn = func() time.Time { return testNow } + + // set resolution lower to make tests more accurate, but the clock won't be + // perfect so 2ms seems a reasonable trade-off + resolution = 2 * time.Millisecond + // set period higher to make tests more accurate, but that will make them + // slower + period = 20 * resolution +) + +func (m *mockScheduledStore) ExpectPops(returns ...any) { + for i := 0; i < len(returns); i += 2 { + var ev *Event + if returns[i] != nil { + ev = returns[i].(*Event) + } + + var err error + if returns[i+1] != nil { + err = returns[i+1].(error) + } + + m.EXPECT().Pop(mock.Anything, mock.Anything).Return(ev, err).Once() + } +} + +func TestNewRunner(t *testing.T) { + logger := newMockLogger(t) + store := newMockScheduledStore(t) + donorStore := newMockDonorStore(t) + notifyClient := newMockNotifyClient(t) + + runner := NewRunner(logger, store, donorStore, notifyClient, time.Hour) + + assert.Equal(t, logger, runner.logger) + assert.Equal(t, store, runner.store) + assert.Equal(t, donorStore, runner.donorStore) + assert.Equal(t, notifyClient, runner.notifyClient) + assert.Equal(t, time.Hour, runner.period) +} + +func TestRunnerRun(t *testing.T) { + ctx, _ := context.WithTimeout(ctx, period) + + logger := newMockLogger(t) + logger.EXPECT(). + InfoContext(ctx, "runner step started", mock.Anything) + logger.EXPECT(). + InfoContext(ctx, "runner step finished", mock.Anything) + + store := newMockScheduledStore(t) + store.EXPECT(). + Pop(mock.Anything, testNow). + Return(nil, dynamo.NotFoundError{}). + Once() + + waiter := newMockWaiter(t) + waiter.EXPECT().Reset() + + runner := &Runner{ + now: testNowFn, + period: time.Hour, + logger: logger, + store: store, + waiter: waiter, + } + + err := runner.Run(ctx) + assert.Nil(t, err) +} + +func TestRunnerRunWhenPeriodElapses(t *testing.T) { + ctx, cancel := context.WithTimeout(ctx, 3*period) + event := &Event{ + Action: 99, + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + logger := newMockLogger(t) + logger.EXPECT(). + InfoContext(ctx, "runner step started", mock.Anything) + logger.EXPECT(). + InfoContext(ctx, "runner step finished", mock.Anything) + logger.EXPECT(). + InfoContext(mock.Anything, "runner action", mock.Anything) + logger.EXPECT(). + InfoContext(mock.Anything, "runner action success", mock.Anything, mock.Anything, mock.Anything) + + store := newMockScheduledStore(t) + store.ExpectPops( + event, nil, + nil, dynamo.NotFoundError{}, + event, nil, + nil, dynamo.NotFoundError{}, + event, nil) + + waiter := newMockWaiter(t) + waiter.EXPECT().Reset() + + var runTimes []time.Time + runner := &Runner{ + now: time.Now, + period: period, + logger: logger, + store: store, + waiter: waiter, + actions: map[Action]ActionFunc{ + Action(99): func(_ context.Context, _ *Event) error { + if runTimes = append(runTimes, time.Now()); len(runTimes) == 3 { + cancel() + } + return nil + }, + }, + } + + err := runner.Run(ctx) + assert.Nil(t, err) + assert.Len(t, runTimes, 3) + assert.InDelta(t, period, runTimes[1].Sub(runTimes[0]), float64(resolution)) + assert.InDelta(t, period, runTimes[2].Sub(runTimes[1]), float64(resolution)) +} + +func TestRunnerRunWhenStepErrors(t *testing.T) { + ctx, _ := context.WithTimeout(ctx, period) + + logger := newMockLogger(t) + logger.EXPECT(). + InfoContext(ctx, "runner step started", mock.Anything) + logger.EXPECT(). + InfoContext(ctx, "runner step finished", mock.Anything) + logger.EXPECT(). + ErrorContext(ctx, "runner step error", slog.Any("err", expectedError)) + + store := newMockScheduledStore(t) + store.EXPECT(). + Pop(mock.Anything, testNow). + Return(nil, expectedError). + Once() + + waiter := newMockWaiter(t) + waiter.EXPECT().Reset() + + runner := &Runner{ + now: testNowFn, + period: time.Hour, + logger: logger, + store: store, + waiter: waiter, + } + + err := runner.Run(ctx) + assert.Nil(t, err) +} + +func TestRunnerStep(t *testing.T) { + event := &Event{ + Action: 99, + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + logger := newMockLogger(t) + logger.EXPECT(). + InfoContext(ctx, "runner action", slog.String("action", "Action(99)")) + logger.EXPECT(). + InfoContext(ctx, "runner action success", + slog.String("action", "Action(99)"), + slog.String("target_pk", "LPA#an-lpa"), + slog.String("target_sk", "DONOR#a-donor")) + + store := newMockScheduledStore(t) + store.EXPECT(). + Pop(ctx, testNow). + Return(event, nil). + Once() + store.EXPECT(). + Pop(ctx, testNow). + Return(nil, dynamo.NotFoundError{}). + Once() + + waiter := newMockWaiter(t) + waiter.EXPECT().Reset() + + actionFunc := newMockActionFunc(t) + actionFunc.EXPECT(). + Execute(ctx, event). + Return(nil) + + runner := &Runner{ + now: testNowFn, + logger: logger, + store: store, + waiter: waiter, + actions: map[Action]ActionFunc{ + 99: actionFunc.Execute, + }, + } + err := runner.step(ctx) + assert.Nil(t, err) +} + +func TestRunnerStepWhenActionIgnored(t *testing.T) { + event := &Event{ + Action: 99, + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + logger := newMockLogger(t) + logger.EXPECT(). + InfoContext(ctx, "runner action", slog.String("action", "Action(99)")) + logger.EXPECT(). + InfoContext(ctx, "runner action ignored", + slog.String("action", "Action(99)"), + slog.String("target_pk", "LPA#an-lpa"), + slog.String("target_sk", "DONOR#a-donor")) + + store := newMockScheduledStore(t) + store.EXPECT(). + Pop(ctx, testNow). + Return(event, nil). + Once() + store.EXPECT(). + Pop(ctx, testNow). + Return(nil, dynamo.NotFoundError{}). + Once() + + waiter := newMockWaiter(t) + waiter.EXPECT().Reset() + + actionFunc := newMockActionFunc(t) + actionFunc.EXPECT(). + Execute(mock.Anything, mock.Anything). + Return(errStepIgnored) + + runner := &Runner{ + now: testNowFn, + logger: logger, + store: store, + waiter: waiter, + actions: map[Action]ActionFunc{ + 99: actionFunc.Execute, + }, + } + err := runner.step(ctx) + assert.Nil(t, err) +} + +func TestRunnerStepWhenActionErrors(t *testing.T) { + event := &Event{ + Action: 99, + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + logger := newMockLogger(t) + logger.EXPECT(). + InfoContext(ctx, "runner action", slog.String("action", "Action(99)")) + logger.EXPECT(). + ErrorContext(ctx, "runner action error", + slog.String("action", "Action(99)"), + slog.String("target_pk", "LPA#an-lpa"), + slog.String("target_sk", "DONOR#a-donor"), + slog.Any("err", expectedError)) + + store := newMockScheduledStore(t) + store.EXPECT(). + Pop(ctx, testNow). + Return(event, nil). + Once() + store.EXPECT(). + Pop(ctx, testNow). + Return(nil, dynamo.NotFoundError{}). + Once() + + waiter := newMockWaiter(t) + waiter.EXPECT().Reset() + + actionFunc := newMockActionFunc(t) + actionFunc.EXPECT(). + Execute(mock.Anything, mock.Anything). + Return(expectedError) + + runner := &Runner{ + now: testNowFn, + logger: logger, + store: store, + waiter: waiter, + actions: map[Action]ActionFunc{ + 99: actionFunc.Execute, + }, + } + err := runner.step(ctx) + assert.Nil(t, err) +} + +func TestRunnerStepWhenConditionalCheckFails(t *testing.T) { + event := &Event{ + Action: 99, + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + logger := newMockLogger(t) + logger.EXPECT(). + InfoContext(ctx, "runner action", slog.String("action", "Action(99)")) + logger.EXPECT(). + InfoContext(ctx, "runner conditional check failed") + logger.EXPECT(). + InfoContext(ctx, "runner action success", + slog.String("action", "Action(99)"), + slog.String("target_pk", "LPA#an-lpa"), + slog.String("target_sk", "DONOR#a-donor")) + + store := newMockScheduledStore(t) + store.ExpectPops( + nil, dynamo.ConditionalCheckFailedError{}, + event, nil, + nil, dynamo.NotFoundError{}) + + waiter := newMockWaiter(t) + waiter.EXPECT().Reset().Twice() + waiter.EXPECT().Wait().Return(nil).Once() + + actionFunc := newMockActionFunc(t) + actionFunc.EXPECT(). + Execute(mock.Anything, mock.Anything). + Return(nil) + + runner := &Runner{ + now: testNowFn, + logger: logger, + store: store, + waiter: waiter, + actions: map[Action]ActionFunc{ + 99: actionFunc.Execute, + }, + } + err := runner.step(ctx) + assert.Nil(t, err) +} + +func TestRunnerStepWhenConditionalCheckFailsAndWaiterErrors(t *testing.T) { + logger := newMockLogger(t) + logger.EXPECT(). + InfoContext(ctx, "runner conditional check failed") + + store := newMockScheduledStore(t) + store.ExpectPops( + nil, dynamo.ConditionalCheckFailedError{}, + nil, dynamo.ConditionalCheckFailedError{}) + + waiter := newMockWaiter(t) + waiter.EXPECT().Reset().Once() + waiter.EXPECT().Wait().Return(nil).Once() + waiter.EXPECT().Wait().Return(expectedError).Once() + + runner := &Runner{ + now: testNowFn, + logger: logger, + store: store, + waiter: waiter, + } + err := runner.step(ctx) + assert.Equal(t, expectedError, err) +} + +func TestRunnerStepCancelDonorIdentity(t *testing.T) { + lpaKey := dynamo.LpaKey("an-lpa") + donorKey := dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")) + event := &Event{ + TargetLpaKey: lpaKey, + TargetLpaOwnerKey: donorKey, + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(ctx, lpaKey, donorKey). + Return(&donordata.Provided{ + LpaUID: "lpa-uid", + Donor: donordata.Donor{Email: "donor@example.com"}, + DonorIdentityUserData: identity.UserData{Status: identity.StatusConfirmed}, + }, nil) + donorStore.EXPECT(). + Put(ctx, &donordata.Provided{ + LpaUID: "lpa-uid", + Donor: donordata.Donor{Email: "donor@example.com"}, + DonorIdentityUserData: identity.UserData{Status: identity.StatusExpired}, + Tasks: donordata.Tasks{ConfirmYourIdentityAndSign: task.IdentityStateNotStarted}, + }). + Return(nil) + + notifyClient := newMockNotifyClient(t) + notifyClient.EXPECT(). + SendActorEmail(ctx, "donor@example.com", "lpa-uid", notify.DonorIdentityCheckExpiredEmail{}). + Return(nil) + + runner := &Runner{ + donorStore: donorStore, + notifyClient: notifyClient, + } + err := runner.stepCancelDonorIdentity(ctx, event) + + assert.Nil(t, err) +} + +func TestRunnerStepCancelDonorIdentityWhenDonorStoreErrors(t *testing.T) { + event := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(nil, expectedError) + + runner := &Runner{ + donorStore: donorStore, + } + err := runner.stepCancelDonorIdentity(ctx, event) + + assert.ErrorContains(t, err, "error retrieving donor: hey") +} + +func TestRunnerStepCancelDonorIdentityWhenStepIgnored(t *testing.T) { + testcases := map[string]*donordata.Provided{ + "identity not confirmed": &donordata.Provided{ + DonorIdentityUserData: identity.UserData{Status: identity.StatusFailed}, + }, + "already signed": &donordata.Provided{ + DonorIdentityUserData: identity.UserData{Status: identity.StatusConfirmed}, + SignedAt: time.Now(), + }, + } + + for name, provided := range testcases { + t.Run(name, func(t *testing.T) { + lpaKey := dynamo.LpaKey("an-lpa") + donorKey := dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")) + event := &Event{ + TargetLpaKey: lpaKey, + TargetLpaOwnerKey: donorKey, + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(ctx, lpaKey, donorKey). + Return(provided, nil) + + runner := &Runner{ + donorStore: donorStore, + } + err := runner.stepCancelDonorIdentity(ctx, event) + + assert.Equal(t, errStepIgnored, err) + }) + } +} + +func TestRunnerStepCancelDonorIdentityWhenNotifySendErrors(t *testing.T) { + event := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(&donordata.Provided{ + LpaUID: "lpa-uid", + Donor: donordata.Donor{Email: "donor@example.com"}, + DonorIdentityUserData: identity.UserData{Status: identity.StatusConfirmed}, + }, nil) + + notifyClient := newMockNotifyClient(t) + notifyClient.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(expectedError) + + runner := &Runner{ + donorStore: donorStore, + notifyClient: notifyClient, + } + err := runner.stepCancelDonorIdentity(ctx, event) + + assert.ErrorIs(t, err, expectedError) +} + +func TestRunnerStepCancelDonorIdentityWhenDonorStorePutErrors(t *testing.T) { + event := &Event{ + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + donorStore := newMockDonorStore(t) + donorStore.EXPECT(). + One(mock.Anything, mock.Anything, mock.Anything). + Return(&donordata.Provided{ + LpaUID: "lpa-uid", + Donor: donordata.Donor{Email: "donor@example.com"}, + DonorIdentityUserData: identity.UserData{Status: identity.StatusConfirmed}, + }, nil) + donorStore.EXPECT(). + Put(mock.Anything, mock.Anything). + Return(expectedError) + + notifyClient := newMockNotifyClient(t) + notifyClient.EXPECT(). + SendActorEmail(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + runner := &Runner{ + donorStore: donorStore, + notifyClient: notifyClient, + } + err := runner.stepCancelDonorIdentity(ctx, event) + + assert.ErrorIs(t, err, expectedError) +} diff --git a/internal/scheduled/store.go b/internal/scheduled/store.go new file mode 100644 index 0000000000..179309ba7b --- /dev/null +++ b/internal/scheduled/store.go @@ -0,0 +1,50 @@ +package scheduled + +import ( + "context" + "time" + + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" +) + +type DynamoClient interface { + Move(ctx context.Context, oldKeys dynamo.Keys, value any) error + OneByPK(ctx context.Context, pk dynamo.PK, v interface{}) error + Put(ctx context.Context, v interface{}) error +} + +type Store struct { + dynamoClient DynamoClient + now func() time.Time +} + +func NewStore(dynamoClient DynamoClient) *Store { + return &Store{ + dynamoClient: dynamoClient, + now: time.Now, + } +} + +func (s *Store) Pop(ctx context.Context, day time.Time) (*Event, error) { + var row Event + if err := s.dynamoClient.OneByPK(ctx, dynamo.ScheduledDayKey(day), &row); err != nil { + return nil, err + } + + oldKeys := dynamo.Keys{PK: row.PK, SK: row.SK} + row.PK = row.PK.Handled() + + if err := s.dynamoClient.Move(ctx, oldKeys, row); err != nil { + return nil, err + } + + return &row, nil +} + +func (s *Store) Put(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() + + return s.dynamoClient.Put(ctx, row) +} diff --git a/internal/scheduled/store_test.go b/internal/scheduled/store_test.go new file mode 100644 index 0000000000..a7268ace0f --- /dev/null +++ b/internal/scheduled/store_test.go @@ -0,0 +1,107 @@ +package scheduled + +import ( + "context" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func (c *mockDynamoClient_OneByPK_Call) SetData(row *Event) { + c.Run(func(_ context.Context, _ dynamo.PK, v any) { + b, _ := attributevalue.Marshal(row) + attributevalue.Unmarshal(b, v) + }) +} + +func TestNewStore(t *testing.T) { + dynamoClient := newMockDynamoClient(t) + store := NewStore(dynamoClient) + assert.Equal(t, dynamoClient, store.dynamoClient) +} + +func TestStorePop(t *testing.T) { + row := &Event{ + Action: 99, + PK: dynamo.ScheduledDayKey(testNow), + SK: dynamo.ScheduledKey(testNow, 99), + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + movedRow := &Event{ + Action: 99, + PK: dynamo.ScheduledDayKey(testNow).Handled(), + SK: dynamo.ScheduledKey(testNow, 99), + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + } + + dynamoClient := newMockDynamoClient(t) + dynamoClient.EXPECT(). + OneByPK(ctx, dynamo.ScheduledDayKey(testNow), mock.Anything). + Return(nil). + SetData(row) + dynamoClient.EXPECT(). + Move(ctx, dynamo.Keys{PK: row.PK, SK: row.SK}, *movedRow). + Return(nil) + + store := &Store{dynamoClient: dynamoClient} + result, err := store.Pop(ctx, testNow) + assert.Nil(t, err) + assert.Equal(t, movedRow, result) +} + +func TestStorePopWhenOneByPKErrors(t *testing.T) { + dynamoClient := newMockDynamoClient(t) + dynamoClient.EXPECT(). + OneByPK(mock.Anything, mock.Anything, mock.Anything). + Return(expectedError) + + store := &Store{dynamoClient: dynamoClient} + _, err := store.Pop(ctx, testNow) + assert.Equal(t, expectedError, err) +} + +func TestStorePopWhenDeleteOneErrors(t *testing.T) { + dynamoClient := newMockDynamoClient(t) + dynamoClient.EXPECT(). + OneByPK(mock.Anything, mock.Anything, mock.Anything). + Return(nil). + SetData(&Event{ + Action: 99, + PK: dynamo.ScheduledDayKey(testNow), + SK: dynamo.ScheduledKey(testNow, 99), + TargetLpaKey: dynamo.LpaKey("an-lpa"), + TargetLpaOwnerKey: dynamo.LpaOwnerKey(dynamo.DonorKey("a-donor")), + }) + dynamoClient.EXPECT(). + Move(mock.Anything, mock.Anything, mock.Anything). + Return(expectedError) + + store := &Store{dynamoClient: dynamoClient} + _, err := store.Pop(ctx, testNow) + assert.Equal(t, expectedError, err) +} + +func TestStorePut(t *testing.T) { + at := time.Date(2024, time.January, 1, 12, 13, 14, 5, time.UTC) + + dynamoClient := newMockDynamoClient(t) + dynamoClient.EXPECT(). + Put(ctx, Event{ + PK: dynamo.ScheduledDayKey(at), + SK: dynamo.ScheduledKey(at, 99), + CreatedAt: testNow, + At: at, + Action: 99, + }). + Return(expectedError) + + store := &Store{dynamoClient: dynamoClient, now: testNowFn} + err := store.Put(ctx, Event{At: at, Action: 99}) + assert.Equal(t, expectedError, err) +} diff --git a/internal/scheduled/waiter.go b/internal/scheduled/waiter.go new file mode 100644 index 0000000000..7fc09263d3 --- /dev/null +++ b/internal/scheduled/waiter.go @@ -0,0 +1,30 @@ +package scheduled + +import ( + "errors" + "math/rand/v2" + "time" +) + +type waiter struct { + backoff time.Duration + sleep func(time.Duration) + maxRetries int + retries int +} + +func (w *waiter) Reset() { + w.retries = 0 +} + +func (w *waiter) Wait() error { + w.retries++ + count := rand.IntN(w.retries) + 1 + w.sleep(time.Duration(count) * w.backoff) + + if w.retries > w.maxRetries { + return errors.New("waiter exceeded max retries") + } + + return nil +} diff --git a/internal/scheduled/waiter_test.go b/internal/scheduled/waiter_test.go new file mode 100644 index 0000000000..d974aabd5c --- /dev/null +++ b/internal/scheduled/waiter_test.go @@ -0,0 +1,42 @@ +package scheduled + +import ( + "slices" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestWaiterReset(t *testing.T) { + w := &waiter{retries: 1} + w.Reset() + assert.Equal(t, 0, w.retries) +} + +func TestWaiterWait(t *testing.T) { + var calledDur time.Duration + w := &waiter{backoff: time.Second, sleep: func(dur time.Duration) { calledDur = dur }, maxRetries: 2} + + err := w.Wait() + assert.Nil(t, err) + assert.Equal(t, time.Second, calledDur) +} + +func TestWaiterWaitWhenRetries(t *testing.T) { + for range 5 { + var calledDur time.Duration + w := &waiter{backoff: time.Second, sleep: func(dur time.Duration) { calledDur = dur }, maxRetries: 3, retries: 2} + + err := w.Wait() + assert.Nil(t, err) + assert.True(t, slices.Contains([]time.Duration{time.Second, 2 * time.Second, 3 * time.Second}, calledDur)) + } +} + +func TestWaiterWaitWhenRetriesExceedsMax(t *testing.T) { + w := &waiter{backoff: time.Second, sleep: func(dur time.Duration) {}, maxRetries: 2, retries: 2} + + err := w.Wait() + assert.ErrorContains(t, err, "waiter exceeded max retries") +} diff --git a/internal/templatefn/fn.go b/internal/templatefn/fn.go index c79e909ba0..39e7e73ac2 100644 --- a/internal/templatefn/fn.go +++ b/internal/templatefn/fn.go @@ -460,20 +460,22 @@ func content(app appcontext.Data, content string) map[string]interface{} { } type notificationBannerData struct { - App appcontext.Data - Title string - Content template.HTML - Heading bool - Success bool + App appcontext.Data + Title string + Content template.HTML + Heading bool + Success bool + Contents bool } func notificationBanner(app appcontext.Data, title string, content template.HTML, options ...string) notificationBannerData { return notificationBannerData{ - App: app, - Title: title, - Content: content, - Heading: slices.Contains(options, "heading"), - Success: slices.Contains(options, "success"), + App: app, + Title: title, + Content: content, + Heading: slices.Contains(options, "heading"), + Success: slices.Contains(options, "success"), + Contents: slices.Contains(options, "contents"), } } diff --git a/lang/cy.json b/lang/cy.json index 7238da8138..ae0ccb43e6 100644 --- a/lang/cy.json +++ b/lang/cy.json @@ -1365,5 +1365,9 @@ "yourEmailAddressForUpdates": "Welsh", "enterYourEmailAddress": "Welsh", "yourMobileForUpdates": "Welsh", - "youCanLeaveThisFieldBlankIfNotTextMessage": "Welsh" + "youCanLeaveThisFieldBlankIfNotTextMessage": "Welsh", + "yourConfirmedIdentityHasExpired": "

Welsh

Welsh

", + "returnToOneLoginToConfirmYourIdentityContent": "

Welsh

", + "askSomeoneToVouchForYouContent": "

Welsh

", + "iWillReturnToOneLogin": "Welsh" } diff --git a/lang/en.json b/lang/en.json index 6e53395161..0ba610e9d5 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1294,5 +1294,9 @@ "yourEmailAddressForUpdates": "Your email address for receiving updates about your LPA", "enterYourEmailAddress": "Enter your email address", "yourMobileForUpdates": "Your mobile phone number for receiving text message updates about your LPA", - "youCanLeaveThisFieldBlankIfNotTextMessage": "You can leave this field blank if you do not wish to receive updates by text message" + "youCanLeaveThisFieldBlankIfNotTextMessage": "You can leave this field blank if you do not wish to receive updates by text message", + "yourConfirmedIdentityHasExpired": "

Your confirmed identity has expired

You must sign your LPA within 6 months of confirming your identity.

", + "returnToOneLoginToConfirmYourIdentityContent": "

Return to GOV.UK One Login to confirm your identity

You will need either:

", + "askSomeoneToVouchForYouContent": "

Ask someone to vouch for you

Vouching allows someone you know well to confirm your identity. The person you ask to vouch for you will also need to confirm their own identity using GOV.UK One Login.

", + "iWillReturnToOneLogin": "I will return to GOV.UK One Login and confirm my identity" } diff --git a/web/template/donor/what_you_can_do_now_expired.gohtml b/web/template/donor/what_you_can_do_now_expired.gohtml new file mode 100644 index 0000000000..3ed739adaf --- /dev/null +++ b/web/template/donor/what_you_can_do_now_expired.gohtml @@ -0,0 +1,48 @@ +{{ template "page" . }} + +{{ define "pageTitle" }}{{ tr .App "whatYouCanDoNow" }}{{ end }} + +{{ define "main" }} +
+
+ {{ template "notification-banner" (notificationBanner .App "important" (trHtml .App "yourConfirmedIdentityHasExpired") "contents") }} + +

{{ tr .App "whatYouCanDoNow" }}

+ + {{ trHtml .App "whatYouCanDoNowContent" }} + {{ trHtml .App "returnToOneLoginToConfirmYourIdentityContent" }} + {{ trHtml .App "askSomeoneToVouchForYouContent" }} + {{ trHtml .App "registerYourLPAThroughTheCOPContent" }} + + {{ template "warning" (content .App "onceYouSelectThisOptionYouCannotChangeYourMindWarning") }} + +
+
+
+ {{ tr .App "whatWouldYouLikeToDo" }} + + {{ template "error-message" (errorMessage . "do-next") }} + + {{ template "radios" (items . "do-next" "" + (item .Form.Options.ProveOwnID.String "iWillReturnToOneLogin") + (item .Form.Options.SelectNewVoucher.String "iHaveSomeoneWhoCanVouch") + (item .Form.Options.WithdrawLPA.String "iNoLongerWantToMakeThisLPA" "hint" "iUnderstandIWillNotGetRefundHint" "orDivider" "1") + (item .Form.Options.ApplyToCOP.String "iWillApplyToCOPToRegister" "hint" "iUnderstandICannotChangeMyMind") + ) }} +
+
+ +
+

{{ tr .App "ifYouNeedHelpWithGOLContactTheirTeam" }}

+ + {{ tr .App "contactGOL" }} + +
+ + {{ template "buttons" (button .App "continue") }} + + {{ template "csrf-field" . }} +
+
+
+{{ end }} diff --git a/web/template/layout/notification-banner.gohtml b/web/template/layout/notification-banner.gohtml index 4d0854288d..37a4e0e15e 100644 --- a/web/template/layout/notification-banner.gohtml +++ b/web/template/layout/notification-banner.gohtml @@ -1,14 +1,18 @@ {{ define "notification-banner" }} -