diff --git a/Makefile b/Makefile index 9ac0d3901d..98bcc627cf 100644 --- a/Makefile +++ b/Makefile @@ -88,9 +88,9 @@ get-lpa: ##@app dumps all entries in the lpas dynamodb table that are related t docker compose -f docker/docker-compose.yml exec localstack awslocal dynamodb \ query --table-name lpas --key-condition-expression 'PK = :pk' --expression-attribute-values '{":pk": {"S": "LPA#$(id)"}}' -get-evidence: ##@app dumps all fee evidence in the lpas dynamodb table that are related to the LPA id supplied e.g. get-evidence id=abc-123 +get-documents: ##@app dumps all documents in the lpas dynamodb table that are related to the LPA id supplied e.g. get-documents lpaId=abc-123 docker compose -f docker/docker-compose.yml exec localstack awslocal dynamodb \ - query --table-name lpas --key-condition-expression 'PK = :pk' --expression-attribute-values '{":pk": {"S": "LPA#$(id)"}}' --projection-expression "Evidence" + query --table-name lpas --key-condition-expression 'PK = :pk and begins_with(SK, :sk)' --expression-attribute-values '{":pk": {"S": "LPA#$(lpaId)"}, ":sk": {"S": "#DOCUMENT#"}}' emit-evidence-received: ##@app emits an evidence-received event with the given UID e.g. emit-evidence-received uid=abc-123 curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"version":"0","id":"63eb7e5f-1f10-4744-bba9-e16d327c3b98","detail-type":"evidence-received","source":"opg.poas.sirius","account":"653761790766","time":"2023-08-30T13:40:30Z","region":"eu-west-1","resources":[],"detail":{"UID":"$(uid)"}}' @@ -104,17 +104,17 @@ emit-fee-denied: ##@app emits a fee-denied event with the given UID e.g. emit-fe emit-more-evidence-required: ##@app emits a more-evidence-required event with the given UID e.g. emit-more-evidence-required uid=abc-123 curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"version":"0","id":"63eb7e5f-1f10-4744-bba9-e16d327c3b98","detail-type":"more-evidence-required","source":"opg.poas.sirius","account":"653761790766","time":"2023-08-30T13:40:30Z","region":"eu-west-1","resources":[],"detail":{"UID":"$(uid)"}}' -emit-object-tags-added-with-virus: ##@app emits a Object Tags Added event with the given S3 key e.g. emit-object-tags-added-with-virus key=doc/key. Also ensures a tag with virus-scan-status exists on an existing object set to infected +emit-object-tags-added-with-virus: ##@app emits a ObjectTagging:Put event with the given S3 key e.g. emit-object-tags-added-with-virus key=doc/key. Also ensures a tag with virus-scan-status exists on an existing object set to infected docker compose -f docker/docker-compose.yml exec localstack awslocal s3api \ put-object-tagging --bucket evidence --key $(key) --tagging '{"TagSet": [{ "Key": "virus-scan-status", "Value": "infected" }]}' - curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"version":"0","id":"63eb7e5f-1f10-4744-bba9-e16d327c3b98","detail-type":"Object Tags Added","source":"aws.s3","account":"653761790766","time":"2023-08-30T13:40:30Z","region":"eu-west-1","resources":[],"detail":{"object":{"key":"$(key)"}}}' + curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"Records":[{"eventSource":"aws:s3","eventTime":"2023-10-23T15:58:33.081Z","eventName":"ObjectTagging:Put","s3":{"bucket":{"name":"uploads-opg-modernising-lpa-eu-west-1"},"object":{"key":"$(key)"}}}]}' -emit-object-tags-added-without-virus: ##@app emits a Object Tags Added event with the given S3 key e.g. emit-object-tags-added-with-virus key=doc/key. Also ensures a tag with virus-scan-status exists on an existing object set to ok +emit-object-tags-added-without-virus: ##@app emits a ObjectTagging:Put event with the given S3 key e.g. emit-object-tags-added-with-virus key=doc/key. Also ensures a tag with virus-scan-status exists on an existing object set to ok docker compose -f docker/docker-compose.yml exec localstack awslocal s3api \ put-object-tagging --bucket evidence --key $(key) --tagging '{"TagSet": [{ "Key": "virus-scan-status", "Value": "ok" }]}' - curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"version":"0","id":"63eb7e5f-1f10-4744-bba9-e16d327c3b98","detail-type":"Object Tags Added","source":"aws.s3","account":"653761790766","time":"2023-08-30T13:40:30Z","region":"eu-west-1","resources":[],"detail":{"object":{"key":"$(key)"}}}' + curl "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{"Records":[{"eventSource":"aws:s3","eventTime":"2023-10-23T15:58:33.081Z","eventName":"ObjectTagging:Put","s3":{"bucket":{"name":"uploads-opg-modernising-lpa-eu-west-1"},"object":{"key":"$(key)"}}}]}' logs: ##@app tails logs for all containers running docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml logs -f diff --git a/cmd/event-received/main.go b/cmd/event-received/main.go index ace122da42..403b37ae82 100644 --- a/cmd/event-received/main.go +++ b/cmd/event-received/main.go @@ -55,6 +55,11 @@ type shareCodeSender interface { SendCertificateProvider(context.Context, notify.Template, page.AppData, bool, *page.Lpa) error } +//go:generate mockery --testonly --inpackage --name DocumentStore --structname mockDocumentStore +type DocumentStore interface { + UpdateScanResults(ctx context.Context, lpaID, objectKey string, virusDetected bool) error +} + type Event struct { events.S3Event events.CloudWatchEvent @@ -110,6 +115,8 @@ func Handler(ctx context.Context, event Event) error { notifyClient, err := notify.New(notifyIsProduction, notifyBaseURL, notifyApiKey, http.DefaultClient) + documentStore := app.NewDocumentStore(dynamoClient, s3Client, random.UuidString) + bundle := localize.NewBundle("./lang/en.json", "./lang/cy.json") //TODO do this in handleFeeApproved when/if we save lang preference in LPA @@ -119,7 +126,7 @@ func Handler(ctx context.Context, event Event) error { now := time.Now if event.isS3Event() { - return handleObjectTagsAdded(ctx, dynamoClient, event.S3Event, now, s3Client) + return handleObjectTagsAdded(ctx, dynamoClient, event.S3Event, s3Client, documentStore) } if event.isCloudWatchEvent() { @@ -230,7 +237,7 @@ func handleFeeDenied(ctx context.Context, client dynamodbClient, event events.Cl return nil } -func handleObjectTagsAdded(ctx context.Context, client dynamodbClient, event events.S3Event, now func() time.Time, s3Client s3Client) error { +func handleObjectTagsAdded(ctx context.Context, dynamodbClient dynamodbClient, event events.S3Event, s3Client s3Client, documentStore DocumentStore) error { objectKey := event.Records[0].S3.Object.Key if objectKey == "" { return fmt.Errorf("object key missing in event in '%s'", objectTagsAddedEventName) @@ -256,26 +263,16 @@ func handleObjectTagsAdded(ctx context.Context, client dynamodbClient, event eve return nil } - uid := strings.Split(objectKey, "/") + parts := strings.Split(objectKey, "/") - lpa, err := getLpaByUID(ctx, client, uid[0], objectTagsAddedEventName) + lpa, err := getLpaByUID(ctx, dynamodbClient, parts[0], objectTagsAddedEventName) if err != nil { return err } - document := lpa.Evidence.Get(objectKey) - if document.Key == "" { - return fmt.Errorf("LPA did not contain a document with key %s for '%s'", objectKey, objectTagsAddedEventName) - } - - document.Scanned = now() - document.VirusDetected = hasVirus - - lpa.Evidence.Put(document) - lpa.UpdatedAt = now() - - if err := client.Put(ctx, lpa); err != nil { - return fmt.Errorf("failed to update LPA for '%s': %w", objectTagsAddedEventName, err) + err = documentStore.UpdateScanResults(ctx, lpa.ID, objectKey, hasVirus) + if err != nil { + return fmt.Errorf("failed to update scan results for '%s': %w", objectTagsAddedEventName, err) } return nil diff --git a/cmd/event-received/main_test.go b/cmd/event-received/main_test.go index 09cdec4068..1538f6027b 100644 --- a/cmd/event-received/main_test.go +++ b/cmd/event-received/main_test.go @@ -334,97 +334,105 @@ func TestHandleFeeDeniedWhenPutError(t *testing.T) { } func TestHandleObjectTagsAdded(t *testing.T) { + testCases := map[string]bool{ + "ok": false, + "infected": true, + } + + for scanResult, hasVirus := range testCases { + t.Run(scanResult, func(t *testing.T) { + event := Event{ + S3Event: events.S3Event{Records: []events.S3EventRecord{ + {S3: events.S3Entity{Object: events.S3Object{Key: "M-1111-2222-3333/evidence/a-uid"}}}, + }}, + } + + s3Client := newMockS3Client(t) + s3Client. + On("GetObjectTags", ctx, "M-1111-2222-3333/evidence/a-uid"). + Return([]types.Tag{ + {Key: aws.String("virus-scan-status"), Value: aws.String(scanResult)}, + }, nil) + + dynamoClient := newMockDynamodbClient(t) + dynamoClient. + On("OneByUID", ctx, "M-1111-2222-3333", mock.Anything). + Return(func(ctx context.Context, uid string, v interface{}) error { + b, _ := json.Marshal(dynamo.Key{PK: "LPA#123", SK: "#DONOR#456"}) + json.Unmarshal(b, v) + return nil + }) + dynamoClient. + On("One", ctx, "LPA#123", "#DONOR#456", mock.Anything). + Return(func(ctx context.Context, pk, sk string, v interface{}) error { + b, _ := json.Marshal(page.Lpa{ID: "123", Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}}) + json.Unmarshal(b, v) + return nil + }) + + documentStore := newMockDocumentStore(t) + documentStore. + On("UpdateScanResults", ctx, "123", "M-1111-2222-3333/evidence/a-uid", hasVirus). + Return(nil) + + err := handleObjectTagsAdded(ctx, dynamoClient, event.S3Event, s3Client, documentStore) + assert.Nil(t, err) + }) + } +} + +func TestHandleObjectTagsAddedWhenScannedTagMissing(t *testing.T) { event := Event{ S3Event: events.S3Event{Records: []events.S3EventRecord{ {S3: events.S3Entity{Object: events.S3Object{Key: "M-1111-2222-3333/evidence/a-uid"}}}, }}, } - now := time.Now() - s3Client := newMockS3Client(t) s3Client. On("GetObjectTags", ctx, "M-1111-2222-3333/evidence/a-uid"). Return([]types.Tag{ - {Key: aws.String("virus-scan-status"), Value: aws.String("ok")}, + {Key: aws.String("not-virus-scan-status"), Value: aws.String("ok")}, }, nil) - dynamoClient := newMockDynamodbClient(t) - dynamoClient. - On("OneByUID", ctx, "M-1111-2222-3333", mock.Anything). - Return(func(ctx context.Context, uid string, v interface{}) error { - b, _ := json.Marshal(dynamo.Key{PK: "LPA#123", SK: "#DONOR#456"}) - json.Unmarshal(b, v) - return nil - }) - dynamoClient. - On("One", ctx, "LPA#123", "#DONOR#456", mock.Anything). - Return(func(ctx context.Context, pk, sk string, v interface{}) error { - b, _ := json.Marshal(page.Lpa{PK: "LPA#123", SK: "#DONOR#456", Evidence: page.Evidence{ - Documents: []page.Document{{Key: "M-1111-2222-3333/evidence/a-uid"}}, - }}) - json.Unmarshal(b, v) - return nil - }) - dynamoClient. - On("Put", ctx, page.Lpa{PK: "LPA#123", SK: "#DONOR#456", UpdatedAt: now, Evidence: page.Evidence{ - Documents: []page.Document{{Key: "M-1111-2222-3333/evidence/a-uid", Scanned: now, VirusDetected: false}}, - }}). - Return(nil) - - err := handleObjectTagsAdded(ctx, dynamoClient, event.S3Event, func() time.Time { return now }, s3Client) + err := handleObjectTagsAdded(ctx, nil, event.S3Event, s3Client, nil) assert.Nil(t, err) } -func TestHandleObjectTagsAddedWhenGetObjectTagsError(t *testing.T) { +func TestHandleObjectTagsAddedWhenObjectKeyMissing(t *testing.T) { event := Event{ S3Event: events.S3Event{Records: []events.S3EventRecord{ - {S3: events.S3Entity{Object: events.S3Object{Key: "M-1111-2222-3333/evidence/a-uid"}}}, + {S3: events.S3Entity{Object: events.S3Object{}}}, }}, } - now := time.Now() - - s3Client := newMockS3Client(t) - s3Client. - On("GetObjectTags", ctx, "M-1111-2222-3333/evidence/a-uid"). - Return([]types.Tag{ - {Key: aws.String("virus-scan-status"), Value: aws.String("ok")}, - }, expectedError) - - err := handleObjectTagsAdded(ctx, nil, event.S3Event, func() time.Time { return now }, s3Client) - assert.Equal(t, fmt.Errorf("failed to get tags for object in 'ObjectTagging:Put': %w", expectedError), err) + err := handleObjectTagsAdded(ctx, nil, event.S3Event, nil, nil) + assert.Equal(t, fmt.Errorf("object key missing in event in '%s'", objectTagsAddedEventName), err) } -func TestHandleObjectTagsAddedWhenDoesNotContainVirusScanTag(t *testing.T) { +func TestHandleObjectTagsAddedWhenS3ClientGetObjectTagsError(t *testing.T) { event := Event{ S3Event: events.S3Event{Records: []events.S3EventRecord{ {S3: events.S3Entity{Object: events.S3Object{Key: "M-1111-2222-3333/evidence/a-uid"}}}, }}, } - now := time.Now() - s3Client := newMockS3Client(t) s3Client. On("GetObjectTags", ctx, "M-1111-2222-3333/evidence/a-uid"). - Return([]types.Tag{ - {Key: aws.String("not-virus-scan-status")}, - }, nil) + Return([]types.Tag{}, expectedError) - err := handleObjectTagsAdded(ctx, nil, event.S3Event, func() time.Time { return now }, s3Client) - assert.Nil(t, err) + err := handleObjectTagsAdded(ctx, nil, event.S3Event, s3Client, nil) + assert.Equal(t, fmt.Errorf("failed to get tags for object in '%s': %w", objectTagsAddedEventName, expectedError), err) } -func TestHandleObjectTagsAddedWhenLpaEvidenceDoesNotContainDocument(t *testing.T) { +func TestHandleObjectTagsAddedWhenDynamoClientOneByUIDError(t *testing.T) { event := Event{ S3Event: events.S3Event{Records: []events.S3EventRecord{ {S3: events.S3Entity{Object: events.S3Object{Key: "M-1111-2222-3333/evidence/a-uid"}}}, }}, } - now := time.Now() - s3Client := newMockS3Client(t) s3Client. On("GetObjectTags", ctx, "M-1111-2222-3333/evidence/a-uid"). @@ -443,26 +451,22 @@ func TestHandleObjectTagsAddedWhenLpaEvidenceDoesNotContainDocument(t *testing.T dynamoClient. On("One", ctx, "LPA#123", "#DONOR#456", mock.Anything). Return(func(ctx context.Context, pk, sk string, v interface{}) error { - b, _ := json.Marshal(page.Lpa{PK: "LPA#123", SK: "#DONOR#456", Evidence: page.Evidence{ - Documents: []page.Document{{Key: "M-1111-2222-3333/evidence/a-different-uid"}}, - }}) + b, _ := json.Marshal(page.Lpa{ID: "123", Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}}) json.Unmarshal(b, v) - return nil + return expectedError }) - err := handleObjectTagsAdded(ctx, dynamoClient, event.S3Event, func() time.Time { return now }, s3Client) - assert.Equal(t, fmt.Errorf("LPA did not contain a document with key %s for 'ObjectTagging:Put'", "M-1111-2222-3333/evidence/a-uid"), err) + err := handleObjectTagsAdded(ctx, dynamoClient, event.S3Event, s3Client, nil) + assert.Equal(t, fmt.Errorf("failed to get LPA for '%s': %w", objectTagsAddedEventName, expectedError), err) } -func TestHandleObjectTagsAddedWhenDynamoPutError(t *testing.T) { +func TestHandleObjectTagsAddedWhenDocumentStoreUpdateScanResultsError(t *testing.T) { event := Event{ S3Event: events.S3Event{Records: []events.S3EventRecord{ {S3: events.S3Entity{Object: events.S3Object{Key: "M-1111-2222-3333/evidence/a-uid"}}}, }}, } - now := time.Now() - s3Client := newMockS3Client(t) s3Client. On("GetObjectTags", ctx, "M-1111-2222-3333/evidence/a-uid"). @@ -481,26 +485,22 @@ func TestHandleObjectTagsAddedWhenDynamoPutError(t *testing.T) { dynamoClient. On("One", ctx, "LPA#123", "#DONOR#456", mock.Anything). Return(func(ctx context.Context, pk, sk string, v interface{}) error { - b, _ := json.Marshal(page.Lpa{PK: "LPA#123", SK: "#DONOR#456", Evidence: page.Evidence{ - Documents: []page.Document{{Key: "M-1111-2222-3333/evidence/a-uid"}}, - }}) + b, _ := json.Marshal(page.Lpa{ID: "123", Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}}) json.Unmarshal(b, v) return nil }) - dynamoClient. - On("Put", ctx, page.Lpa{PK: "LPA#123", SK: "#DONOR#456", UpdatedAt: now, Evidence: page.Evidence{ - Documents: []page.Document{{Key: "M-1111-2222-3333/evidence/a-uid", Scanned: now, VirusDetected: false}}, - }}). + + documentStore := newMockDocumentStore(t) + documentStore. + On("UpdateScanResults", ctx, "123", "M-1111-2222-3333/evidence/a-uid", false). Return(expectedError) - err := handleObjectTagsAdded(ctx, dynamoClient, event.S3Event, func() time.Time { return now }, s3Client) - assert.Equal(t, fmt.Errorf("failed to update LPA for 'ObjectTagging:Put': %w", expectedError), err) + err := handleObjectTagsAdded(ctx, dynamoClient, event.S3Event, s3Client, documentStore) + assert.Equal(t, fmt.Errorf("failed to update scan results for '%s': %w", objectTagsAddedEventName, expectedError), err) } func TestGetLpaByUID(t *testing.T) { - expectedLpa := page.Lpa{PK: "LPA#123", SK: "#DONOR#456", Evidence: page.Evidence{ - Documents: []page.Document{{Key: "document/key"}}, - }} + expectedLpa := page.Lpa{PK: "LPA#123", SK: "#DONOR#456"} client := newMockDynamodbClient(t) client. diff --git a/cmd/event-received/mock_DocumentStore_test.go b/cmd/event-received/mock_DocumentStore_test.go new file mode 100644 index 0000000000..b62160b663 --- /dev/null +++ b/cmd/event-received/mock_DocumentStore_test.go @@ -0,0 +1,43 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package main + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// mockDocumentStore is an autogenerated mock type for the DocumentStore type +type mockDocumentStore struct { + mock.Mock +} + +// UpdateScanResults provides a mock function with given fields: ctx, pk, sk, virusDetected +func (_m *mockDocumentStore) UpdateScanResults(ctx context.Context, pk string, sk string, virusDetected bool) error { + ret := _m.Called(ctx, pk, sk, virusDetected) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, bool) error); ok { + r0 = rf(ctx, pk, sk, virusDetected) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTnewMockDocumentStore interface { + mock.TestingT + Cleanup(func()) +} + +// newMockDocumentStore creates a new instance of mockDocumentStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func newMockDocumentStore(t mockConstructorTestingTnewMockDocumentStore) *mockDocumentStore { + mock := &mockDocumentStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/cypress/e2e/donor/payment.cy.js b/cypress/e2e/donor/payment.cy.js index 4a3e43c608..5844d03e2a 100644 --- a/cypress/e2e/donor/payment.cy.js +++ b/cypress/e2e/donor/payment.cy.js @@ -67,16 +67,25 @@ describe('Pay for LPA', () => { cy.checkA11yApp(); - cy.get('.govuk-notification-banner--success').within(() => { - cy.contains('2 files successfully uploaded'); - }); + cy.get('#dialog').should('not.have.class', 'govuk-!-display-none'); + cy.get('#dialog-overlay').should('not.have.class', 'govuk-!-display-none'); + cy.get('#file-count').should('contain', '0 of 2 files uploaded'); + + cy.contains('button', 'Cancel upload').click() + cy.get('#dialog').should('have.class', 'govuk-!-display-none'); + cy.get('#dialog-overlay').should('have.class', 'govuk-!-display-none'); + + cy.get('.govuk-summary-list').should('not.exist'); + + // spoofing virus scan completing + cy.visit('/fixtures?redirect=/upload-evidence&progress=payForTheLpa&paymentTaskProgress=InProgress&feeType=HalfFee'); + cy.url().should('contain', '/upload-evidence'); cy.get('.govuk-summary-list').within(() => { - cy.contains('dummy.pdf'); - cy.contains('dummy.png'); + cy.contains('supporting-evidence.png'); }); - cy.contains('button', 'Continue to payment').click() + cy.contains('button', 'Continue').click() cy.url().should('contain', '/payment-confirmation'); }) @@ -122,12 +131,20 @@ describe('Pay for LPA', () => { cy.url().should('contain', '/upload-evidence'); cy.checkA11yApp(); - cy.get('.govuk-notification-banner--success').within(() => { - cy.contains('1 file successfully uploaded'); - }); + cy.get('#dialog').should('not.have.class', 'govuk-!-display-none'); + cy.get('#dialog-overlay').should('not.have.class', 'govuk-!-display-none'); + cy.get('#file-count').should('contain', '0 of 1 files uploaded'); + + cy.contains('button', 'Cancel upload').click() + cy.get('#dialog').should('have.class', 'govuk-!-display-none'); + cy.get('#dialog-overlay').should('have.class', 'govuk-!-display-none'); + + // spoofing virus scan completing + cy.visit('/fixtures?redirect=/upload-evidence&progress=payForTheLpa&paymentTaskProgress=InProgress&feeType=NoFee'); + cy.url().should('contain', '/upload-evidence'); cy.get('.govuk-summary-list').within(() => { - cy.contains('dummy.pdf'); + cy.contains('supporting-evidence.png'); }); cy.contains('button', 'Continue').click() @@ -177,12 +194,20 @@ describe('Pay for LPA', () => { cy.url().should('contain', '/upload-evidence'); cy.checkA11yApp(); - cy.get('.govuk-notification-banner--success').within(() => { - cy.contains('1 file successfully uploaded'); - }); + cy.get('#dialog').should('not.have.class', 'govuk-!-display-none'); + cy.get('#dialog-overlay').should('not.have.class', 'govuk-!-display-none'); + cy.get('#file-count').should('contain', '0 of 1 files uploaded'); + + cy.contains('button', 'Cancel upload').click() + cy.get('#dialog').should('have.class', 'govuk-!-display-none'); + cy.get('#dialog-overlay').should('have.class', 'govuk-!-display-none'); + + // spoofing virus scan completing + cy.visit('/fixtures?redirect=/upload-evidence&progress=payForTheLpa&paymentTaskProgress=InProgress&feeType=NoFee'); + cy.url().should('contain', '/upload-evidence'); cy.get('.govuk-summary-list').within(() => { - cy.contains('dummy.pdf'); + cy.contains('supporting-evidence.png'); }); cy.contains('button', 'Continue').click() @@ -192,25 +217,20 @@ describe('Pay for LPA', () => { }) it('can only delete evidence that has not been sent to OPG', () => { - cy.visit('/fixtures?redirect=/upload-evidence&progress=payForTheLpa&feeType=half-fee'); + cy.visit('/fixtures?redirect=/upload-evidence&progress=payForTheLpa&feeType=HalfFee'); cy.checkA11yApp(); - cy.get('input[type="file"]').attachFile(['dummy.pdf']); - - cy.contains('button', 'Upload files').click() - cy.url().should('contain', '/upload-evidence'); cy.get('.govuk-summary-list').within(() => { - cy.contains('supporting-evidence.png').parent().should('not.contain', 'Delete'); - cy.contains('dummy.pdf').parent().contains('button', 'Delete').click(); + cy.contains('supporting-evidence.png').parent().contains('button', 'Delete').click(); }); cy.url().should('contain', '/upload-evidence'); cy.checkA11yApp(); cy.get('.moj-banner').within(() => { - cy.contains('You have deleted file dummy.pdf'); + cy.contains('supporting-evidence.png'); }); }) }); diff --git a/go.mod b/go.mod index b9d126d106..b2760330cf 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/eventbridge v1.22.2 github.com/aws/aws-sdk-go-v2/service/s3 v1.40.2 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.21.6 + github.com/aws/smithy-go v1.15.0 github.com/dustin/go-humanize v1.0.1 github.com/felixge/httpsnoop v1.0.3 github.com/gabriel-vasile/mimetype v1.4.3 @@ -57,7 +58,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sso v1.15.2 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.3 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.23.2 // indirect - github.com/aws/smithy-go v1.15.0 // indirect github.com/brunoscheufler/aws-ecs-metadata-go v0.0.0-20220812150832-b6b31c6eeeaf // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/go.sum b/go.sum index da96e79389..6c5e462f42 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,6 @@ github.com/aws/aws-sdk-go-v2 v1.21.2 h1:+LXZ0sgo8quN9UOKXXzAWRT3FWd4NxeXWOZom9pE github.com/aws/aws-sdk-go-v2 v1.21.2/go.mod h1:ErQhvNuEMhJjweavOYhxVkn2RUx7kQXVATHrjKtxIpM= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.14 h1:Sc82v7tDQ/vdU1WtuSyzZ1I7y/68j//HJ6uozND1IDs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.14/go.mod h1:9NCTOURS8OpxvoAVHq79LK81/zC78hfRWFn+aL0SPcY= -github.com/aws/aws-sdk-go-v2/config v1.19.0 h1:AdzDvwH6dWuVARCl3RTLGRc4Ogy+N7yLFxVxXe1ClQ0= -github.com/aws/aws-sdk-go-v2/config v1.19.0/go.mod h1:ZwDUgFnQgsazQTnWfeLWk5GjeqTQTL8lMkoE1UXzxdE= github.com/aws/aws-sdk-go-v2/config v1.19.1 h1:oe3vqcGftyk40icfLymhhhNysAwk0NfiwkDi2GTPMXs= github.com/aws/aws-sdk-go-v2/config v1.19.1/go.mod h1:ZwDUgFnQgsazQTnWfeLWk5GjeqTQTL8lMkoE1UXzxdE= github.com/aws/aws-sdk-go-v2/credentials v1.13.43 h1:LU8vo40zBlo3R7bAvBVy/ku4nxGEyZe9N8MqAeFTzF8= @@ -131,8 +129,6 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= -github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4= @@ -176,8 +172,6 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/nicksnyder/go-i18n/v2 v2.2.1 h1:aOzRCdwsJuoExfZhoiXHy4bjruwCMdt5otbYojM/PaA= -github.com/nicksnyder/go-i18n/v2 v2.2.1/go.mod h1:fF2++lPHlo+/kPaj3nB0uxtPwzlPm+BlgwGX7MkeGj0= github.com/nicksnyder/go-i18n/v2 v2.2.2 h1:Iv/FL6pvYmDqybEZkr4TrOv8jSHezwpE77K68kcaft8= github.com/nicksnyder/go-i18n/v2 v2.2.2/go.mod h1:fF2++lPHlo+/kPaj3nB0uxtPwzlPm+BlgwGX7MkeGj0= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= diff --git a/internal/app/app.go b/internal/app/app.go index 5bcd190777..43299c3c40 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -49,6 +49,9 @@ type DynamoClient interface { Put(ctx context.Context, v interface{}) error Create(ctx context.Context, v interface{}) error DeleteKeys(ctx context.Context, keys []dynamo.Key) error + DeleteOne(ctx context.Context, pk, sk string) error + Update(ctx context.Context, pk, sk string, values map[string]dynamodbtypes.AttributeValue, expression string) error + BatchPut(ctx context.Context, items []interface{}) (int, error) } //go:generate mockery --testonly --inpackage --name S3Client --structname mockS3Client @@ -56,6 +59,7 @@ type S3Client interface { PutObject(context.Context, string, []byte) error PutObjectTagging(context.Context, string, []types.Tag) error DeleteObject(context.Context, string) error + DeleteObjects(ctx context.Context, keys []string) error } //go:generate mockery --testonly --inpackage --name SessionStore --structname mockSessionStore @@ -85,13 +89,16 @@ func App( s3Client S3Client, eventClient *event.Client, ) http.Handler { + documentStore := NewDocumentStore(lpaDynamoClient, s3Client, random.UuidString) + donorStore := &donorStore{ - dynamoClient: lpaDynamoClient, - eventClient: eventClient, - uidClient: uidClient, - logger: logger, - uuidString: uuid.NewString, - now: time.Now, + dynamoClient: lpaDynamoClient, + eventClient: eventClient, + uidClient: uidClient, + logger: logger, + uuidString: uuid.NewString, + now: time.Now, + documentStore: documentStore, } certificateProviderStore := &certificateProviderStore{dynamoClient: lpaDynamoClient, now: time.Now} attorneyStore := &attorneyStore{dynamoClient: lpaDynamoClient, now: time.Now} @@ -113,7 +120,7 @@ func App( handleRoot(paths.SignOut, None, page.SignOut(logger, sessionStore, oneLoginClient, appPublicURL)) handleRoot(paths.Fixtures, None, - fixtures.Donor(tmpls.Get("fixtures.gohtml"), sessionStore, donorStore, certificateProviderStore, attorneyStore)) + fixtures.Donor(tmpls.Get("fixtures.gohtml"), sessionStore, donorStore, certificateProviderStore, attorneyStore, documentStore)) handleRoot(paths.CertificateProviderFixtures, None, fixtures.CertificateProvider(tmpls.Get("certificate_provider_fixtures.gohtml"), sessionStore, shareCodeSender, donorStore, certificateProviderStore)) handleRoot(paths.AttorneyFixtures, None, @@ -181,6 +188,7 @@ func App( notifyClient, evidenceReceivedStore, s3Client, + documentStore, ) return withAppData(page.ValidateCsrf(rootMux, sessionStore, random.String, errorHandler), localizer, lang, rumConfig, staticHash, oneloginURL) diff --git a/internal/app/document_store.go b/internal/app/document_store.go new file mode 100644 index 0000000000..74a050282b --- /dev/null +++ b/internal/app/document_store.go @@ -0,0 +1,139 @@ +package app + +import ( + "context" + "errors" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/ministryofjustice/opg-modernising-lpa/internal/page" +) + +type PartialBatchWriteError struct { + Written int + Expected int +} + +func (e PartialBatchWriteError) Error() string { + return fmt.Sprintf("Expected to write %d but %d were written", e.Expected, e.Written) +} + +type documentStore struct { + dynamoClient DynamoClient + s3Client S3Client + randomUUID func() string +} + +func NewDocumentStore(dynamoClient DynamoClient, s3Client S3Client, randomUUID func() string) *documentStore { + return &documentStore{dynamoClient: dynamoClient, s3Client: s3Client, randomUUID: randomUUID} +} + +func (s *documentStore) Create(ctx context.Context, lpa *page.Lpa, filename string, data []byte) (page.Document, error) { + key := lpa.UID + "/evidence/" + s.randomUUID() + + document := page.Document{ + PK: lpaKey(lpa.ID), + SK: documentKey(key), + Filename: filename, + Key: key, + } + + if err := s.s3Client.PutObject(ctx, document.Key, data); err != nil { + return page.Document{}, err + } + + if err := s.dynamoClient.Create(ctx, document); err != nil { + return page.Document{}, err + } + + return document, nil +} + +func (s *documentStore) GetAll(ctx context.Context) (page.Documents, error) { + data, err := page.SessionDataFromContext(ctx) + if err != nil { + return nil, err + } + + if data.LpaID == "" { + return nil, errors.New("documentStore.GetAll requires LpaID") + } + + var ds []page.Document + if err := s.dynamoClient.AllByPartialSk(ctx, lpaKey(data.LpaID), documentKey(""), &ds); err != nil && !errors.Is(err, dynamo.NotFoundError{}) { + return nil, err + } + + return ds, nil +} + +func (s *documentStore) UpdateScanResults(ctx context.Context, lpaID, s3ObjectKey string, virusDetected bool) error { + return s.dynamoClient.Update(ctx, + lpaKey(lpaID), + documentKey(s3ObjectKey), + map[string]types.AttributeValue{ + ":virusDetected": &types.AttributeValueMemberBOOL{Value: virusDetected}, + ":scanned": &types.AttributeValueMemberBOOL{Value: true}, + }, + "set VirusDetected = :virusDetected, Scanned = :scanned") +} + +func (s *documentStore) BatchPut(ctx context.Context, documents []page.Document) error { + var converted []interface{} + for _, d := range documents { + converted = append(converted, d) + } + + toWrite := len(converted) + written, err := s.dynamoClient.BatchPut(ctx, converted) + + if err != nil { + return err + } else if written != toWrite { + return PartialBatchWriteError{Written: written, Expected: toWrite} + } + + return nil +} + +func (s *documentStore) Put(ctx context.Context, document page.Document) error { + return s.dynamoClient.Put(ctx, document) +} + +func (s *documentStore) DeleteInfectedDocuments(ctx context.Context, documents page.Documents) error { + var dynamoKeys []dynamo.Key + var s3Keys []string + + for _, d := range documents { + if d.VirusDetected { + dynamoKeys = append(dynamoKeys, dynamo.Key{ + PK: d.PK, + SK: d.SK, + }) + s3Keys = append(s3Keys, d.Key) + } + } + + if len(dynamoKeys) == 0 { + return nil + } + + if err := s.s3Client.DeleteObjects(ctx, s3Keys); err != nil { + return err + } + + return s.dynamoClient.DeleteKeys(ctx, dynamoKeys) +} + +func (s *documentStore) Delete(ctx context.Context, document page.Document) error { + if err := s.s3Client.DeleteObject(ctx, document.Key); err != nil { + return err + } + + return s.dynamoClient.DeleteOne(ctx, document.PK, document.SK) +} + +func documentKey(s3Key string) string { + return "#DOCUMENT#" + s3Key +} diff --git a/internal/app/document_store_test.go b/internal/app/document_store_test.go new file mode 100644 index 0000000000..e83761876a --- /dev/null +++ b/internal/app/document_store_test.go @@ -0,0 +1,372 @@ +package app + +import ( + "context" + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/ministryofjustice/opg-modernising-lpa/internal/dynamo" + "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestNewDocumentStore(t *testing.T) { + dynamoClient := newMockDynamoClient(t) + s3Client := newMockS3Client(t) + + expected := &documentStore{dynamoClient: dynamoClient, s3Client: s3Client, randomUUID: nil} + + assert.Equal(t, expected, NewDocumentStore(dynamoClient, s3Client, nil)) +} + +func TestDocumentStoreGetAll(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("AllByPartialSk", ctx, "LPA#123", "#DOCUMENT#", mock.Anything). + Return(func(ctx context.Context, pk, partialSk string, v interface{}) error { + b, _ := json.Marshal(page.Documents{{PK: "LPA#123"}}) + json.Unmarshal(b, v) + return nil + }) + + documentStore := documentStore{dynamoClient: dynamoClient} + + documents, err := documentStore.GetAll(ctx) + + assert.Nil(t, err) + assert.Equal(t, page.Documents{{PK: "LPA#123"}}, documents) +} + +func TestDocumentStoreGetAllMissingSessionData(t *testing.T) { + documentStore := documentStore{} + _, err := documentStore.GetAll(context.Background()) + + assert.NotNil(t, err) +} + +func TestDocumentStoreGetAllMissingLpaIdInSession(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{}) + + documentStore := documentStore{} + _, err := documentStore.GetAll(ctx) + + assert.NotNil(t, err) +} + +func TestDocumentStoreGetAllWhenDynamoClientAllByPartialSkError(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("AllByPartialSk", ctx, "LPA#123", "#DOCUMENT#", mock.Anything). + Return(func(ctx context.Context, pk, partialSk string, v interface{}) error { + b, _ := json.Marshal(page.Documents{{PK: "LPA#123"}}) + json.Unmarshal(b, v) + return expectedError + }) + + documentStore := documentStore{dynamoClient: dynamoClient} + _, err := documentStore.GetAll(ctx) + + assert.Equal(t, expectedError, err) +} + +func TestDocumentStoreGetAllWhenNoResults(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("AllByPartialSk", ctx, "LPA#123", "#DOCUMENT#", mock.Anything). + Return(func(ctx context.Context, pk, partialSk string, v interface{}) error { + b, _ := json.Marshal(page.Documents{}) + json.Unmarshal(b, v) + return dynamo.NotFoundError{} + }) + + documentStore := documentStore{dynamoClient: dynamoClient} + documents, err := documentStore.GetAll(ctx) + + assert.Nil(t, err) + assert.Equal(t, page.Documents{}, documents) +} + +func TestDocumentStoreUpdateScanResults(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("Update", + ctx, + "LPA#123", + "#DOCUMENT#object/key", + map[string]types.AttributeValue{ + ":virusDetected": &types.AttributeValueMemberBOOL{Value: true}, + ":scanned": &types.AttributeValueMemberBOOL{Value: true}, + }, "set VirusDetected = :virusDetected, Scanned = :scanned"). + Return(nil) + + documentStore := documentStore{dynamoClient: dynamoClient} + + err := documentStore.UpdateScanResults(ctx, "123", "object/key", true) + + assert.Nil(t, err) +} + +func TestDocumentStoreUpdateScanResultsWhenUpdateError(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("Update", + ctx, + "LPA#123", + "#DOCUMENT#object/key", + map[string]types.AttributeValue{ + ":virusDetected": &types.AttributeValueMemberBOOL{Value: true}, + ":scanned": &types.AttributeValueMemberBOOL{Value: true}, + }, "set VirusDetected = :virusDetected, Scanned = :scanned"). + Return(expectedError) + + documentStore := documentStore{dynamoClient: dynamoClient} + + err := documentStore.UpdateScanResults(ctx, "123", "object/key", true) + + assert.Equal(t, expectedError, err) +} + +func TestDocumentStorePut(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("Put", ctx, page.Document{Key: "a-key"}). + Return(nil) + + documentStore := documentStore{dynamoClient: dynamoClient} + + err := documentStore.Put(ctx, page.Document{Key: "a-key"}) + + assert.Nil(t, err) +} + +func TestDocumentStorePutWhenDynamoClientError(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("Put", ctx, page.Document{Key: "a-key"}). + Return(expectedError) + + documentStore := documentStore{dynamoClient: dynamoClient} + + err := documentStore.Put(ctx, page.Document{Key: "a-key"}) + + assert.Equal(t, expectedError, err) +} + +func TestDeleteInfectedDocuments(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + s3Client := newMockS3Client(t) + s3Client. + On("DeleteObjects", ctx, []string{"a-key", "another-key"}). + Return(nil) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("DeleteKeys", ctx, []dynamo.Key{ + {PK: "a-pk", SK: "a-sk"}, + {PK: "another-pk", SK: "another-sk"}, + }). + Return(nil) + + documentStore := documentStore{s3Client: s3Client, dynamoClient: dynamoClient} + + err := documentStore.DeleteInfectedDocuments(ctx, page.Documents{ + {PK: "a-pk", SK: "a-sk", Key: "a-key", VirusDetected: true}, + {PK: "another-pk", SK: "another-sk", Key: "another-key", VirusDetected: true}, + }) + + assert.Nil(t, err) +} + +func TestDeleteInfectedDocumentsWhenS3ClientError(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + s3Client := newMockS3Client(t) + s3Client. + On("DeleteObjects", ctx, []string{"a-key", "another-key"}). + Return(expectedError) + + documentStore := documentStore{s3Client: s3Client} + + err := documentStore.DeleteInfectedDocuments(ctx, page.Documents{ + {PK: "a-pk", SK: "a-sk", Key: "a-key", VirusDetected: true}, + {PK: "another-pk", SK: "another-sk", Key: "another-key", VirusDetected: true}, + }) + + assert.Equal(t, expectedError, err) +} + +func TestDeleteInfectedDocumentsWhenDynamoClientError(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + s3Client := newMockS3Client(t) + s3Client. + On("DeleteObjects", ctx, []string{"a-key", "another-key"}). + Return(nil) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("DeleteKeys", ctx, []dynamo.Key{ + {PK: "a-pk", SK: "a-sk"}, + {PK: "another-pk", SK: "another-sk"}, + }). + Return(expectedError) + + documentStore := documentStore{s3Client: s3Client, dynamoClient: dynamoClient} + + err := documentStore.DeleteInfectedDocuments(ctx, page.Documents{ + {PK: "a-pk", SK: "a-sk", Key: "a-key", VirusDetected: true}, + {PK: "another-pk", SK: "another-sk", Key: "another-key", VirusDetected: true}, + }) + + assert.Equal(t, expectedError, err) +} + +func TestDeleteInfectedDocumentsNonInfectedDocumentsAreNotDeleted(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + documentStore := documentStore{} + + err := documentStore.DeleteInfectedDocuments(ctx, page.Documents{ + {PK: "a-pk", SK: "a-sk", Key: "a-key"}, + {PK: "another-pk", SK: "another-sk", Key: "another-key"}, + }) + + assert.Nil(t, err) +} + +func TestDelete(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + s3Client := newMockS3Client(t) + s3Client. + On("DeleteObject", ctx, "a-key"). + Return(nil) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("DeleteOne", ctx, "a-pk", "a-sk"). + Return(nil) + + documentStore := documentStore{s3Client: s3Client, dynamoClient: dynamoClient} + + err := documentStore.Delete(ctx, page.Document{PK: "a-pk", SK: "a-sk", Key: "a-key", VirusDetected: true}) + + assert.Nil(t, err) +} + +func TestDeleteWhenS3ClientError(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + s3Client := newMockS3Client(t) + s3Client. + On("DeleteObject", ctx, "a-key"). + Return(expectedError) + + documentStore := documentStore{s3Client: s3Client} + + err := documentStore.Delete(ctx, page.Document{PK: "a-pk", SK: "a-sk", Key: "a-key", VirusDetected: true}) + + assert.Equal(t, expectedError, err) +} + +func TestDeleteWhenDynamoClientError(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + s3Client := newMockS3Client(t) + s3Client. + On("DeleteObject", ctx, "a-key"). + Return(nil) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("DeleteOne", ctx, "a-pk", "a-sk"). + Return(expectedError) + + documentStore := documentStore{s3Client: s3Client, dynamoClient: dynamoClient} + + err := documentStore.Delete(ctx, page.Document{PK: "a-pk", SK: "a-sk", Key: "a-key", VirusDetected: true}) + + assert.Equal(t, expectedError, err) +} + +func TestDocumentKey(t *testing.T) { + assert.Equal(t, "#DOCUMENT#key", documentKey("key")) +} + +func TestBatchPut(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("BatchPut", ctx, []interface{}{ + page.Document{PK: "a-pk", SK: "a-sk", Key: "a-key"}, + page.Document{PK: "aanother-pk", SK: "aanother-sk", Key: "aanother-key"}, + }). + Return(2, nil) + + documentStore := documentStore{dynamoClient: dynamoClient} + + err := documentStore.BatchPut(ctx, []page.Document{ + {PK: "a-pk", SK: "a-sk", Key: "a-key"}, + {PK: "aanother-pk", SK: "aanother-sk", Key: "aanother-key"}, + }) + + assert.Nil(t, err) +} + +func TestBatchPutPartialWrite(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("BatchPut", ctx, []interface{}{ + page.Document{PK: "a-pk", SK: "a-sk", Key: "a-key"}, + page.Document{PK: "aanother-pk", SK: "aanother-sk", Key: "aanother-key"}, + }). + Return(1, nil) + + documentStore := documentStore{dynamoClient: dynamoClient} + + err := documentStore.BatchPut(ctx, []page.Document{ + {PK: "a-pk", SK: "a-sk", Key: "a-key"}, + {PK: "aanother-pk", SK: "aanother-sk", Key: "aanother-key"}, + }) + + assert.Equal(t, PartialBatchWriteError{Written: 1, Expected: 2}, err) +} + +func TestBatchPutWhenDynamoError(t *testing.T) { + ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{LpaID: "123"}) + + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("BatchPut", ctx, []interface{}{ + page.Document{PK: "a-pk", SK: "a-sk", Key: "a-key"}, + page.Document{PK: "aanother-pk", SK: "aanother-sk", Key: "aanother-key"}, + }). + Return(0, expectedError) + + documentStore := documentStore{dynamoClient: dynamoClient} + + err := documentStore.BatchPut(ctx, []page.Document{ + {PK: "a-pk", SK: "a-sk", Key: "a-key"}, + {PK: "aanother-pk", SK: "aanother-sk", Key: "aanother-key"}, + }) + + assert.Equal(t, expectedError, err) +} diff --git a/internal/app/donor_store.go b/internal/app/donor_store.go index 8768a100e5..be8963e4b1 100644 --- a/internal/app/donor_store.go +++ b/internal/app/donor_store.go @@ -22,14 +22,23 @@ type EventClient interface { Send(context.Context, string, any) error } +//go:generate mockery --testonly --inpackage --name DocumentStore --structname mockDocumentStore +type DocumentStore interface { + GetAll(context.Context) (page.Documents, error) + Put(context.Context, page.Document) error + UpdateScanResults(context.Context, string, string, bool) error + BatchPut(context.Context, []page.Document) error +} + type donorStore struct { - dynamoClient DynamoClient - eventClient EventClient - uidClient UidClient - logger Logger - uuidString func() string - now func() time.Time - s3Client *s3.Client + dynamoClient DynamoClient + eventClient EventClient + uidClient UidClient + logger Logger + uuidString func() string + now func() time.Time + s3Client *s3.Client + documentStore DocumentStore } func (s *donorStore) Create(ctx context.Context) (*page.Lpa, error) { @@ -49,6 +58,7 @@ func (s *donorStore) Create(ctx context.Context) (*page.Lpa, error) { SK: donorKey(data.SessionID), ID: lpaID, CreatedAt: s.now(), + Version: 1, } if err := s.dynamoClient.Create(ctx, lpa); err != nil { @@ -170,25 +180,40 @@ func (s *donorStore) Put(ctx context.Context, lpa *page.Lpa) error { } } - if lpa.UID != "" && lpa.Tasks.PayForLpa.IsPending() && lpa.HasUnsentReducedFeesEvidence() { + if lpa.UID != "" && lpa.Tasks.PayForLpa.IsPending() { + documents, err := s.documentStore.GetAll(ctx) + if err != nil { + s.logger.Print(err) + return s.dynamoClient.Put(ctx, lpa) + } + var unsentKeys []string - for _, document := range lpa.Evidence.Documents { - if document.Sent.IsZero() { + for _, document := range documents { + if document.Sent.IsZero() && !document.Scanned { unsentKeys = append(unsentKeys, document.Key) } } - if err := s.eventClient.Send(ctx, "reduced-fee-requested", reducedFeeRequestedEvent{ - UID: lpa.UID, - RequestType: lpa.FeeType.String(), - Evidence: unsentKeys, - }); err != nil { - s.logger.Print(err) - } else { - for i, document := range lpa.Evidence.Documents { - if document.Sent.IsZero() { - lpa.Evidence.Documents[i].Sent = s.now() + if len(unsentKeys) > 0 { + if err := s.eventClient.Send(ctx, "reduced-fee-requested", reducedFeeRequestedEvent{ + UID: lpa.UID, + RequestType: lpa.FeeType.String(), + Evidence: unsentKeys, + }); err != nil { + s.logger.Print(err) + } else { + var updatedDocuments page.Documents + + for _, document := range documents { + if document.Sent.IsZero() && !document.Scanned { + document.Sent = s.now() + updatedDocuments = append(updatedDocuments, document) + } + } + + if err := s.documentStore.BatchPut(ctx, updatedDocuments); err != nil { + s.logger.Print(err) } } } @@ -263,11 +288,3 @@ type reducedFeeRequestedEvent struct { RequestType string `json:"requestType"` Evidence []string `json:"evidence"` } - -type address struct { - Line1 string `json:"line1,omitempty"` - Line2 string `json:"line2,omitempty"` - Line3 string `json:"line3,omitempty"` - TownOrCity string `json:"townOrCity,omitempty"` - Postcode string `json:"postcode,omitempty"` -} diff --git a/internal/app/donor_store_test.go b/internal/app/donor_store_test.go index acb81acd1b..3bf6390aad 100644 --- a/internal/app/donor_store_test.go +++ b/internal/app/donor_store_test.go @@ -288,6 +288,7 @@ func TestDonorStorePutWhenUIDNeeded(t *testing.T) { }, Type: page.LpaTypeHealthWelfare, }) + assert.Nil(t, err) } @@ -324,6 +325,7 @@ func TestDonorStorePutWhenUIDFails(t *testing.T) { }, Type: page.LpaTypeHealthWelfare, }) + assert.Nil(t, err) } @@ -362,6 +364,7 @@ func TestDonorStorePutWhenApplicationUpdatedWhenError(t *testing.T) { }, Type: page.LpaTypeHealthWelfare, }) + assert.Nil(t, err) } @@ -401,6 +404,7 @@ func TestDonorStorePutWhenPreviousApplicationLinked(t *testing.T) { PreviousApplicationNumber: "5555", HasSentApplicationUpdatedEvent: true, }) + assert.Nil(t, err) } @@ -424,6 +428,7 @@ func TestDonorStorePutWhenPreviousApplicationLinkedWontResend(t *testing.T) { HasSentApplicationUpdatedEvent: true, HasSentPreviousApplicationLinkedEvent: true, }) + assert.Nil(t, err) } @@ -455,10 +460,11 @@ func TestDonorStorePutWhenPreviousApplicationLinkedWhenError(t *testing.T) { PreviousApplicationNumber: "5555", HasSentApplicationUpdatedEvent: true, }) + assert.Nil(t, err) } -func TestDonorStorePutWhenReducedFeeRequested(t *testing.T) { +func TestDonorStorePutWhenReducedFeeRequestedAndUnsentDocuments(t *testing.T) { ctx := context.Background() now := time.Now() @@ -471,7 +477,6 @@ func TestDonorStorePutWhenReducedFeeRequested(t *testing.T) { UID: "M-1111", UpdatedAt: now, FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{{Key: "lpa-uid-evidence-a-uid", Filename: "whatever.pdf", Sent: now}}}, Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, HasSentApplicationUpdatedEvent: true, }). @@ -486,7 +491,17 @@ func TestDonorStorePutWhenReducedFeeRequested(t *testing.T) { }). Return(nil) - donorStore := &donorStore{dynamoClient: dynamoClient, eventClient: eventClient, now: func() time.Time { return now }} + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", ctx). + Return(page.Documents{ + {Key: "lpa-uid-evidence-a-uid", Filename: "whatever.pdf"}, + }, nil) + documentStore. + On("BatchPut", ctx, []page.Document{{Key: "lpa-uid-evidence-a-uid", Filename: "whatever.pdf", Sent: now}}). + Return(nil) + + donorStore := &donorStore{dynamoClient: dynamoClient, eventClient: eventClient, now: func() time.Time { return now }, documentStore: documentStore} err := donorStore.Put(ctx, &page.Lpa{ PK: "LPA#5", @@ -494,74 +509,62 @@ func TestDonorStorePutWhenReducedFeeRequested(t *testing.T) { ID: "5", UID: "M-1111", FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{{Key: "lpa-uid-evidence-a-uid", Filename: "whatever.pdf"}}}, Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, HasSentApplicationUpdatedEvent: true, }) + assert.Nil(t, err) } -func TestDonorStorePutWhenReducedFeeRequestedSentAndUnsentFees(t *testing.T) { +func TestDonorStorePutWhenReducedFeeRequestedWontResend(t *testing.T) { ctx := context.Background() now := time.Now() dynamoClient := newMockDynamoClient(t) dynamoClient. - On("Put", ctx, &page.Lpa{ - PK: "LPA#5", - SK: "#DONOR#an-id", - ID: "5", - UID: "M-1111", - UpdatedAt: now, - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid-evidence-a-uid-1", Filename: "whatever.pdf", Sent: now}, - {Key: "lpa-uid-evidence-a-uid-2", Filename: "whenever.pdf", Sent: now}, - {Key: "lpa-uid-evidence-a-uid-3", Filename: "whoever.pdf", Sent: now}, - }}, - Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, - HasSentApplicationUpdatedEvent: true, - }). + On("Put", ctx, mock.Anything). Return(nil) - eventClient := newMockEventClient(t) - eventClient. - On("Send", ctx, "reduced-fee-requested", reducedFeeRequestedEvent{ - UID: "M-1111", - RequestType: "HalfFee", - Evidence: []string{"lpa-uid-evidence-a-uid-1", "lpa-uid-evidence-a-uid-3"}, - }). - Return(nil) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", ctx). + Return(page.Documents{ + {Key: "lpa-uid-evidence-a-uid", Filename: "whatever.pdf", Sent: now}, + }, nil) - donorStore := &donorStore{dynamoClient: dynamoClient, eventClient: eventClient, now: func() time.Time { return now }} + donorStore := &donorStore{dynamoClient: dynamoClient, now: func() time.Time { return now }, documentStore: documentStore} err := donorStore.Put(ctx, &page.Lpa{ - PK: "LPA#5", - SK: "#DONOR#an-id", - ID: "5", - UID: "M-1111", - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid-evidence-a-uid-1", Filename: "whatever.pdf"}, - {Key: "lpa-uid-evidence-a-uid-2", Filename: "whenever.pdf", Sent: now}, - {Key: "lpa-uid-evidence-a-uid-3", Filename: "whoever.pdf"}, - }}, + PK: "LPA#5", + SK: "#DONOR#an-id", + ID: "5", + UID: "M-1111", Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, HasSentApplicationUpdatedEvent: true, }) + assert.Nil(t, err) } -func TestDonorStorePutWhenReducedFeeRequestedWontResend(t *testing.T) { +func TestDonorStorePutWhenReducedFeeRequestedWhenDocumentStoreGetAllError(t *testing.T) { ctx := context.Background() now := time.Now() + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", ctx). + Return(page.Documents{}, expectedError) + dynamoClient := newMockDynamoClient(t) dynamoClient. On("Put", ctx, mock.Anything). Return(nil) - donorStore := &donorStore{dynamoClient: dynamoClient, now: func() time.Time { return now }} + logger := newMockLogger(t) + logger. + On("Print", expectedError) + + donorStore := &donorStore{now: func() time.Time { return now }, documentStore: documentStore, logger: logger, dynamoClient: dynamoClient} err := donorStore.Put(ctx, &page.Lpa{ PK: "LPA#5", @@ -569,57 +572,107 @@ func TestDonorStorePutWhenReducedFeeRequestedWontResend(t *testing.T) { ID: "5", UID: "M-1111", Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, - Evidence: page.Evidence{Documents: []page.Document{{Key: "lpa-uid-evidence-a-uid-1", Filename: "whatever.pdf", Sent: now}}}, HasSentApplicationUpdatedEvent: true, }) + assert.Nil(t, err) } -func TestDonorStorePutWhenReducedFeeRequestedWhenError(t *testing.T) { +func TestDonorStorePutWhenReducedFeeRequestedAndUnsentDocumentsWhenEventClientSendError(t *testing.T) { ctx := context.Background() now := time.Now() + eventClient := newMockEventClient(t) + eventClient. + On("Send", ctx, "reduced-fee-requested", reducedFeeRequestedEvent{ + UID: "M-1111", + RequestType: "HalfFee", + Evidence: []string{"lpa-uid-evidence-a-uid"}, + }). + Return(expectedError) + dynamoClient := newMockDynamoClient(t) dynamoClient. - On("Put", ctx, &page.Lpa{ - PK: "LPA#5", - SK: "#DONOR#an-id", - ID: "5", - UID: "M-1111", - Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, - Evidence: page.Evidence{Documents: []page.Document{{Sent: now}, {}}}, - UpdatedAt: now, - HasSentApplicationUpdatedEvent: true, - }). + On("Put", ctx, mock.Anything). Return(nil) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", ctx). + Return(page.Documents{ + {Key: "lpa-uid-evidence-a-uid", Filename: "whatever.pdf"}, + }, nil) + + logger := newMockLogger(t) + logger. + On("Print", expectedError) + + donorStore := &donorStore{dynamoClient: dynamoClient, eventClient: eventClient, now: func() time.Time { return now }, documentStore: documentStore, logger: logger} + + err := donorStore.Put(ctx, &page.Lpa{ + PK: "LPA#5", + SK: "#DONOR#an-id", + ID: "5", + UID: "M-1111", + FeeType: page.HalfFee, + Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, + HasSentApplicationUpdatedEvent: true, + }) + + assert.Nil(t, err) +} + +func TestDonorStorePutWhenReducedFeeRequestedAndUnsentDocumentsWhenDocumentStoreBatchPutError(t *testing.T) { + ctx := context.Background() + now := time.Now() + eventClient := newMockEventClient(t) eventClient. - On("Send", ctx, "reduced-fee-requested", mock.Anything). + On("Send", ctx, "reduced-fee-requested", reducedFeeRequestedEvent{ + UID: "M-1111", + RequestType: "HalfFee", + Evidence: []string{"lpa-uid-evidence-a-uid"}, + }). + Return(nil) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", ctx). + Return(page.Documents{ + {Key: "lpa-uid-evidence-a-uid", Filename: "whatever.pdf"}, + }, nil) + documentStore. + On("BatchPut", ctx, []page.Document{{Key: "lpa-uid-evidence-a-uid", Filename: "whatever.pdf", Sent: now}}). Return(expectedError) logger := newMockLogger(t) logger. On("Print", expectedError) - donorStore := &donorStore{dynamoClient: dynamoClient, eventClient: eventClient, logger: logger, now: func() time.Time { return now }} + dynamoClient := newMockDynamoClient(t) + dynamoClient. + On("Put", ctx, mock.Anything). + Return(nil) + + donorStore := &donorStore{eventClient: eventClient, now: func() time.Time { return now }, documentStore: documentStore, dynamoClient: dynamoClient, logger: logger} err := donorStore.Put(ctx, &page.Lpa{ PK: "LPA#5", SK: "#DONOR#an-id", ID: "5", UID: "M-1111", + FeeType: page.HalfFee, Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, - Evidence: page.Evidence{Documents: []page.Document{{Sent: now}, {}}}, HasSentApplicationUpdatedEvent: true, }) + assert.Nil(t, err) } func TestDonorStoreCreate(t *testing.T) { ctx := page.ContextWithSessionData(context.Background(), &page.SessionData{SessionID: "an-id"}) now := time.Now() - lpa := &page.Lpa{PK: "LPA#10100000", SK: "#DONOR#an-id", ID: "10100000", CreatedAt: now} + lpa := &page.Lpa{PK: "LPA#10100000", SK: "#DONOR#an-id", ID: "10100000", CreatedAt: now, Version: 1} dynamoClient := newMockDynamoClient(t) dynamoClient. diff --git a/internal/app/mock_DocumentStore_test.go b/internal/app/mock_DocumentStore_test.go new file mode 100644 index 0000000000..ea6eb407ca --- /dev/null +++ b/internal/app/mock_DocumentStore_test.go @@ -0,0 +1,98 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package app + +import ( + context "context" + + page "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + mock "github.com/stretchr/testify/mock" +) + +// mockDocumentStore is an autogenerated mock type for the DocumentStore type +type mockDocumentStore struct { + mock.Mock +} + +// BatchPut provides a mock function with given fields: _a0, _a1 +func (_m *mockDocumentStore) BatchPut(_a0 context.Context, _a1 []page.Document) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []page.Document) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetAll provides a mock function with given fields: _a0 +func (_m *mockDocumentStore) GetAll(_a0 context.Context) (page.Documents, error) { + ret := _m.Called(_a0) + + var r0 page.Documents + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (page.Documents, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(context.Context) page.Documents); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(page.Documents) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Put provides a mock function with given fields: _a0, _a1 +func (_m *mockDocumentStore) Put(_a0 context.Context, _a1 page.Document) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, page.Document) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateScanResults provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *mockDocumentStore) UpdateScanResults(_a0 context.Context, _a1 string, _a2 string, _a3 bool) error { + ret := _m.Called(_a0, _a1, _a2, _a3) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, bool) error); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTnewMockDocumentStore interface { + mock.TestingT + Cleanup(func()) +} + +// newMockDocumentStore creates a new instance of mockDocumentStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func newMockDocumentStore(t mockConstructorTestingTnewMockDocumentStore) *mockDocumentStore { + mock := &mockDocumentStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/app/mock_DynamoClient_test.go b/internal/app/mock_DynamoClient_test.go index bf2d9feb15..882b2e2312 100644 --- a/internal/app/mock_DynamoClient_test.go +++ b/internal/app/mock_DynamoClient_test.go @@ -96,6 +96,30 @@ func (_m *mockDynamoClient) AllKeysByPk(ctx context.Context, pk string) ([]dynam return r0, r1 } +// BatchPut provides a mock function with given fields: ctx, items +func (_m *mockDynamoClient) BatchPut(ctx context.Context, items []interface{}) (int, error) { + ret := _m.Called(ctx, items) + + var r0 int + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []interface{}) (int, error)); ok { + return rf(ctx, items) + } + if rf, ok := ret.Get(0).(func(context.Context, []interface{}) int); ok { + r0 = rf(ctx, items) + } else { + r0 = ret.Get(0).(int) + } + + if rf, ok := ret.Get(1).(func(context.Context, []interface{}) error); ok { + r1 = rf(ctx, items) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, v func (_m *mockDynamoClient) Create(ctx context.Context, v interface{}) error { ret := _m.Called(ctx, v) @@ -124,6 +148,20 @@ func (_m *mockDynamoClient) DeleteKeys(ctx context.Context, keys []dynamo.Key) e return r0 } +// DeleteOne provides a mock function with given fields: ctx, pk, sk +func (_m *mockDynamoClient) DeleteOne(ctx context.Context, pk string, sk string) error { + ret := _m.Called(ctx, pk, sk) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok { + r0 = rf(ctx, pk, sk) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // LatestForActor provides a mock function with given fields: ctx, sk, v func (_m *mockDynamoClient) LatestForActor(ctx context.Context, sk string, v interface{}) error { ret := _m.Called(ctx, sk, v) @@ -180,6 +218,20 @@ func (_m *mockDynamoClient) Put(ctx context.Context, v interface{}) error { return r0 } +// Update provides a mock function with given fields: ctx, pk, sk, values, expression +func (_m *mockDynamoClient) Update(ctx context.Context, pk string, sk string, values map[string]types.AttributeValue, expression string) error { + ret := _m.Called(ctx, pk, sk, values, expression) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, map[string]types.AttributeValue, string) error); ok { + r0 = rf(ctx, pk, sk, values, expression) + } else { + r0 = ret.Error(0) + } + + return r0 +} + type mockConstructorTestingTnewMockDynamoClient interface { mock.TestingT Cleanup(func()) diff --git a/internal/app/mock_S3Client_test.go b/internal/app/mock_S3Client_test.go index c5e81359e5..b401a6584f 100644 --- a/internal/app/mock_S3Client_test.go +++ b/internal/app/mock_S3Client_test.go @@ -28,6 +28,20 @@ func (_m *mockS3Client) DeleteObject(_a0 context.Context, _a1 string) error { return r0 } +// DeleteObjects provides a mock function with given fields: ctx, keys +func (_m *mockS3Client) DeleteObjects(ctx context.Context, keys []string) error { + ret := _m.Called(ctx, keys) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []string) error); ok { + r0 = rf(ctx, keys) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // PutObject provides a mock function with given fields: _a0, _a1, _a2 func (_m *mockS3Client) PutObject(_a0 context.Context, _a1 string, _a2 []byte) error { ret := _m.Called(_a0, _a1, _a2) diff --git a/internal/dynamo/client.go b/internal/dynamo/client.go index b1522cb34d..090f7094fa 100644 --- a/internal/dynamo/client.go +++ b/internal/dynamo/client.go @@ -2,8 +2,8 @@ package dynamo import ( "context" + "errors" "fmt" - "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" @@ -23,11 +23,20 @@ type dynamoDB interface { BatchGetItem(context.Context, *dynamodb.BatchGetItemInput, ...func(*dynamodb.Options)) (*dynamodb.BatchGetItemOutput, error) PutItem(context.Context, *dynamodb.PutItemInput, ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) TransactWriteItems(context.Context, *dynamodb.TransactWriteItemsInput, ...func(*dynamodb.Options)) (*dynamodb.TransactWriteItemsOutput, error) + DeleteItem(context.Context, *dynamodb.DeleteItemInput, ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) + UpdateItem(context.Context, *dynamodb.UpdateItemInput, ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error) + BatchWriteItem(ctx context.Context, params *dynamodb.BatchWriteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.BatchWriteItemOutput, error) +} + +//go:generate mockery --testonly --inpackage --name Logger --structname mockLogger +type Logger interface { + Print(v ...interface{}) } type Client struct { - table string - svc dynamoDB + table string + svc dynamoDB + logger Logger } type NotFoundError struct{} @@ -42,6 +51,12 @@ func (n MultipleResultsError) Error() string { return "A single result was expected but multiple results found" } +type ConditionalCheckFailedError struct{} + +func (c ConditionalCheckFailedError) Error() string { + return "Conditional checks failed" +} + func NewClient(cfg aws.Config, tableName string) (*Client, error) { return &Client{table: tableName, svc: dynamodb.NewFromConfig(cfg)}, nil } @@ -232,24 +247,41 @@ func (c *Client) Put(ctx context.Context, v interface{}) error { Item: item, } - if updatedAt, exists := item["UpdatedAt"]; exists { - var updatedAtTime time.Time - err = attributevalue.Unmarshal(updatedAt, &updatedAtTime) + // Tracking Value equality against data on write allows for optimistic locking + if currentVersion, exists := item["Version"]; exists { + var v int + err = attributevalue.Unmarshal(currentVersion, &v) if err != nil { return err } - if !updatedAtTime.IsZero() { - input.ConditionExpression = aws.String("UpdatedAt < :updatedAt") - input.ExpressionAttributeValues = map[string]types.AttributeValue{ - ":updatedAt": updatedAt, - } + v++ + newVersion, err := attributevalue.Marshal(v) + if err != nil { + return err + } + + item["Version"] = newVersion + + input.Item = item + input.ConditionExpression = aws.String("Version = :version") + input.ExpressionAttributeValues = map[string]types.AttributeValue{ + ":version": currentVersion, } } _, err = c.svc.PutItem(ctx, input) - return err + if err != nil { + var ccf *types.ConditionalCheckFailedException + if errors.As(err, &ccf) { + return ConditionalCheckFailedError{} + } + + return err + } + + return nil } func (c *Client) Create(ctx context.Context, v interface{}) error { @@ -288,3 +320,75 @@ func (c *Client) DeleteKeys(ctx context.Context, keys []Key) error { return err } + +func (c *Client) DeleteOne(ctx context.Context, pk, sk string) error { + _, err := c.svc.DeleteItem(ctx, &dynamodb.DeleteItemInput{ + TableName: aws.String(c.table), + Key: map[string]types.AttributeValue{ + "PK": &types.AttributeValueMemberS{Value: pk}, + "SK": &types.AttributeValueMemberS{Value: sk}, + }, + }) + + return err +} + +func (c *Client) Update(ctx context.Context, pk, sk string, values map[string]types.AttributeValue, expression string) error { + _, err := c.svc.UpdateItem(ctx, &dynamodb.UpdateItemInput{ + TableName: aws.String(c.table), + Key: map[string]types.AttributeValue{ + "PK": &types.AttributeValueMemberS{Value: pk}, + "SK": &types.AttributeValueMemberS{Value: sk}, + }, + ExpressionAttributeValues: values, + UpdateExpression: aws.String(expression), + }) + + return err +} + +func (c *Client) BatchPut(ctx context.Context, items []interface{}) (int, error) { + // courtesy of https://docs.aws.amazon.com/code-library/latest/ug/go_2_dynamodb_code_examples.html + + var err error + var itemValues map[string]types.AttributeValue + + written := 0 + batchSize := 25 // DynamoDB allows a maximum batch size of 25 items. + start := 0 + end := start + batchSize + + for start < len(items) { + var writeReqs []types.WriteRequest + if end > len(items) { + end = len(items) + } + + for _, item := range items[start:end] { + itemValues, err = attributevalue.MarshalMap(item) + if err != nil { + c.logger.Print("failed to marshal item during BatchPut: ", err) + } else { + writeReqs = append( + writeReqs, + types.WriteRequest{PutRequest: &types.PutRequest{Item: itemValues}}, + ) + } + } + + _, err := c.svc.BatchWriteItem(ctx, &dynamodb.BatchWriteItemInput{ + RequestItems: map[string][]types.WriteRequest{c.table: writeReqs}, + }) + + if err != nil { + c.logger.Print("failed to add a batch of item during BatchPut: ", err) + } else { + written += len(writeReqs) + } + + start = end + end += batchSize + } + + return written, err +} diff --git a/internal/dynamo/client_test.go b/internal/dynamo/client_test.go index 7be0c886a7..c9fd0323d3 100644 --- a/internal/dynamo/client_test.go +++ b/internal/dynamo/client_test.go @@ -10,6 +10,7 @@ import ( "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" "github.com/aws/aws-sdk-go-v2/service/dynamodb" "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/aws/smithy-go" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" "github.com/stretchr/testify/assert" mock "github.com/stretchr/testify/mock" @@ -541,26 +542,46 @@ func TestPut(t *testing.T) { } } -func TestPutWhenStructHasUpdatedAt(t *testing.T) { +func TestPutWhenStructHasVersion(t *testing.T) { ctx := context.Background() - data, _ := attributevalue.MarshalMap(map[string]string{"Col": "Val", "UpdatedAt": "2023-10-04T10:51:44.021428675Z"}) + data, _ := attributevalue.MarshalMap(map[string]any{"Col": "Val", "Version": 2}) dynamoDB := newMockDynamoDB(t) dynamoDB. On("PutItem", ctx, &dynamodb.PutItemInput{ TableName: aws.String("this"), Item: data, - ConditionExpression: aws.String("UpdatedAt < :updatedAt"), - ExpressionAttributeValues: map[string]types.AttributeValue{":updatedAt": &types.AttributeValueMemberS{Value: "2023-10-04T10:51:44.021428675Z"}}, + ConditionExpression: aws.String("Version = :version"), + ExpressionAttributeValues: map[string]types.AttributeValue{":version": &types.AttributeValueMemberN{Value: "1"}}, }). Return(&dynamodb.PutItemOutput{}, nil) c := &Client{table: "this", svc: dynamoDB} - err := c.Put(ctx, map[string]string{"Col": "Val", "UpdatedAt": "2023-10-04T10:51:44.021428675Z"}) + err := c.Put(ctx, map[string]any{"Col": "Val", "Version": 1}) assert.Nil(t, err) } +func TestPutWhenConditionalCheckFailedException(t *testing.T) { + ctx := context.Background() + data, _ := attributevalue.MarshalMap(map[string]any{"Col": "Val", "Version": 2}) + + dynamoDB := newMockDynamoDB(t) + dynamoDB. + On("PutItem", ctx, &dynamodb.PutItemInput{ + TableName: aws.String("this"), + Item: data, + ConditionExpression: aws.String("Version = :version"), + ExpressionAttributeValues: map[string]types.AttributeValue{":version": &types.AttributeValueMemberN{Value: "1"}}, + }). + Return(&dynamodb.PutItemOutput{}, &smithy.OperationError{Err: &types.ConditionalCheckFailedException{}}) + + c := &Client{table: "this", svc: dynamoDB} + + err := c.Put(ctx, map[string]any{"Col": "Val", "Version": 1}) + assert.Equal(t, ConditionalCheckFailedError{}, err) +} + func TestPutWhenError(t *testing.T) { ctx := context.Background() @@ -580,7 +601,7 @@ func TestPutWhenUnmarshalError(t *testing.T) { c := &Client{table: "this", svc: newMockDynamoDB(t)} - err := c.Put(ctx, map[string]string{"Col": "Val", "UpdatedAt": "not a date"}) + err := c.Put(ctx, map[string]string{"Col": "Val", "Version": "not an int"}) assert.NotNil(t, err) } @@ -651,3 +672,164 @@ func TestDeleteKeys(t *testing.T) { err := c.DeleteKeys(ctx, []Key{{PK: "pk", SK: "sk1"}, {PK: "pk", SK: "sk2"}}) assert.Equal(t, expectedError, err) } + +func TestUpdate(t *testing.T) { + ctx := context.Background() + + dynamoDB := newMockDynamoDB(t) + dynamoDB. + On("UpdateItem", ctx, &dynamodb.UpdateItemInput{ + TableName: aws.String("table-name"), + Key: map[string]types.AttributeValue{ + "PK": &types.AttributeValueMemberS{Value: "a-pk"}, + "SK": &types.AttributeValueMemberS{Value: "a-sk"}, + }, + ExpressionAttributeValues: map[string]types.AttributeValue{"prop": &types.AttributeValueMemberS{Value: "val"}}, + UpdateExpression: aws.String("some = expression"), + }). + Return(nil, nil) + + c := &Client{table: "table-name", svc: dynamoDB} + + err := c.Update(ctx, "a-pk", "a-sk", map[string]types.AttributeValue{"prop": &types.AttributeValueMemberS{Value: "val"}}, "some = expression") + + assert.Nil(t, err) +} + +func TestUpdateOnServiceError(t *testing.T) { + ctx := context.Background() + + dynamoDB := newMockDynamoDB(t) + dynamoDB. + On("UpdateItem", ctx, &dynamodb.UpdateItemInput{ + TableName: aws.String("table-name"), + Key: map[string]types.AttributeValue{ + "PK": &types.AttributeValueMemberS{Value: "a-pk"}, + "SK": &types.AttributeValueMemberS{Value: "a-sk"}, + }, + ExpressionAttributeValues: map[string]types.AttributeValue{"Col": &types.AttributeValueMemberS{Value: "Val"}}, + UpdateExpression: aws.String("some = expression"), + }). + Return(nil, expectedError) + + c := &Client{table: "table-name", svc: dynamoDB} + + err := c.Update(ctx, "a-pk", "a-sk", map[string]types.AttributeValue{"Col": &types.AttributeValueMemberS{Value: "Val"}}, "some = expression") + + assert.Equal(t, expectedError, err) +} + +func TestBatchPutOneBatch(t *testing.T) { + ctx := context.Background() + + type testObject struct { + Col string + } + + item, _ := attributevalue.MarshalMap(map[string]string{"Col": "Val"}) + var items []interface{} + var writeReqs []types.WriteRequest + + count := 0 + for count < 25 { + items = append(items, testObject{Col: "Val"}) + + writeReqs = append( + writeReqs, + types.WriteRequest{PutRequest: &types.PutRequest{Item: item}}, + ) + + count++ + } + + dynamoDB := newMockDynamoDB(t) + dynamoDB. + On("BatchWriteItem", ctx, &dynamodb.BatchWriteItemInput{ + RequestItems: map[string][]types.WriteRequest{"table-name": writeReqs}, + }). + Return(nil, nil). + Once() + + c := &Client{table: "table-name", svc: dynamoDB} + written, err := c.BatchPut(ctx, items) + + assert.Nil(t, err) + assert.Equal(t, 25, written) +} + +func TestBatchPutMultipleBatches(t *testing.T) { + ctx := context.Background() + + type testObject struct { + Col string + } + + item, _ := attributevalue.MarshalMap(map[string]string{"Col": "Val"}) + var items []interface{} + var writeReqs []types.WriteRequest + + count := 0 + for count < 26 { + items = append(items, testObject{Col: "Val"}) + + writeReqs = append( + writeReqs, + types.WriteRequest{PutRequest: &types.PutRequest{Item: item}}, + ) + count++ + } + + dynamoDB := newMockDynamoDB(t) + dynamoDB. + On("BatchWriteItem", ctx, &dynamodb.BatchWriteItemInput{ + RequestItems: map[string][]types.WriteRequest{"table-name": writeReqs[0:25]}, + }). + Return(nil, nil). + Once() + + dynamoDB. + On("BatchWriteItem", ctx, &dynamodb.BatchWriteItemInput{ + RequestItems: map[string][]types.WriteRequest{"table-name": { + {PutRequest: &types.PutRequest{Item: item}}, + }}, + }). + Return(nil, nil). + Once() + + c := &Client{table: "table-name", svc: dynamoDB} + written, err := c.BatchPut(ctx, items) + + assert.Nil(t, err) + assert.Equal(t, 26, written) +} + +func TestBatchPutWhenDynamoBatchWriteItemError(t *testing.T) { + ctx := context.Background() + + type testObject struct { + Col string + } + + item, _ := attributevalue.MarshalMap(map[string]string{"Col": "Val"}) + items := []interface{}{testObject{Col: "Val"}} + writeReqs := []types.WriteRequest{ + {PutRequest: &types.PutRequest{Item: item}}, + } + + dynamoDB := newMockDynamoDB(t) + dynamoDB. + On("BatchWriteItem", ctx, &dynamodb.BatchWriteItemInput{ + RequestItems: map[string][]types.WriteRequest{"table-name": writeReqs}, + }). + Return(nil, expectedError) + + logger := newMockLogger(t) + logger. + On("Print", "failed to add a batch of item during BatchPut: ", expectedError) + + c := &Client{table: "table-name", svc: dynamoDB, logger: logger} + written, err := c.BatchPut(ctx, items) + + assert.Nil(t, err) + assert.Equal(t, 0, written) +} diff --git a/internal/dynamo/mock_Logger_test.go b/internal/dynamo/mock_Logger_test.go new file mode 100644 index 0000000000..800de662a1 --- /dev/null +++ b/internal/dynamo/mock_Logger_test.go @@ -0,0 +1,32 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package dynamo + +import mock "github.com/stretchr/testify/mock" + +// mockLogger is an autogenerated mock type for the Logger type +type mockLogger struct { + mock.Mock +} + +// Print provides a mock function with given fields: v +func (_m *mockLogger) Print(v ...interface{}) { + var _ca []interface{} + _ca = append(_ca, v...) + _m.Called(_ca...) +} + +type mockConstructorTestingTnewMockLogger interface { + mock.TestingT + Cleanup(func()) +} + +// 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. +func newMockLogger(t mockConstructorTestingTnewMockLogger) *mockLogger { + mock := &mockLogger{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/dynamo/mock_dynamoDB_test.go b/internal/dynamo/mock_dynamoDB_test.go index 2963def7de..15747c11da 100644 --- a/internal/dynamo/mock_dynamoDB_test.go +++ b/internal/dynamo/mock_dynamoDB_test.go @@ -47,6 +47,72 @@ func (_m *mockDynamoDB) BatchGetItem(_a0 context.Context, _a1 *dynamodb.BatchGet return r0, r1 } +// BatchWriteItem provides a mock function with given fields: ctx, params, optFns +func (_m *mockDynamoDB) BatchWriteItem(ctx context.Context, params *dynamodb.BatchWriteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.BatchWriteItemOutput, error) { + _va := make([]interface{}, len(optFns)) + for _i := range optFns { + _va[_i] = optFns[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, params) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *dynamodb.BatchWriteItemOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *dynamodb.BatchWriteItemInput, ...func(*dynamodb.Options)) (*dynamodb.BatchWriteItemOutput, error)); ok { + return rf(ctx, params, optFns...) + } + if rf, ok := ret.Get(0).(func(context.Context, *dynamodb.BatchWriteItemInput, ...func(*dynamodb.Options)) *dynamodb.BatchWriteItemOutput); ok { + r0 = rf(ctx, params, optFns...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dynamodb.BatchWriteItemOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *dynamodb.BatchWriteItemInput, ...func(*dynamodb.Options)) error); ok { + r1 = rf(ctx, params, optFns...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeleteItem provides a mock function with given fields: _a0, _a1, _a2 +func (_m *mockDynamoDB) DeleteItem(_a0 context.Context, _a1 *dynamodb.DeleteItemInput, _a2 ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *dynamodb.DeleteItemOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *dynamodb.DeleteItemInput, ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error)); ok { + return rf(_a0, _a1, _a2...) + } + if rf, ok := ret.Get(0).(func(context.Context, *dynamodb.DeleteItemInput, ...func(*dynamodb.Options)) *dynamodb.DeleteItemOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dynamodb.DeleteItemOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *dynamodb.DeleteItemInput, ...func(*dynamodb.Options)) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetItem provides a mock function with given fields: _a0, _a1, _a2 func (_m *mockDynamoDB) GetItem(_a0 context.Context, _a1 *dynamodb.GetItemInput, _a2 ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) { _va := make([]interface{}, len(_a2)) @@ -179,6 +245,39 @@ func (_m *mockDynamoDB) TransactWriteItems(_a0 context.Context, _a1 *dynamodb.Tr return r0, r1 } +// UpdateItem provides a mock function with given fields: _a0, _a1, _a2 +func (_m *mockDynamoDB) UpdateItem(_a0 context.Context, _a1 *dynamodb.UpdateItemInput, _a2 ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *dynamodb.UpdateItemOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *dynamodb.UpdateItemInput, ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error)); ok { + return rf(_a0, _a1, _a2...) + } + if rf, ok := ret.Get(0).(func(context.Context, *dynamodb.UpdateItemInput, ...func(*dynamodb.Options)) *dynamodb.UpdateItemOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*dynamodb.UpdateItemOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *dynamodb.UpdateItemInput, ...func(*dynamodb.Options)) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + type mockConstructorTestingTnewMockDynamoDB interface { mock.TestingT Cleanup(func()) diff --git a/internal/page/data.go b/internal/page/data.go index 53d6e4a669..9fae0a2552 100644 --- a/internal/page/data.go +++ b/internal/page/data.go @@ -152,6 +152,8 @@ type Lpa struct { RegisteredAt time.Time // WithdrawnAt is when the Lpa was withdrawn by the donor WithdrawnAt time.Time + // Version is the number of times the LPA has been updated (auto-incremented on PUT) + Version int // Codes used for the certificate provider to witness signing CertificateProviderCodes WitnessCodes @@ -166,8 +168,6 @@ type Lpa struct { // FeeType is the type of fee the user is applying for FeeType FeeType - // Evidence is the documents uploaded by a donor to apply for non-full fees - Evidence Evidence // PreviousApplicationNumber if the application is related to an existing application PreviousApplicationNumber string // PreviousFee is the fee previously paid for an LPA @@ -177,58 +177,6 @@ type Lpa struct { HasSentPreviousApplicationLinkedEvent bool } -type Evidence struct { - Documents []Document -} - -func (e *Evidence) Delete(documentKey string) bool { - idx := slices.IndexFunc(e.Documents, func(d Document) bool { return d.Key == documentKey }) - if idx == -1 { - return false - } - - e.Documents = slices.Delete(e.Documents, idx, idx+1) - - return true -} - -func (e *Evidence) Keys() []string { - var keys []string - - for _, d := range e.Documents { - keys = append(keys, d.Key) - } - - return keys -} - -func (e *Evidence) Get(documentKey string) Document { - for _, d := range e.Documents { - if d.Key == documentKey { - return d - } - } - - return Document{} -} - -func (e *Evidence) Put(document Document) { - idx := slices.IndexFunc(e.Documents, func(d Document) bool { return d.Key == document.Key }) - if idx == -1 { - e.Documents = append(e.Documents, document) - } else { - e.Documents[idx] = document - } -} - -type Document struct { - Key string - Filename string - Sent time.Time - Scanned time.Time - VirusDetected bool -} - type Payment struct { // Reference generated for the payment PaymentReference string @@ -558,15 +506,6 @@ func (l *Lpa) FeeAmount() int { return l.Cost() - paid } -func (l *Lpa) HasUnsentReducedFeesEvidence() bool { - for _, document := range l.Evidence.Documents { - if document.Sent.IsZero() { - return true - } - } - return false -} - // CertificateProviderSharesDetails will return true if the last name or address // of the certificate provider matches that of the donor or one of the // attorneys. For a match of the last name we break on '-' to account for diff --git a/internal/page/data_test.go b/internal/page/data_test.go index 460467a004..d0b377c959 100644 --- a/internal/page/data_test.go +++ b/internal/page/data_test.go @@ -1128,70 +1128,6 @@ func TestFeeAmount(t *testing.T) { } -func TestHasUnsentReducedFeesEvidence(t *testing.T) { - lpa := Lpa{Evidence: Evidence{Documents: []Document{ - {Sent: time.Now()}, {}, {Sent: time.Now()}}}, - } - - assert.True(t, lpa.HasUnsentReducedFeesEvidence()) - - lpa.Evidence = Evidence{Documents: []Document{ - {Sent: time.Now()}, {Sent: time.Now()}}, - } - - assert.False(t, lpa.HasUnsentReducedFeesEvidence()) -} - -func TestEvidenceDelete(t *testing.T) { - evidence := Evidence{Documents: []Document{ - {Key: "a-key"}, - {Key: "another-key"}, - }} - - assert.True(t, evidence.Delete("a-key")) - assert.Equal(t, Evidence{Documents: []Document{ - {Key: "another-key"}, - }}, evidence) - - assert.True(t, evidence.Delete("another-key")) - assert.Equal(t, Evidence{Documents: []Document{}}, evidence) - - assert.False(t, evidence.Delete("not-a-key")) -} - -func TestEvidenceKeys(t *testing.T) { - evidence := Evidence{Documents: []Document{ - {Key: "a-key"}, - {Key: "another-key"}, - }} - - assert.Equal(t, []string{"a-key", "another-key"}, evidence.Keys()) -} - -func TestEvidenceGetByKey(t *testing.T) { - evidence := Evidence{Documents: []Document{ - {Key: "a-key"}, - {Key: "another-key"}, - }} - - assert.Equal(t, Document{Key: "a-key"}, evidence.Get("a-key")) - assert.Equal(t, Document{Key: "another-key"}, evidence.Get("another-key")) - assert.Equal(t, Document{}, evidence.Get("not-a-key")) -} - -func TestEvidencePut(t *testing.T) { - evidence := Evidence{Documents: []Document{ - {Key: "a-key", Filename: "a-filename"}, - {Key: "another-key", Filename: "another-filename"}, - }} - - evidence.Put(Document{Key: "a-key", Filename: "a-new-filename"}) - assert.Equal(t, Document{Key: "a-key", Filename: "a-new-filename"}, evidence.Documents[0]) - - evidence.Put(Document{Key: "new-key", Filename: "a-filename"}) - assert.Equal(t, Document{Key: "new-key", Filename: "a-filename"}, evidence.Documents[2]) -} - func TestCertificateProviderSharesDetailsNames(t *testing.T) { testcases := map[string]struct { certificateProvider string diff --git a/internal/page/document.go b/internal/page/document.go new file mode 100644 index 0000000000..6eb0ee775f --- /dev/null +++ b/internal/page/document.go @@ -0,0 +1,103 @@ +package page + +import ( + "slices" + "time" +) + +type Document struct { + PK, SK string + Filename string + VirusDetected bool + Scanned bool + Key string + Sent time.Time +} + +type Documents []Document + +func (ds *Documents) Delete(documentKey string) bool { + idx := slices.IndexFunc(*ds, func(ds Document) bool { return ds.Key == documentKey }) + if idx == -1 { + return false + } + + *ds = slices.Delete(*ds, idx, idx+1) + + return true +} + +func (ds *Documents) Keys() []string { + var keys []string + + for _, ds := range *ds { + keys = append(keys, ds.Key) + } + + return keys +} + +func (ds *Documents) Put(scannedDocument Document) { + idx := slices.IndexFunc(*ds, func(ds Document) bool { return ds.Key == scannedDocument.Key }) + if idx == -1 { + *ds = append(*ds, scannedDocument) + } else { + (*ds)[idx] = scannedDocument + } +} + +func (ds *Documents) InfectedFilenames() []string { + var filenames []string + + for _, d := range *ds { + if d.VirusDetected { + filenames = append(filenames, d.Filename) + } + } + + return filenames +} + +func (ds *Documents) Scanned() Documents { + var documents Documents + + for _, d := range *ds { + if d.Scanned { + documents = append(documents, d) + } + } + + return documents +} + +func (ds *Documents) NotScanned() Documents { + var documents Documents + + for _, d := range *ds { + if !d.Scanned { + documents = append(documents, d) + } + } + + return documents +} + +func (ds *Documents) Filenames() []string { + var filenames []string + + for _, ds := range *ds { + filenames = append(filenames, ds.Filename) + } + + return filenames +} + +func (ds *Documents) Get(documentKey string) Document { + for _, d := range *ds { + if d.Key == documentKey { + return d + } + } + + return Document{} +} diff --git a/internal/page/document_test.go b/internal/page/document_test.go new file mode 100644 index 0000000000..b8a637aea7 --- /dev/null +++ b/internal/page/document_test.go @@ -0,0 +1,99 @@ +package page + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDocumentsDelete(t *testing.T) { + documents := Documents{ + {Key: "a-key"}, + {Key: "another-key"}, + } + + assert.True(t, documents.Delete("a-key")) + assert.Equal(t, Documents{{Key: "another-key"}}, documents) + + assert.True(t, documents.Delete("another-key")) + assert.Equal(t, Documents{}, documents) + + assert.False(t, documents.Delete("not-a-key")) +} + +func TestDocumentsKeys(t *testing.T) { + documents := Documents{ + {Key: "a-key"}, + {Key: "another-key"}, + } + + assert.Equal(t, []string{"a-key", "another-key"}, documents.Keys()) +} + +func TestDocumentsGet(t *testing.T) { + documents := Documents{ + {Key: "a-key"}, + {Key: "another-key"}, + } + + assert.Equal(t, Document{Key: "a-key"}, documents.Get("a-key")) + assert.Equal(t, Document{Key: "another-key"}, documents.Get("another-key")) + assert.Equal(t, Document{}, documents.Get("not-a-key")) +} + +func TestDocumentsPut(t *testing.T) { + documents := Documents{ + {Key: "a-key", Filename: "a-filename"}, + {Key: "another-key", Filename: "another-filename"}, + } + + documents.Put(Document{Key: "a-key", Filename: "a-new-filename"}) + assert.Equal(t, Document{Key: "a-key", Filename: "a-new-filename"}, documents[0]) + + documents.Put(Document{Key: "new-key", Filename: "a-filename"}) + assert.Equal(t, Document{Key: "new-key", Filename: "a-filename"}, documents[2]) +} + +func TestDocumentsInfectedFilenames(t *testing.T) { + documents := Documents{ + {Key: "a-key", Filename: "a-filename"}, + {Key: "another-key", Filename: "another-filename", VirusDetected: true}, + } + + assert.Equal(t, []string{"another-filename"}, documents.InfectedFilenames()) +} + +func TestDocumentsScanned(t *testing.T) { + documents := Documents{ + {Key: "a-key", Filename: "a-filename"}, + {Key: "another-key", Filename: "another-filename", Scanned: true}, + {Key: "more-key", Filename: "another-filename", Scanned: true}, + } + + assert.Equal(t, Documents{ + {Key: "another-key", Filename: "another-filename", Scanned: true}, + {Key: "more-key", Filename: "another-filename", Scanned: true}, + }, documents.Scanned()) +} + +func TestDocumentsNotScanned(t *testing.T) { + documents := Documents{ + {Key: "a-key", Filename: "a-filename", Scanned: true}, + {Key: "another-key", Filename: "another-filename"}, + {Key: "more-key", Filename: "another-filename"}, + } + + assert.Equal(t, Documents{ + {Key: "another-key", Filename: "another-filename"}, + {Key: "more-key", Filename: "another-filename"}, + }, documents.NotScanned()) +} + +func TestDocumentsFilenames(t *testing.T) { + documents := Documents{ + {Key: "a-key", Filename: "a-filename"}, + {Key: "another-key", Filename: "another-filename", VirusDetected: true}, + } + + assert.Equal(t, []string{"a-filename", "another-filename"}, documents.Filenames()) +} diff --git a/internal/page/donor/mock_DocumentStore_test.go b/internal/page/donor/mock_DocumentStore_test.go new file mode 100644 index 0000000000..d453807f79 --- /dev/null +++ b/internal/page/donor/mock_DocumentStore_test.go @@ -0,0 +1,122 @@ +// Code generated by mockery v2.20.0. DO NOT EDIT. + +package donor + +import ( + context "context" + + page "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + mock "github.com/stretchr/testify/mock" +) + +// mockDocumentStore is an autogenerated mock type for the DocumentStore type +type mockDocumentStore struct { + mock.Mock +} + +// Create provides a mock function with given fields: _a0, _a1, _a2, _a3 +func (_m *mockDocumentStore) Create(_a0 context.Context, _a1 *page.Lpa, _a2 string, _a3 []byte) (page.Document, error) { + ret := _m.Called(_a0, _a1, _a2, _a3) + + var r0 page.Document + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *page.Lpa, string, []byte) (page.Document, error)); ok { + return rf(_a0, _a1, _a2, _a3) + } + if rf, ok := ret.Get(0).(func(context.Context, *page.Lpa, string, []byte) page.Document); ok { + r0 = rf(_a0, _a1, _a2, _a3) + } else { + r0 = ret.Get(0).(page.Document) + } + + if rf, ok := ret.Get(1).(func(context.Context, *page.Lpa, string, []byte) error); ok { + r1 = rf(_a0, _a1, _a2, _a3) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: _a0, _a1 +func (_m *mockDocumentStore) Delete(_a0 context.Context, _a1 page.Document) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, page.Document) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteInfectedDocuments provides a mock function with given fields: _a0, _a1 +func (_m *mockDocumentStore) DeleteInfectedDocuments(_a0 context.Context, _a1 page.Documents) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, page.Documents) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetAll provides a mock function with given fields: _a0 +func (_m *mockDocumentStore) GetAll(_a0 context.Context) (page.Documents, error) { + ret := _m.Called(_a0) + + var r0 page.Documents + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (page.Documents, error)); ok { + return rf(_a0) + } + if rf, ok := ret.Get(0).(func(context.Context) page.Documents); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(page.Documents) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Put provides a mock function with given fields: _a0, _a1 +func (_m *mockDocumentStore) Put(_a0 context.Context, _a1 page.Document) error { + ret := _m.Called(_a0, _a1) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, page.Document) error); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTnewMockDocumentStore interface { + mock.TestingT + Cleanup(func()) +} + +// newMockDocumentStore creates a new instance of mockDocumentStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func newMockDocumentStore(t mockConstructorTestingTnewMockDocumentStore) *mockDocumentStore { + mock := &mockDocumentStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/page/donor/payment_confirmation.go b/internal/page/donor/payment_confirmation.go index f5bfffd857..f51e1c31e8 100644 --- a/internal/page/donor/payment_confirmation.go +++ b/internal/page/donor/payment_confirmation.go @@ -22,7 +22,7 @@ type paymentConfirmationData struct { FeeType page.FeeType } -func PaymentConfirmation(logger Logger, tmpl template.Template, payClient PayClient, donorStore DonorStore, sessionStore sessions.Store, evidenceS3Client S3Client, now func() time.Time) Handler { +func PaymentConfirmation(logger Logger, tmpl template.Template, payClient PayClient, donorStore DonorStore, sessionStore sessions.Store, evidenceS3Client S3Client, now func() time.Time, documentStore DocumentStore) Handler { return func(appData page.AppData, w http.ResponseWriter, r *http.Request, lpa *page.Lpa) error { paymentSession, err := sesh.Payment(sessionStore, r) if err != nil { @@ -58,9 +58,14 @@ func PaymentConfirmation(logger Logger, tmpl template.Template, payClient PayCli } else { lpa.Tasks.PayForLpa = actor.PaymentTaskPending - for i, evidence := range lpa.Evidence.Documents { - if evidence.Sent.IsZero() { - err := evidenceS3Client.PutObjectTagging(r.Context(), evidence.Key, []types.Tag{ + documents, err := documentStore.GetAll(r.Context()) + if err != nil { + return err + } + + for _, document := range documents { + if document.Sent.IsZero() { + err := evidenceS3Client.PutObjectTagging(r.Context(), document.Key, []types.Tag{ {Key: aws.String("replicate"), Value: aws.String("true")}, }) @@ -69,7 +74,10 @@ func PaymentConfirmation(logger Logger, tmpl template.Template, payClient PayCli return err } - lpa.Evidence.Documents[i].Sent = now() + document.Sent = now() + if err := documentStore.Put(r.Context(), document); err != nil { + return err + } } } } diff --git a/internal/page/donor/payment_confirmation_test.go b/internal/page/donor/payment_confirmation_test.go index 68d9e4a756..9d2c0ea1ff 100644 --- a/internal/page/donor/payment_confirmation_test.go +++ b/internal/page/donor/payment_confirmation_test.go @@ -55,7 +55,7 @@ func TestGetPaymentConfirmationFullFee(t *testing.T) { }). Return(nil) - err := PaymentConfirmation(newMockLogger(t), template.Execute, payClient, donorStore, sessionStore, nil, nil)(testAppData, w, r, &page.Lpa{ + err := PaymentConfirmation(newMockLogger(t), template.Execute, payClient, donorStore, sessionStore, nil, nil, nil)(testAppData, w, r, &page.Lpa{ FeeType: page.FullFee, CertificateProvider: actor.CertificateProvider{ Email: "certificateprovider@example.com", @@ -104,13 +104,20 @@ func TestGetPaymentConfirmationHalfFee(t *testing.T) { Tasks: page.Tasks{ PayForLpa: actor.PaymentTaskPending, }, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "evidence-key", Sent: now}, - {Key: "another-evidence-key", Sent: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC)}, - }}, }). Return(nil) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "evidence-key"}, + {Key: "another-evidence-key", Sent: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC)}, + }, nil) + documentStore. + On("Put", r.Context(), page.Document{Key: "evidence-key", Sent: now}). + Return(nil) + s3Client := newMockS3Client(t) s3Client. On("PutObjectTagging", r.Context(), "evidence-key", []types.Tag{ @@ -118,15 +125,11 @@ func TestGetPaymentConfirmationHalfFee(t *testing.T) { }). Return(nil) - err := PaymentConfirmation(newMockLogger(t), template.Execute, payClient, donorStore, sessionStore, s3Client, func() time.Time { return now })(testAppData, w, r, &page.Lpa{ + err := PaymentConfirmation(newMockLogger(t), template.Execute, payClient, donorStore, sessionStore, s3Client, func() time.Time { return now }, documentStore)(testAppData, w, r, &page.Lpa{ FeeType: page.HalfFee, CertificateProvider: actor.CertificateProvider{ Email: "certificateprovider@example.com", }, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "evidence-key"}, - {Key: "another-evidence-key", Sent: time.Date(2000, 1, 2, 0, 0, 0, 0, time.UTC)}, - }}, }) resp := w.Result() @@ -145,7 +148,7 @@ func TestGetPaymentConfirmationWhenErrorGettingSession(t *testing.T) { On("Get", r, "pay"). Return(&sessions.Session{}, expectedError) - err := PaymentConfirmation(nil, template.Execute, newMockPayClient(t), nil, sessionStore, nil, nil)(testAppData, w, r, &page.Lpa{}) + err := PaymentConfirmation(nil, template.Execute, newMockPayClient(t), nil, sessionStore, nil, nil, nil)(testAppData, w, r, &page.Lpa{}) resp := w.Result() assert.Equal(t, expectedError, err) @@ -171,7 +174,7 @@ func TestGetPaymentConfirmationWhenErrorGettingPayment(t *testing.T) { template := newMockTemplate(t) - err := PaymentConfirmation(logger, template.Execute, payClient, nil, sessionStore, nil, nil)(testAppData, w, r, &page.Lpa{}) + err := PaymentConfirmation(logger, template.Execute, payClient, nil, sessionStore, nil, nil, nil)(testAppData, w, r, &page.Lpa{}) resp := w.Result() assert.Equal(t, expectedError, err) @@ -205,7 +208,7 @@ func TestGetPaymentConfirmationWhenErrorExpiringSession(t *testing.T) { On("Execute", w, mock.Anything). Return(nil) - err := PaymentConfirmation(logger, template.Execute, payClient, donorStore, sessionStore, nil, nil)(testAppData, w, r, &page.Lpa{CertificateProvider: actor.CertificateProvider{ + err := PaymentConfirmation(logger, template.Execute, payClient, donorStore, sessionStore, nil, nil, nil)(testAppData, w, r, &page.Lpa{CertificateProvider: actor.CertificateProvider{ Email: "certificateprovider@example.com", }}) resp := w.Result() @@ -214,7 +217,7 @@ func TestGetPaymentConfirmationWhenErrorExpiringSession(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } -func TestGetPaymentConfirmationHalfFeeWhenS3ClientError(t *testing.T) { +func TestGetPaymentConfirmationHalfFeeWhenDocumentStoreGetAllError(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "/payment-confirmation", nil) @@ -227,26 +230,140 @@ func TestGetPaymentConfirmationHalfFeeWhenS3ClientError(t *testing.T) { now := time.Now() + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, expectedError) + + err := PaymentConfirmation(nil, nil, payClient, nil, sessionStore, nil, func() time.Time { return now }, documentStore)(testAppData, w, r, &page.Lpa{ + FeeType: page.HalfFee, + CertificateProvider: actor.CertificateProvider{ + Email: "certificateprovider@example.com", + }, + }) + resp := w.Result() + + assert.Equal(t, expectedError, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestGetPaymentConfirmationHalfFeeWhenS3ClientPutTaggingObjectError(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/payment-confirmation", nil) + + payClient := newMockPayClient(t). + withASuccessfulPayment("abc123", "123456789012", 4100) + + sessionStore := newMockSessionStore(t). + withPaySession(r). + withExpiredPaySession(r, w) + + now := time.Now() + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{{}}, nil) + s3Client := newMockS3Client(t) s3Client. - On("PutObjectTagging", r.Context(), "evidence-key", []types.Tag{ - {Key: aws.String("replicate"), Value: aws.String("true")}, - }). + On("PutObjectTagging", mock.Anything, mock.Anything, mock.Anything). Return(expectedError) logger := newMockLogger(t) logger. - On("Print", fmt.Sprintf("error tagging evidence: %s", expectedError.Error())). + On("Print", fmt.Sprintf("error tagging evidence: %s", expectedError)) + + err := PaymentConfirmation(logger, nil, payClient, nil, sessionStore, s3Client, func() time.Time { return now }, documentStore)(testAppData, w, r, &page.Lpa{ + FeeType: page.HalfFee, + CertificateProvider: actor.CertificateProvider{ + Email: "certificateprovider@example.com", + }, + }) + resp := w.Result() + + assert.Equal(t, expectedError, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestGetPaymentConfirmationHalfFeeWhenDocumentStorePutError(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/payment-confirmation", nil) + + payClient := newMockPayClient(t). + withASuccessfulPayment("abc123", "123456789012", 4100) + + sessionStore := newMockSessionStore(t). + withPaySession(r). + withExpiredPaySession(r, w) + + now := time.Now() + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{{}}, nil) + documentStore. + On("Put", r.Context(), mock.Anything). + Return(expectedError) + + s3Client := newMockS3Client(t) + s3Client. + On("PutObjectTagging", mock.Anything, mock.Anything, mock.Anything). Return(nil) - err := PaymentConfirmation(logger, nil, payClient, nil, sessionStore, s3Client, func() time.Time { return now })(testAppData, w, r, &page.Lpa{ + err := PaymentConfirmation(nil, nil, payClient, nil, sessionStore, s3Client, func() time.Time { return now }, documentStore)(testAppData, w, r, &page.Lpa{ + FeeType: page.HalfFee, + CertificateProvider: actor.CertificateProvider{ + Email: "certificateprovider@example.com", + }, + }) + resp := w.Result() + + assert.Equal(t, expectedError, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestGetPaymentConfirmationHalfFeeWhenDonorStorePutError(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/payment-confirmation", nil) + + payClient := newMockPayClient(t). + withASuccessfulPayment("abc123", "123456789012", 4100) + + sessionStore := newMockSessionStore(t). + withPaySession(r). + withExpiredPaySession(r, w) + + now := time.Now() + + donorStore := newMockDonorStore(t) + donorStore. + On("Put", r.Context(), mock.Anything). + Return(expectedError) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{{}}, nil) + documentStore. + On("Put", r.Context(), mock.Anything). + Return(nil) + + s3Client := newMockS3Client(t) + s3Client. + On("PutObjectTagging", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + + logger := newMockLogger(t) + logger. + On("Print", fmt.Sprintf("unable to update lpa in donorStore: %s", expectedError)) + + err := PaymentConfirmation(logger, nil, payClient, donorStore, sessionStore, s3Client, func() time.Time { return now }, documentStore)(testAppData, w, r, &page.Lpa{ FeeType: page.HalfFee, CertificateProvider: actor.CertificateProvider{ Email: "certificateprovider@example.com", }, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "evidence-key"}, - }}, }) resp := w.Result() diff --git a/internal/page/donor/register.go b/internal/page/donor/register.go index 8cf0a7e2ab..b9dec4b5d7 100644 --- a/internal/page/donor/register.go +++ b/internal/page/donor/register.go @@ -141,6 +141,15 @@ type Localizer interface { Concat([]string, string) string } +//go:generate mockery --testonly --inpackage --name DocumentStore --structname mockDocumentStore +type DocumentStore interface { + GetAll(context.Context) (page.Documents, error) + Put(context.Context, page.Document) error + Delete(context.Context, page.Document) error + DeleteInfectedDocuments(context.Context, page.Documents) error + Create(context.Context, *page.Lpa, string, []byte) (page.Document, error) +} + func Register( rootMux *http.ServeMux, logger Logger, @@ -160,6 +169,7 @@ func Register( notifyClient NotifyClient, evidenceReceivedStore EvidenceReceivedStore, evidenceS3Client S3Client, + documentStore DocumentStore, ) { payer := &payHelper{ logger: logger, @@ -170,6 +180,7 @@ func Register( randomString: random.String, evidenceS3Client: evidenceS3Client, now: time.Now, + documentStore: documentStore, } handleRoot := makeHandle(rootMux, sessionStore, None, errorHandler) @@ -310,7 +321,7 @@ func Register( handleWithLpa(page.Paths.HowWouldYouLikeToSendEvidence, CanGoBack, HowWouldYouLikeToSendEvidence(tmpls.Get("how_would_you_like_to_send_evidence.gohtml"))) handleWithLpa(page.Paths.UploadEvidence, CanGoBack, - UploadEvidence(tmpls.Get("upload_evidence.gohtml"), payer, donorStore, random.UuidString, evidenceS3Client)) + UploadEvidence(tmpls.Get("upload_evidence.gohtml"), payer, documentStore)) handleWithLpa(page.Paths.EvidenceSuccessfullyUploaded, None, Guidance(tmpls.Get("evidence_successfully_uploaded.gohtml"))) handleWithLpa(page.Paths.HowToEmailOrPostEvidence, CanGoBack, @@ -318,7 +329,7 @@ func Register( handleWithLpa(page.Paths.FeeDenied, None, FeeDenied(tmpls.Get("fee_denied.gohtml"), payer)) handleWithLpa(page.Paths.PaymentConfirmation, None, - PaymentConfirmation(logger, tmpls.Get("payment_confirmation.gohtml"), payClient, donorStore, sessionStore, evidenceS3Client, time.Now)) + PaymentConfirmation(logger, tmpls.Get("payment_confirmation.gohtml"), payClient, donorStore, sessionStore, evidenceS3Client, time.Now, documentStore)) handleWithLpa(page.Paths.HowToConfirmYourIdentityAndSign, None, Guidance(tmpls.Get("how_to_confirm_your_identity_and_sign.gohtml"))) @@ -338,7 +349,7 @@ func Register( handleWithLpa(page.Paths.SignTheLpaOnBehalf, CanGoBack, SignYourLpa(tmpls.Get("sign_the_lpa_on_behalf.gohtml"), donorStore)) handleWithLpa(page.Paths.WitnessingYourSignature, None, - WitnessingYourSignature(tmpls.Get("witnessing_your_signature.gohtml"), witnessCodeSender)) + WitnessingYourSignature(tmpls.Get("witnessing_your_signature.gohtml"), witnessCodeSender, donorStore)) handleWithLpa(page.Paths.WitnessingAsIndependentWitness, None, WitnessingAsIndependentWitness(tmpls.Get("witnessing_as_independent_witness.gohtml"), donorStore, time.Now)) handleWithLpa(page.Paths.ResendIndependentWitnessCode, CanGoBack, @@ -356,6 +367,9 @@ func Register( handleWithLpa(page.Paths.Progress, CanGoBack, LpaProgress(tmpls.Get("lpa_progress.gohtml"), certificateProviderStore, attorneyStore)) + + handleWithLpa(page.Paths.UploadEvidenceSSE, None, + UploadEvidenceSSE(documentStore, 3*time.Minute, 2*time.Second)) } type handleOpt byte @@ -460,22 +474,29 @@ type payHelper struct { randomString func(int) string evidenceS3Client S3Client now func() time.Time + documentStore DocumentStore } func (p *payHelper) Pay(appData page.AppData, w http.ResponseWriter, r *http.Request, lpa *page.Lpa) error { if lpa.FeeType.IsNoFee() || lpa.FeeType.IsHardshipFee() || lpa.Tasks.PayForLpa.IsMoreEvidenceRequired() { - for i, evidence := range lpa.Evidence.Documents { - if evidence.Sent.IsZero() { - err := p.evidenceS3Client.PutObjectTagging(r.Context(), evidence.Key, []types.Tag{ - {Key: aws.String("replicate"), Value: aws.String("true")}, - }) + documents, err := p.documentStore.GetAll(r.Context()) + if err != nil { + return err + } - if err != nil { + for _, document := range documents { + if document.Sent.IsZero() { + if err := p.evidenceS3Client.PutObjectTagging(r.Context(), document.Key, []types.Tag{ + {Key: aws.String("replicate"), Value: aws.String("true")}, + }); err != nil { p.logger.Print(fmt.Sprintf("error tagging evidence: %s", err.Error())) return err } - lpa.Evidence.Documents[i].Sent = p.now() + document.Sent = p.now() + if err := p.documentStore.Put(r.Context(), document); err != nil { + return err + } } } diff --git a/internal/page/donor/register_test.go b/internal/page/donor/register_test.go index 0809d7b52f..fa1f1500db 100644 --- a/internal/page/donor/register_test.go +++ b/internal/page/donor/register_test.go @@ -2,6 +2,7 @@ package donor import ( "context" + "fmt" "log" "net/http" "net/http/httptest" @@ -27,7 +28,7 @@ import ( func TestRegister(t *testing.T) { mux := http.NewServeMux() - Register(mux, &log.Logger{}, template.Templates{}, nil, nil, &onelogin.Client{}, &place.Client{}, "http://public.url", &pay.Client{}, nil, &mockWitnessCodeSender{}, nil, nil, nil, nil, ¬ify.Client{}, nil, &s3.Client{}) + Register(mux, &log.Logger{}, template.Templates{}, nil, nil, &onelogin.Client{}, &place.Client{}, "http://public.url", &pay.Client{}, nil, &mockWitnessCodeSender{}, nil, nil, nil, nil, ¬ify.Client{}, nil, &s3.Client{}, nil) assert.Implements(t, (*http.Handler)(nil), mux) } @@ -433,26 +434,35 @@ func TestPayHelperPayWhenPaymentNotRequired(t *testing.T) { for _, feeType := range testCases { t.Run(feeType.String(), func(t *testing.T) { w := httptest.NewRecorder() - r, _ := http.NewRequest(http.MethodPost, "/about-payment", nil) + r, _ := http.NewRequest(http.MethodPost, "/", nil) now := time.Now() + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Sent: now}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, nil) + documentStore. + On("Put", r.Context(), page.Document{ + Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Sent: now, + }). + Return(nil) + donorStore := newMockDonorStore(t) donorStore. On("Put", r.Context(), &page.Lpa{ ID: "lpa-id", FeeType: feeType, Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "evidence-1", Sent: date.New("2000", "01", "01").Time()}, - {Key: "evidence-2", Sent: now}, - }}, }). Return(nil) s3Client := newMockS3Client(t) s3Client. - On("PutObjectTagging", r.Context(), "evidence-2", []types.Tag{ + On("PutObjectTagging", r.Context(), "lpa-uid/evidence/another-uid", []types.Tag{ {Key: aws.String("replicate"), Value: aws.String("true")}, }). Return(nil) @@ -461,13 +471,10 @@ func TestPayHelperPayWhenPaymentNotRequired(t *testing.T) { donorStore: donorStore, now: func() time.Time { return now }, evidenceS3Client: s3Client, + documentStore: documentStore, }).Pay(testAppData, w, r, &page.Lpa{ ID: "lpa-id", FeeType: feeType, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "evidence-1", Sent: date.New("2000", "01", "01").Time()}, - {Key: "evidence-2"}, - }}, }) resp := w.Result() @@ -480,26 +487,35 @@ func TestPayHelperPayWhenPaymentNotRequired(t *testing.T) { func TestPayHelperPayWhenMoreEvidenceProvided(t *testing.T) { w := httptest.NewRecorder() - r, _ := http.NewRequest(http.MethodPost, "/about-payment", nil) + r, _ := http.NewRequest(http.MethodPost, "/", nil) now := time.Now() + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Sent: now}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, nil) + documentStore. + On("Put", r.Context(), page.Document{ + Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Sent: now, + }). + Return(nil) + donorStore := newMockDonorStore(t) donorStore. On("Put", r.Context(), &page.Lpa{ ID: "lpa-id", - FeeType: page.HalfFee, + FeeType: page.FullFee, Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "evidence-1", Sent: date.New("2000", "01", "01").Time()}, - {Key: "evidence-2", Sent: now}, - }}, }). Return(nil) s3Client := newMockS3Client(t) s3Client. - On("PutObjectTagging", r.Context(), "evidence-2", []types.Tag{ + On("PutObjectTagging", r.Context(), "lpa-uid/evidence/another-uid", []types.Tag{ {Key: aws.String("replicate"), Value: aws.String("true")}, }). Return(nil) @@ -508,14 +524,11 @@ func TestPayHelperPayWhenMoreEvidenceProvided(t *testing.T) { donorStore: donorStore, now: func() time.Time { return now }, evidenceS3Client: s3Client, + documentStore: documentStore, }).Pay(testAppData, w, r, &page.Lpa{ ID: "lpa-id", - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "evidence-1", Sent: date.New("2000", "01", "01").Time()}, - {Key: "evidence-2"}, - }}, - Tasks: page.Tasks{PayForLpa: actor.PaymentTaskMoreEvidenceRequired}, + FeeType: page.FullFee, + Tasks: page.Tasks{PayForLpa: actor.PaymentTaskMoreEvidenceRequired}, }) resp := w.Result() @@ -524,33 +537,148 @@ func TestPayHelperPayWhenMoreEvidenceProvided(t *testing.T) { assert.Equal(t, page.Paths.EvidenceSuccessfullyUploaded.Format("lpa-id"), resp.Header.Get("Location")) } -func TestPayHelperPayNoPaymentRequiredWhenS3ClientError(t *testing.T) { +func TestPayHelperPayWhenPaymentNotRequiredWhenDocumentStoreGetAllError(t *testing.T) { w := httptest.NewRecorder() - r, _ := http.NewRequest(http.MethodPost, "/about-payment", nil) + r, _ := http.NewRequest(http.MethodPost, "/", nil) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, expectedError) + + err := (&payHelper{documentStore: documentStore}).Pay(testAppData, w, r, &page.Lpa{ + ID: "lpa-id", + FeeType: page.NoFee, + }) + resp := w.Result() + + assert.Equal(t, expectedError, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPayHelperPayWhenPaymentNotRequiredWhenS3ClientPutObjectTaggingError(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", nil) + + now := time.Now() + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Sent: now}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, nil) s3Client := newMockS3Client(t) s3Client. - On("PutObjectTagging", r.Context(), "evidence-2", []types.Tag{ + On("PutObjectTagging", r.Context(), "lpa-uid/evidence/another-uid", []types.Tag{ {Key: aws.String("replicate"), Value: aws.String("true")}, }). Return(expectedError) logger := newMockLogger(t) logger. - On("Print", "error tagging evidence: err"). + On("Print", fmt.Sprintf("error tagging evidence: %s", expectedError)) + + err := (&payHelper{ + logger: logger, + now: func() time.Time { return now }, + evidenceS3Client: s3Client, + documentStore: documentStore, + }).Pay(testAppData, w, r, &page.Lpa{ + ID: "lpa-id", + FeeType: page.NoFee, + }) + resp := w.Result() + + assert.Equal(t, expectedError, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPayHelperPayWhenPaymentNotRequiredWhenDocumentStorePutError(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", nil) + + now := time.Now() + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Sent: now}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, nil) + documentStore. + On("Put", r.Context(), page.Document{ + Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Sent: now, + }). + Return(expectedError) + + s3Client := newMockS3Client(t) + s3Client. + On("PutObjectTagging", r.Context(), "lpa-uid/evidence/another-uid", []types.Tag{ + {Key: aws.String("replicate"), Value: aws.String("true")}, + }). Return(nil) err := (&payHelper{ + now: func() time.Time { return now }, evidenceS3Client: s3Client, - logger: logger, + documentStore: documentStore, }).Pay(testAppData, w, r, &page.Lpa{ ID: "lpa-id", - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "evidence-1", Sent: date.New("2000", "01", "01").Time()}, - {Key: "evidence-2"}, - }}, - Tasks: page.Tasks{PayForLpa: actor.PaymentTaskMoreEvidenceRequired}, + FeeType: page.NoFee, + }) + resp := w.Result() + + assert.Equal(t, expectedError, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestPayHelperPayWhenPaymentNotRequiredWhenDonorStorePutError(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", nil) + + now := time.Now() + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Sent: now}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, nil) + documentStore. + On("Put", r.Context(), page.Document{ + Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Sent: now, + }). + Return(nil) + + donorStore := newMockDonorStore(t) + donorStore. + On("Put", r.Context(), &page.Lpa{ + ID: "lpa-id", + FeeType: page.NoFee, + Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}, + }). + Return(expectedError) + + s3Client := newMockS3Client(t) + s3Client. + On("PutObjectTagging", r.Context(), "lpa-uid/evidence/another-uid", []types.Tag{ + {Key: aws.String("replicate"), Value: aws.String("true")}, + }). + Return(nil) + + err := (&payHelper{ + donorStore: donorStore, + now: func() time.Time { return now }, + evidenceS3Client: s3Client, + documentStore: documentStore, + }).Pay(testAppData, w, r, &page.Lpa{ + ID: "lpa-id", + FeeType: page.NoFee, }) resp := w.Result() @@ -697,31 +825,6 @@ func TestPayHelperPayWhenFeeDeniedAndPutStoreError(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) } -func TestPayHelperPayWhenPaymentNotRequiredAndDonorStoreErrors(t *testing.T) { - testCases := []page.FeeType{ - page.NoFee, - page.HardshipFee, - } - - for _, feeType := range testCases { - t.Run(feeType.String(), func(t *testing.T) { - w := httptest.NewRecorder() - r, _ := http.NewRequest(http.MethodPost, "/about-payment", nil) - - donorStore := newMockDonorStore(t) - donorStore. - On("Put", r.Context(), mock.Anything). - Return(expectedError) - - err := (&payHelper{ - donorStore: donorStore, - }).Pay(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: feeType}) - - assert.Equal(t, expectedError, err) - }) - } -} - func TestPayHelperPayWhenCreatePaymentErrors(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodPost, "/about-payment", nil) diff --git a/internal/page/donor/upload_evidence.go b/internal/page/donor/upload_evidence.go index 041306d262..d48c16d053 100644 --- a/internal/page/donor/upload_evidence.go +++ b/internal/page/donor/upload_evidence.go @@ -50,19 +50,28 @@ type uploadEvidenceData struct { Errors validation.List NumberOfAllowedFiles int FeeType page.FeeType - Evidence page.Evidence + Documents page.Documents MimeTypes []string Deleted string - UploadedCount int + StartScan string } -func UploadEvidence(tmpl template.Template, payer Payer, donorStore DonorStore, randomUUID func() string, evidenceS3Client S3Client) Handler { +func UploadEvidence(tmpl template.Template, payer Payer, documentStore DocumentStore) Handler { return func(appData page.AppData, w http.ResponseWriter, r *http.Request, lpa *page.Lpa) error { + if lpa.Tasks.PayForLpa.IsPending() { + return appData.Redirect(w, r, lpa, page.Paths.TaskList.Format(lpa.ID)) + } + + documents, err := documentStore.GetAll(r.Context()) + if err != nil { + return err + } + data := &uploadEvidenceData{ App: appData, NumberOfAllowedFiles: numberOfAllowedFiles, FeeType: lpa.FeeType, - Evidence: lpa.Evidence, + Documents: documents, MimeTypes: acceptedMimeTypes(), } @@ -74,46 +83,74 @@ func UploadEvidence(tmpl template.Template, payer Payer, donorStore DonorStore, if data.Errors.None() { switch form.Action { case "upload": - for _, file := range form.Files { - uuid := randomUUID() - key := lpa.UID + "/evidence/" + uuid + var uploadedDocuments []page.Document - err := evidenceS3Client.PutObject(r.Context(), key, file.Data) + for _, file := range form.Files { + document, err := documentStore.Create(r.Context(), lpa, file.Filename, file.Data) if err != nil { return err } - lpa.Evidence.Documents = append(lpa.Evidence.Documents, page.Document{Key: key, Filename: file.Filename}) - data.UploadedCount += 1 + uploadedDocuments = append(uploadedDocuments, document) } - if err := donorStore.Put(r.Context(), lpa); err != nil { - return err - } + data.Documents = uploadedDocuments + data.StartScan = "1" + + case "scanResults": + infectedFilenames := documents.InfectedFilenames() - data.Evidence = lpa.Evidence + if len(infectedFilenames) > 0 { + if err := documentStore.DeleteInfectedDocuments(r.Context(), documents); err != nil { + return err + } + + refreshedDocuments, err := documentStore.GetAll(r.Context()) + if err != nil { + return err + } + + data.Errors = validation.With("upload", validation.FilesInfectedError{Label: "upload", Filenames: infectedFilenames}) + data.Documents = refreshedDocuments + + return tmpl(w, data) + } case "pay": return payer.Pay(appData, w, r, lpa) case "delete": - evidence := lpa.Evidence.Get(form.DeleteKey) - if evidence.Key != "" { - if err := evidenceS3Client.DeleteObject(r.Context(), evidence.Key); err != nil { + document := documents.Get(form.DeleteKey) + if document.Key != "" { + data.Deleted = document.Filename + + if err := documentStore.Delete(r.Context(), document); err != nil { return err } + documents.Delete(document.Key) - data.Deleted = evidence.Filename + data.Documents = documents + } - lpa.Evidence.Delete(evidence.Key) + case "closeConnection", "cancelUpload": + for _, d := range documents { + if d.Key != "" && !d.Scanned { + if err := documentStore.Delete(r.Context(), d); err != nil { + return err + } - if err := donorStore.Put(r.Context(), lpa); err != nil { - return err + documents.Delete(d.Key) } + } - data.Evidence = lpa.Evidence + data.Documents = documents + + if form.Action == "closeConnection" { + data.Errors = validation.With("upload", validation.CustomError{Label: "errorGenericUploadProblem"}) } + return tmpl(w, data) + default: return errors.New("unexpected action") } diff --git a/internal/page/donor/upload_evidence_sse.go b/internal/page/donor/upload_evidence_sse.go new file mode 100644 index 0000000000..fda0f4a55a --- /dev/null +++ b/internal/page/donor/upload_evidence_sse.go @@ -0,0 +1,51 @@ +package donor + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/ministryofjustice/opg-modernising-lpa/internal/page" +) + +func UploadEvidenceSSE(documentStore DocumentStore, ttl time.Duration, flushFrequency time.Duration) Handler { + return func(appData page.AppData, w http.ResponseWriter, r *http.Request, lpa *page.Lpa) error { + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Content-Type", "text/event-stream") + + documents, err := documentStore.GetAll(r.Context()) + if err != nil { + printMessage("data: {\"closeConnection\": \"1\"}\n\n", w) + return nil + } + + alreadyScannedCount := len(documents.Scanned()) + batchToBeScannedCount := len(documents.NotScanned()) + + for start := time.Now(); time.Since(start) < ttl; { + documents, err := documentStore.GetAll(r.Context()) + if err != nil { + printMessage("data: {\"closeConnection\": \"1\"}\n\n", w) + return nil + } + + scannedCount := len(documents.Scanned()) - alreadyScannedCount + + printMessage(fmt.Sprintf("data: {\"finishedScanning\": %v, \"scannedCount\": %d}\n\n", scannedCount == batchToBeScannedCount, scannedCount), w) + + time.Sleep(flushFrequency) + } + + printMessage("data: {\"closeConnection\": \"1\"}\n\n", w) + + return nil + } +} + +func printMessage(message string, w io.Writer) { + fmt.Fprint(w, "event: message\n") + fmt.Fprint(w, message) + w.(http.Flusher).Flush() +} diff --git a/internal/page/donor/upload_evidence_sse_test.go b/internal/page/donor/upload_evidence_sse_test.go new file mode 100644 index 0000000000..72a6c4f5f8 --- /dev/null +++ b/internal/page/donor/upload_evidence_sse_test.go @@ -0,0 +1,99 @@ +package donor + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ministryofjustice/opg-modernising-lpa/internal/page" + "github.com/stretchr/testify/assert" +) + +func TestUploadEvidenceSSE(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/", nil) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Scanned: false}, + {Scanned: true}, + }, nil).Once() + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Scanned: false}, + {Scanned: true}, + }, nil).Once() + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Scanned: true}, + {Scanned: true}, + }, nil).Once() + + err := UploadEvidenceSSE(documentStore, 4*time.Millisecond, 2*time.Millisecond)(testAppData, w, r, &page.Lpa{}) + resp := w.Result() + + bodyBytes, _ := io.ReadAll(resp.Body) + + assert.Nil(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "event: message\ndata: {\"finishedScanning\": false, \"scannedCount\": 0}\n\nevent: message\ndata: {\"finishedScanning\": true, \"scannedCount\": 1}\n\nevent: message\ndata: {\"closeConnection\": \"1\"}\n\n", string(bodyBytes)) +} + +func TestUploadEvidenceSSEOnDonorStoreError(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/", nil) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Scanned: false}, + {Scanned: true}, + }, expectedError) + + err := UploadEvidenceSSE(documentStore, 4*time.Millisecond, 2*time.Millisecond)(testAppData, w, r, &page.Lpa{}) + resp := w.Result() + + bodyBytes, _ := io.ReadAll(resp.Body) + + assert.Nil(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "event: message\ndata: {\"closeConnection\": \"1\"}\n\n", string(bodyBytes)) +} + +func TestUploadEvidenceSSEOnDonorStoreErrorWhenRefreshingDocuments(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/", nil) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Scanned: false}, + {Scanned: true}, + }, nil). + Once() + + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Scanned: false}, + {Scanned: true}, + }, expectedError). + Once() + + err := UploadEvidenceSSE(documentStore, 4*time.Millisecond, 2*time.Millisecond)(testAppData, w, r, &page.Lpa{}) + resp := w.Result() + + bodyBytes, _ := io.ReadAll(resp.Body) + + assert.Nil(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "event: message\ndata: {\"closeConnection\": \"1\"}\n\n", string(bodyBytes)) +} diff --git a/internal/page/donor/upload_evidence_test.go b/internal/page/donor/upload_evidence_test.go index 35916019b7..3376f60ed4 100644 --- a/internal/page/donor/upload_evidence_test.go +++ b/internal/page/donor/upload_evidence_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/ministryofjustice/opg-modernising-lpa/internal/actor" "github.com/ministryofjustice/opg-modernising-lpa/internal/page" "github.com/ministryofjustice/opg-modernising-lpa/internal/validation" "github.com/stretchr/testify/assert" @@ -21,6 +22,11 @@ func TestGetUploadEvidence(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "/", nil) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{{Scanned: false}}, nil) + template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ @@ -28,26 +34,44 @@ func TestGetUploadEvidence(t *testing.T) { NumberOfAllowedFiles: 5, FeeType: page.FullFee, MimeTypes: acceptedMimeTypes(), + Documents: page.Documents{{Scanned: false}}, }). Return(nil) - err := UploadEvidence(template.Execute, nil, nil, nil, nil)(testAppData, w, r, &page.Lpa{FeeType: page.FullFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{FeeType: page.FullFee}) resp := w.Result() assert.Nil(t, err) assert.Equal(t, http.StatusOK, resp.StatusCode) } +func TestGetUploadEvidenceWhenTaskPending(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/", nil) + + err := UploadEvidence(nil, nil, nil)(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: page.FullFee, Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}}) + resp := w.Result() + + assert.Nil(t, err) + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Equal(t, page.Paths.TaskList.Format("lpa-id"), resp.Header.Get("Location")) +} + func TestGetUploadEvidenceWhenTemplateErrors(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "/", nil) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{{Scanned: false}}, nil) + template := newMockTemplate(t) template. On("Execute", w, mock.Anything). Return(expectedError) - err := UploadEvidence(template.Execute, nil, nil, nil, nil)(testAppData, w, r, &page.Lpa{}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{}) assert.Equal(t, expectedError, err) } @@ -85,40 +109,54 @@ func TestPostUploadEvidenceWithUploadActionAcceptedFileTypes(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("PutObject", r.Context(), "lpa-uid/evidence/a-uid", mock.Anything). - Return(nil) - - evidence := page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: filename}, - }} - - updatedLpa := &page.Lpa{UID: "lpa-uid", Evidence: evidence, FeeType: page.HalfFee} - - donorStore := newMockDonorStore(t) - donorStore. - On("Put", r.Context(), updatedLpa). - Return(nil) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + documentStore. + On("Create", r.Context(), &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}, filename, mock.Anything). + Return(page.Document{ + PK: "LPA#lpa-id", + SK: "#DOCUMENT#lpa-uid/evidence/a-uid", + Filename: filename, + Key: "lpa-uid/evidence/a-uid", + }, nil) template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ - App: testAppData, - Evidence: evidence, + App: testAppData, + Documents: page.Documents{{ + PK: "LPA#lpa-id", + SK: "#DOCUMENT#lpa-uid/evidence/a-uid", + Filename: filename, + Key: "lpa-uid/evidence/a-uid"}, + }, NumberOfAllowedFiles: 5, MimeTypes: acceptedMimeTypes(), FeeType: page.HalfFee, - UploadedCount: 1, + StartScan: "1", }). Return(nil) - err := UploadEvidence(template.Execute, nil, donorStore, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) assert.Nil(t, err) }) } } +func TestPostUploadEvidenceWhenTaskPending(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", nil) + + err := UploadEvidence(nil, nil, nil)(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: page.FullFee, Tasks: page.Tasks{PayForLpa: actor.PaymentTaskPending}}) + resp := w.Result() + + assert.Nil(t, err) + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Equal(t, page.Paths.TaskList.Format("lpa-id"), resp.Header.Get("Location")) +} + func TestPostUploadEvidenceWithUploadActionMultipleFiles(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -139,38 +177,55 @@ func TestPostUploadEvidenceWithUploadActionMultipleFiles(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("PutObject", r.Context(), "lpa-uid/evidence/a-uid", mock.Anything). - Return(nil) - s3Client. - On("PutObject", r.Context(), "lpa-uid/evidence/a-uid", mock.Anything). - Return(nil) - - evidence := page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.png"}, - }} - updatedLpa := &page.Lpa{UID: "lpa-uid", Evidence: evidence, FeeType: page.HalfFee} - - donorStore := newMockDonorStore(t) - donorStore. - On("Put", r.Context(), updatedLpa). - Return(nil) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + documentStore. + On("Create", r.Context(), &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}, "dummy.pdf", mock.Anything). + Return(page.Document{ + PK: "LPA#lpa-id", + SK: "#DOCUMENT#lpa-uid/evidence/a-uid", + Filename: "dummy.pdf", + Key: "lpa-uid/evidence/a-uid", + }, nil). + Once() + documentStore. + On("Create", r.Context(), &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}, "dummy.png", mock.Anything). + Return(page.Document{ + PK: "LPA#lpa-id", + SK: "#DOCUMENT#lpa-uid/evidence/a-uid", + Filename: "dummy.png", + Key: "lpa-uid/evidence/a-uid", + }, nil). + Once() template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ - App: testAppData, - Evidence: evidence, + App: testAppData, + Documents: page.Documents{ + { + PK: "LPA#lpa-id", + SK: "#DOCUMENT#lpa-uid/evidence/a-uid", + Filename: "dummy.pdf", + Key: "lpa-uid/evidence/a-uid", + }, + { + PK: "LPA#lpa-id", + SK: "#DOCUMENT#lpa-uid/evidence/a-uid", + Filename: "dummy.png", + Key: "lpa-uid/evidence/a-uid", + }, + }, NumberOfAllowedFiles: 5, MimeTypes: acceptedMimeTypes(), FeeType: page.HalfFee, - UploadedCount: 2, + StartScan: "1", }). Return(nil) - err := UploadEvidence(template.Execute, nil, donorStore, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) assert.Nil(t, err) } @@ -195,38 +250,163 @@ func TestPostUploadEvidenceWithUploadActionFilenameSpecialCharactersAreEscaped(t r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("PutObject", r.Context(), "lpa-uid/evidence/a-uid", mock.Anything). + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + documentStore. + On("Create", r.Context(), &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}, "<img src=1 onerror=alert(document.domain)>’ brute.heic", mock.Anything). + Return(page.Document{ + PK: "LPA#lpa-id", + SK: "#DOCUMENT#lpa-uid/evidence/a-uid", + Filename: "<img src=1 onerror=alert(document.domain)>’ brute.heic", + Key: "lpa-uid/evidence/a-uid", + }, nil) + + template := newMockTemplate(t) + template. + On("Execute", w, &uploadEvidenceData{ + App: testAppData, + Documents: page.Documents{ + { + PK: "LPA#lpa-id", + SK: "#DOCUMENT#lpa-uid/evidence/a-uid", + Filename: "<img src=1 onerror=alert(document.domain)>’ brute.heic", + Key: "lpa-uid/evidence/a-uid", + }, + }, + NumberOfAllowedFiles: 5, + MimeTypes: acceptedMimeTypes(), + FeeType: page.HalfFee, + StartScan: "1", + }). + Return(nil) + + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + assert.Nil(t, err) +} + +func TestPostUploadEvidenceWithPayAction(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, _ := writer.CreateFormField("csrf") + io.WriteString(part, "123") + + part, _ = writer.CreateFormField("action") + io.WriteString(part, "pay") + + writer.Close() + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", &buf) + r.Header.Set("Content-Type", writer.FormDataContentType()) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + + payer := newMockPayer(t) + payer. + On("Pay", testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}). Return(nil) - evidence := page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "<img src=1 onerror=alert(document.domain)>’ brute.heic"}, - }} - updatedLpa := &page.Lpa{UID: "lpa-uid", Evidence: evidence, FeeType: page.HalfFee} + err := UploadEvidence(nil, payer, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + + assert.Nil(t, err) +} + +func TestPostUploadEvidenceWithPayActionWhenPayerError(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, _ := writer.CreateFormField("csrf") + io.WriteString(part, "123") + + part, _ = writer.CreateFormField("action") + io.WriteString(part, "pay") + + writer.Close() + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", &buf) + r.Header.Set("Content-Type", writer.FormDataContentType()) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + + payer := newMockPayer(t) + payer. + On("Pay", testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}). + Return(expectedError) + + err := UploadEvidence(nil, payer, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + + assert.Equal(t, expectedError, err) +} + +func TestPostUploadEvidenceWithScanResultsActionWithInfectedFiles(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, _ := writer.CreateFormField("csrf") + io.WriteString(part, "123") + + part, _ = writer.CreateFormField("action") + io.WriteString(part, "scanResults") + + writer.Close() - donorStore := newMockDonorStore(t) - donorStore. - On("Put", r.Context(), updatedLpa). + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", &buf) + r.Header.Set("Content-Type", writer.FormDataContentType()) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }, nil). + Once() + documentStore. + On("DeleteInfectedDocuments", r.Context(), page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }). Return(nil) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "b", VirusDetected: false}, + }, nil). + Once() template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ App: testAppData, - Evidence: evidence, + Documents: page.Documents{{Filename: "b", VirusDetected: false}}, NumberOfAllowedFiles: 5, MimeTypes: acceptedMimeTypes(), FeeType: page.HalfFee, - UploadedCount: 1, + Errors: validation.With("upload", validation.FilesInfectedError{Label: "upload", Filenames: []string{"a", "c", "d"}}), }). Return(nil) - err := UploadEvidence(template.Execute, nil, donorStore, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + assert.Nil(t, err) } -func TestPostUploadEvidenceWithPayAction(t *testing.T) { +func TestPostUploadEvidenceWithScanResultsActionWithoutInfectedFiles(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -234,7 +414,7 @@ func TestPostUploadEvidenceWithPayAction(t *testing.T) { io.WriteString(part, "123") part, _ = writer.CreateFormField("action") - io.WriteString(part, "pay") + io.WriteString(part, "scanResults") writer.Close() @@ -242,15 +422,203 @@ func TestPostUploadEvidenceWithPayAction(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - payer := newMockPayer(t) - payer. - On("Pay", testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}). + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "a", VirusDetected: false}, + }, nil) + + template := newMockTemplate(t) + template. + On("Execute", w, &uploadEvidenceData{ + App: testAppData, + Documents: page.Documents{{Filename: "a", VirusDetected: false}}, + NumberOfAllowedFiles: 5, + MimeTypes: acceptedMimeTypes(), + FeeType: page.HalfFee, + }). Return(nil) - err := UploadEvidence(nil, payer, nil, nil, nil)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + assert.Nil(t, err) } +func TestPostUploadEvidenceWithPayActionWithInfectedFilesWhenDocumentStoreGetAllErrors(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, _ := writer.CreateFormField("csrf") + io.WriteString(part, "123") + + part, _ = writer.CreateFormField("action") + io.WriteString(part, "scanResults") + + writer.Close() + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", &buf) + r.Header.Set("Content-Type", writer.FormDataContentType()) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }, expectedError) + + err := UploadEvidence(nil, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + + assert.Equal(t, expectedError, err) +} + +func TestPostUploadEvidenceWithScanResultsActionWithInfectedFilesWhenDocumentStoreDeleteInfectedDocumentsError(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, _ := writer.CreateFormField("csrf") + io.WriteString(part, "123") + + part, _ = writer.CreateFormField("action") + io.WriteString(part, "scanResults") + + writer.Close() + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", &buf) + r.Header.Set("Content-Type", writer.FormDataContentType()) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }, nil) + documentStore. + On("DeleteInfectedDocuments", r.Context(), page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }). + Return(expectedError) + + err := UploadEvidence(nil, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + + assert.Equal(t, expectedError, err) +} + +func TestPostUploadEvidenceWithScanResultsActionWithInfectedFilesWhenDocumentStoreGetAllAgainError(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, _ := writer.CreateFormField("csrf") + io.WriteString(part, "123") + + part, _ = writer.CreateFormField("action") + io.WriteString(part, "scanResults") + + writer.Close() + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", &buf) + r.Header.Set("Content-Type", writer.FormDataContentType()) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }, nil). + Once() + documentStore. + On("DeleteInfectedDocuments", r.Context(), page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }). + Return(nil) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "b", VirusDetected: false}, + }, expectedError). + Once() + + err := UploadEvidence(nil, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + + assert.Equal(t, expectedError, err) +} + +func TestPostUploadEvidenceWithScanResultsActionWithInfectedFilesWhenTemplateError(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + + part, _ := writer.CreateFormField("csrf") + io.WriteString(part, "123") + + part, _ = writer.CreateFormField("action") + io.WriteString(part, "scanResults") + + writer.Close() + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", &buf) + r.Header.Set("Content-Type", writer.FormDataContentType()) + + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }, nil). + Once() + documentStore. + On("DeleteInfectedDocuments", r.Context(), page.Documents{ + {Filename: "a", VirusDetected: true}, + {Filename: "b", VirusDetected: false}, + {Filename: "c", VirusDetected: true}, + {Filename: "d", VirusDetected: true}, + }). + Return(nil) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Filename: "b", VirusDetected: false}, + }, nil). + Once() + + template := newMockTemplate(t) + template. + On("Execute", w, &uploadEvidenceData{ + App: testAppData, + Documents: page.Documents{{Filename: "b", VirusDetected: false}}, + NumberOfAllowedFiles: 5, + MimeTypes: acceptedMimeTypes(), + FeeType: page.HalfFee, + Errors: validation.With("upload", validation.FilesInfectedError{Label: "upload", Filenames: []string{"a", "c", "d"}}), + }). + Return(expectedError) + + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", UID: "lpa-uid", FeeType: page.HalfFee}) + + assert.Equal(t, expectedError, err) +} + func TestPostUploadEvidenceWhenBadCsrfField(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -264,6 +632,11 @@ func TestPostUploadEvidenceWhenBadCsrfField(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ @@ -272,10 +645,11 @@ func TestPostUploadEvidenceWhenBadCsrfField(t *testing.T) { MimeTypes: acceptedMimeTypes(), Errors: validation.With("upload", validation.CustomError{Label: "errorGenericUploadProblem"}), FeeType: page.FullFee, + Documents: page.Documents{}, }). Return(nil) - err := UploadEvidence(template.Execute, nil, nil, nil, nil)(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: page.FullFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: page.FullFee}) resp := w.Result() assert.Nil(t, err) @@ -298,6 +672,11 @@ func TestPostUploadEvidenceWhenBadActionField(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ @@ -306,10 +685,11 @@ func TestPostUploadEvidenceWhenBadActionField(t *testing.T) { MimeTypes: acceptedMimeTypes(), Errors: validation.With("upload", validation.CustomError{Label: "errorGenericUploadProblem"}), FeeType: page.FullFee, + Documents: page.Documents{}, }). Return(nil) - err := UploadEvidence(template.Execute, nil, nil, nil, nil)(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: page.FullFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: page.FullFee}) resp := w.Result() assert.Nil(t, err) @@ -340,6 +720,11 @@ func TestPostUploadEvidenceNumberOfFilesLimitPassed(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ @@ -348,10 +733,11 @@ func TestPostUploadEvidenceNumberOfFilesLimitPassed(t *testing.T) { MimeTypes: acceptedMimeTypes(), Errors: validation.With("upload", validation.CustomError{Label: "errorTooManyFiles"}), FeeType: page.FullFee, + Documents: page.Documents{}, }). Return(nil) - err := UploadEvidence(template.Execute, nil, nil, nil, nil)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.FullFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.FullFee}) assert.Nil(t, err) } @@ -409,6 +795,11 @@ func TestPostUploadEvidenceWhenBadUpload(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{}, nil) + template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ @@ -417,10 +808,11 @@ func TestPostUploadEvidenceWhenBadUpload(t *testing.T) { MimeTypes: acceptedMimeTypes(), Errors: validation.With("upload", tc.expectedError), FeeType: page.FullFee, + Documents: page.Documents{}, }). Return(nil) - err := UploadEvidence(template.Execute, nil, nil, nil, nil)(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: page.FullFee}) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{ID: "lpa-id", FeeType: page.FullFee}) resp := w.Result() assert.Nil(t, err) @@ -429,7 +821,7 @@ func TestPostUploadEvidenceWhenBadUpload(t *testing.T) { } } -func TestPostUploadEvidenceWhenS3ClientErrors(t *testing.T) { +func TestGetUploadEvidenceDeleteEvidence(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -437,27 +829,48 @@ func TestPostUploadEvidenceWhenS3ClientErrors(t *testing.T) { io.WriteString(part, "123") part, _ = writer.CreateFormField("action") - io.WriteString(part, "upload") + io.WriteString(part, "delete") - file := addFileToUploadField(writer, "dummy.pdf") + part, _ = writer.CreateFormField("delete") + io.WriteString(part, "lpa-uid/evidence/a-uid") - file.Close() writer.Close() w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("PutObject", r.Context(), "lpa-uid/evidence/a-uid", mock.Anything). - Return(expectedError) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, nil) + documentStore. + On("Delete", r.Context(), page.Document{Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}). + Return(nil) - err := UploadEvidence(nil, nil, nil, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{UID: "lpa-uid"}) - assert.Equal(t, expectedError, err) + template := newMockTemplate(t) + template. + On("Execute", w, &uploadEvidenceData{ + App: testAppData, + NumberOfAllowedFiles: 5, + MimeTypes: acceptedMimeTypes(), + FeeType: page.FullFee, + Documents: page.Documents{ + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, + Deleted: "dummy.pdf", + }). + Return(nil) + + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{}) + + assert.Nil(t, err) } -func TestPostUploadEvidenceWhenDonorStoreError(t *testing.T) { +func TestGetUploadEvidenceDeleteEvidenceWhenUnexpectedFieldName(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -465,36 +878,44 @@ func TestPostUploadEvidenceWhenDonorStoreError(t *testing.T) { io.WriteString(part, "123") part, _ = writer.CreateFormField("action") - io.WriteString(part, "upload") + io.WriteString(part, "delete") - file := addFileToUploadField(writer, "dummy.pdf") + part, _ = writer.CreateFormField("not-delete") + io.WriteString(part, "not-a-key") - file.Close() writer.Close() w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("PutObject", r.Context(), "lpa-uid/evidence/a-uid", mock.Anything). - Return(nil) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, + }, nil) - updatedLpa := &page.Lpa{UID: "lpa-uid", Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, - }}} + template := newMockTemplate(t) + template. + On("Execute", w, &uploadEvidenceData{ + App: testAppData, + NumberOfAllowedFiles: 5, + MimeTypes: acceptedMimeTypes(), + FeeType: page.FullFee, + Documents: page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, + }, + Errors: validation.With("delete", validation.CustomError{Label: "errorGenericUploadProblem"}), + }). + Return(nil) - donorStore := newMockDonorStore(t) - donorStore. - On("Put", r.Context(), updatedLpa). - Return(expectedError) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{}) - err := UploadEvidence(nil, nil, donorStore, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{UID: "lpa-uid"}) - assert.Equal(t, expectedError, err) + assert.Nil(t, err) } -func TestPostUploadEvidenceWhenPayerError(t *testing.T) { +func TestGetUploadEvidenceDeleteEvidenceWhenDocumentStoreDeleteError(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -502,7 +923,10 @@ func TestPostUploadEvidenceWhenPayerError(t *testing.T) { io.WriteString(part, "123") part, _ = writer.CreateFormField("action") - io.WriteString(part, "pay") + io.WriteString(part, "delete") + + part, _ = writer.CreateFormField("delete") + io.WriteString(part, "lpa-uid/evidence/a-uid") writer.Close() @@ -510,16 +934,23 @@ func TestPostUploadEvidenceWhenPayerError(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - payer := newMockPayer(t) - payer. - On("Pay", testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}). + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, nil) + documentStore. + On("Delete", r.Context(), page.Document{Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}). Return(expectedError) - err := UploadEvidence(nil, payer, nil, nil, nil)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) + err := UploadEvidence(nil, nil, documentStore)(testAppData, w, r, &page.Lpa{}) + assert.Equal(t, expectedError, err) } -func TestGetUploadEvidenceDeleteEvidence(t *testing.T) { +func TestGetUploadEvidenceDeleteEvidenceWhenTemplateError(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -538,46 +969,37 @@ func TestGetUploadEvidenceDeleteEvidence(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("DeleteObject", r.Context(), "lpa-uid/evidence/a-uid"). - Return(nil) - - evidence := page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, - }} - updatedLpa := &page.Lpa{UID: "lpa-uid", Evidence: evidence, FeeType: page.HalfFee} - - donorStore := newMockDonorStore(t) - donorStore. - On("Put", r.Context(), updatedLpa). + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, nil) + documentStore. + On("Delete", r.Context(), page.Document{Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}). Return(nil) template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ App: testAppData, - Evidence: evidence, NumberOfAllowedFiles: 5, MimeTypes: acceptedMimeTypes(), - FeeType: page.HalfFee, - Deleted: "dummy.pdf", + FeeType: page.FullFee, + Documents: page.Documents{ + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, + }, + Deleted: "dummy.pdf", }). - Return(nil) + Return(expectedError) - err := UploadEvidence(template.Execute, nil, donorStore, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{ - UID: "lpa-uid", - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, - {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, - }}, - }) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{}) - assert.Nil(t, err) + assert.Equal(t, expectedError, err) } -func TestGetUploadEvidenceDeleteEvidenceWhenUnexpectedFieldName(t *testing.T) { +func TestPostUploadEvidenceWithCloseConnectionAction(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -585,10 +1007,7 @@ func TestGetUploadEvidenceDeleteEvidenceWhenUnexpectedFieldName(t *testing.T) { io.WriteString(part, "123") part, _ = writer.CreateFormField("action") - io.WriteString(part, "delete") - - part, _ = writer.CreateFormField("not-delete") - io.WriteString(part, "not-a-key") + io.WriteString(part, "closeConnection") writer.Close() @@ -596,32 +1015,40 @@ func TestGetUploadEvidenceDeleteEvidenceWhenUnexpectedFieldName(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Scanned: true}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Scanned: false}, + }, nil) + documentStore. + On("Delete", r.Context(), page.Document{Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Scanned: false}). + Return(nil) + template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ - App: testAppData, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, - }}, + App: testAppData, NumberOfAllowedFiles: 5, MimeTypes: acceptedMimeTypes(), FeeType: page.HalfFee, - Errors: validation.With("delete", validation.CustomError{Label: "errorGenericUploadProblem"}), + Documents: page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Scanned: true}, + }, + Errors: validation.With("upload", validation.CustomError{Label: "errorGenericUploadProblem"}), }). Return(nil) - err := UploadEvidence(template.Execute, nil, nil, func() string { return "a-uid" }, nil)(testAppData, w, r, &page.Lpa{ - UID: "lpa-uid", - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}}, - }, - }) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) + + resp := w.Result() assert.Nil(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) } -func TestGetUploadEvidenceDeleteEvidenceWhenS3ClientError(t *testing.T) { +func TestPostUploadEvidenceWithCloseConnectionActionWhenDocumentStoreDeleteError(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -629,10 +1056,7 @@ func TestGetUploadEvidenceDeleteEvidenceWhenS3ClientError(t *testing.T) { io.WriteString(part, "123") part, _ = writer.CreateFormField("action") - io.WriteString(part, "delete") - - part, _ = writer.CreateFormField("delete") - io.WriteString(part, "lpa-uid/evidence/a-uid") + io.WriteString(part, "closeConnection") writer.Close() @@ -640,24 +1064,26 @@ func TestGetUploadEvidenceDeleteEvidenceWhenS3ClientError(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("DeleteObject", r.Context(), "lpa-uid/evidence/a-uid"). + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Scanned: true}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Scanned: false}, + }, nil) + documentStore. + On("Delete", r.Context(), page.Document{Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Scanned: false}). Return(expectedError) - err := UploadEvidence(nil, nil, nil, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{ - UID: "lpa-uid", - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, - {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, - }}, - }) + err := UploadEvidence(nil, nil, documentStore)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) + + resp := w.Result() assert.Equal(t, expectedError, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) } -func TestGetUploadEvidenceDeleteEvidenceOnDonorStoreError(t *testing.T) { +func TestPostUploadEvidenceWithCloseConnectionActionWhenTemplateError(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -665,10 +1091,7 @@ func TestGetUploadEvidenceDeleteEvidenceOnDonorStoreError(t *testing.T) { io.WriteString(part, "123") part, _ = writer.CreateFormField("action") - io.WriteString(part, "delete") - - part, _ = writer.CreateFormField("delete") - io.WriteString(part, "lpa-uid/evidence/a-uid") + io.WriteString(part, "closeConnection") writer.Close() @@ -676,34 +1099,40 @@ func TestGetUploadEvidenceDeleteEvidenceOnDonorStoreError(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("DeleteObject", r.Context(), "lpa-uid/evidence/a-uid"). + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Scanned: true}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Scanned: false}, + }, nil) + documentStore. + On("Delete", r.Context(), page.Document{Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Scanned: false}). Return(nil) - evidence := page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, - }} - updatedLpa := &page.Lpa{UID: "lpa-uid", Evidence: evidence, FeeType: page.HalfFee} - - donorStore := newMockDonorStore(t) - donorStore. - On("Put", r.Context(), updatedLpa). + template := newMockTemplate(t) + template. + On("Execute", w, &uploadEvidenceData{ + App: testAppData, + NumberOfAllowedFiles: 5, + MimeTypes: acceptedMimeTypes(), + FeeType: page.HalfFee, + Documents: page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Scanned: true}, + }, + Errors: validation.With("upload", validation.CustomError{Label: "errorGenericUploadProblem"}), + }). Return(expectedError) - err := UploadEvidence(nil, nil, donorStore, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{ - UID: "lpa-uid", - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, - {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, - }}, - }) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) + + resp := w.Result() assert.Equal(t, expectedError, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) } -func TestGetUploadEvidenceDeleteEvidenceOnTemplateError(t *testing.T) { +func TestPostUploadEvidenceWithCancelUploadAction(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) @@ -711,10 +1140,7 @@ func TestGetUploadEvidenceDeleteEvidenceOnTemplateError(t *testing.T) { io.WriteString(part, "123") part, _ = writer.CreateFormField("action") - io.WriteString(part, "delete") - - part, _ = writer.CreateFormField("delete") - io.WriteString(part, "lpa-uid/evidence/a-uid") + io.WriteString(part, "cancelUpload") writer.Close() @@ -722,43 +1148,36 @@ func TestGetUploadEvidenceDeleteEvidenceOnTemplateError(t *testing.T) { r, _ := http.NewRequest(http.MethodPost, "/", &buf) r.Header.Set("Content-Type", writer.FormDataContentType()) - s3Client := newMockS3Client(t) - s3Client. - On("DeleteObject", r.Context(), "lpa-uid/evidence/a-uid"). - Return(nil) - - evidence := page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, - }} - updatedLpa := &page.Lpa{UID: "lpa-uid", Evidence: evidence, FeeType: page.HalfFee} - - donorStore := newMockDonorStore(t) - donorStore. - On("Put", r.Context(), updatedLpa). + documentStore := newMockDocumentStore(t) + documentStore. + On("GetAll", r.Context()). + Return(page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Scanned: true}, + {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Scanned: false}, + }, nil) + documentStore. + On("Delete", r.Context(), page.Document{Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png", Scanned: false}). Return(nil) template := newMockTemplate(t) template. On("Execute", w, &uploadEvidenceData{ App: testAppData, - Evidence: evidence, NumberOfAllowedFiles: 5, MimeTypes: acceptedMimeTypes(), FeeType: page.HalfFee, - Deleted: "dummy.pdf", + Documents: page.Documents{ + {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf", Scanned: true}, + }, }). - Return(expectedError) + Return(nil) - err := UploadEvidence(template.Execute, nil, donorStore, func() string { return "a-uid" }, s3Client)(testAppData, w, r, &page.Lpa{ - UID: "lpa-uid", - FeeType: page.HalfFee, - Evidence: page.Evidence{Documents: []page.Document{ - {Key: "lpa-uid/evidence/a-uid", Filename: "dummy.pdf"}, - {Key: "lpa-uid/evidence/another-uid", Filename: "dummy.png"}, - }}, - }) + err := UploadEvidence(template.Execute, nil, documentStore)(testAppData, w, r, &page.Lpa{UID: "lpa-uid", FeeType: page.HalfFee}) - assert.Equal(t, expectedError, err) + resp := w.Result() + + assert.Nil(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) } func addFileToUploadField(writer *multipart.Writer, filename string) *os.File { diff --git a/internal/page/donor/witnessing_your_signature.go b/internal/page/donor/witnessing_your_signature.go index c58305afbb..ffe0c456f9 100644 --- a/internal/page/donor/witnessing_your_signature.go +++ b/internal/page/donor/witnessing_your_signature.go @@ -14,7 +14,7 @@ type witnessingYourSignatureData struct { Lpa *page.Lpa } -func WitnessingYourSignature(tmpl template.Template, witnessCodeSender WitnessCodeSender) Handler { +func WitnessingYourSignature(tmpl template.Template, witnessCodeSender WitnessCodeSender, donorStore DonorStore) Handler { return func(appData page.AppData, w http.ResponseWriter, r *http.Request, lpa *page.Lpa) error { if r.Method == http.MethodPost { if err := witnessCodeSender.SendToCertificateProvider(r.Context(), lpa, appData.Localizer); err != nil { @@ -24,6 +24,11 @@ func WitnessingYourSignature(tmpl template.Template, witnessCodeSender WitnessCo if lpa.Donor.CanSign.IsYes() { return appData.Redirect(w, r, lpa, page.Paths.WitnessingAsCertificateProvider.Format(lpa.ID)) } else { + lpa, err := donorStore.Get(r.Context()) + if err != nil { + return err + } + if err := witnessCodeSender.SendToIndependentWitness(r.Context(), lpa, appData.Localizer); err != nil { return err } diff --git a/internal/page/donor/witnessing_your_signature_test.go b/internal/page/donor/witnessing_your_signature_test.go index e4e2057a30..1c9bbb0821 100644 --- a/internal/page/donor/witnessing_your_signature_test.go +++ b/internal/page/donor/witnessing_your_signature_test.go @@ -27,7 +27,7 @@ func TestGetWitnessingYourSignature(t *testing.T) { On("Execute", w, &witnessingYourSignatureData{App: testAppData, Lpa: lpa}). Return(nil) - err := WitnessingYourSignature(template.Execute, nil)(testAppData, w, r, lpa) + err := WitnessingYourSignature(template.Execute, nil, nil)(testAppData, w, r, lpa) resp := w.Result() assert.Nil(t, err) @@ -45,86 +45,124 @@ func TestGetWitnessingYourSignatureWhenTemplateErrors(t *testing.T) { On("Execute", w, &witnessingYourSignatureData{App: testAppData, Lpa: lpa}). Return(expectedError) - err := WitnessingYourSignature(template.Execute, nil)(testAppData, w, r, lpa) + err := WitnessingYourSignature(template.Execute, nil, nil)(testAppData, w, r, lpa) assert.Equal(t, expectedError, err) } func TestPostWitnessingYourSignature(t *testing.T) { - testcases := map[string]struct { - donor actor.Donor - methods []string - redirect page.LpaPath - }{ - "can sign": { - donor: actor.Donor{CanSign: form.Yes}, - methods: []string{"SendToCertificateProvider"}, - redirect: page.Paths.WitnessingAsCertificateProvider, - }, - "cannot sign": { - donor: actor.Donor{CanSign: form.No}, - methods: []string{"SendToCertificateProvider", "SendToIndependentWitness"}, - redirect: page.Paths.WitnessingAsIndependentWitness, - }, - } + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", nil) - for name, tc := range testcases { - t.Run(name, func(t *testing.T) { - w := httptest.NewRecorder() - r, _ := http.NewRequest(http.MethodPost, "/", nil) + lpa := &page.Lpa{ + ID: "lpa-id", + Donor: actor.Donor{CanSign: form.Yes}, + DonorIdentityUserData: identity.UserData{OK: true}, + CertificateProvider: actor.CertificateProvider{Mobile: "07535111111"}, + } - lpa := &page.Lpa{ - ID: "lpa-id", - Donor: tc.donor, - DonorIdentityUserData: identity.UserData{OK: true}, - CertificateProvider: actor.CertificateProvider{Mobile: "07535111111"}, - } + witnessCodeSender := newMockWitnessCodeSender(t) + witnessCodeSender. + On("SendToCertificateProvider", r.Context(), lpa, mock.Anything). + Return(nil) - witnessCodeSender := newMockWitnessCodeSender(t) - for _, method := range tc.methods { - witnessCodeSender. - On(method, r.Context(), lpa, mock.Anything). - Return(nil) - } + err := WitnessingYourSignature(nil, witnessCodeSender, nil)(testAppData, w, r, lpa) + resp := w.Result() - err := WitnessingYourSignature(nil, witnessCodeSender)(testAppData, w, r, lpa) - resp := w.Result() + assert.Nil(t, err) + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Equal(t, page.Paths.WitnessingAsCertificateProvider.Format("lpa-id"), resp.Header.Get("Location")) +} - assert.Nil(t, err) - assert.Equal(t, http.StatusFound, resp.StatusCode) - assert.Equal(t, tc.redirect.Format("lpa-id"), resp.Header.Get("Location")) - }) +func TestPostWitnessingYourSignatureCannotSign(t *testing.T) { + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/", nil) + + lpa := &page.Lpa{ + Version: 1, + ID: "lpa-id", + Donor: actor.Donor{CanSign: form.No}, + DonorIdentityUserData: identity.UserData{OK: true}, + CertificateProvider: actor.CertificateProvider{Mobile: "07535111111"}, } + + witnessCodeSender := newMockWitnessCodeSender(t) + witnessCodeSender. + On("SendToCertificateProvider", r.Context(), lpa, mock.Anything). + Return(nil) + witnessCodeSender. + On("SendToIndependentWitness", r.Context(), &page.Lpa{ + Version: 2, + ID: "lpa-id", + Donor: actor.Donor{CanSign: form.No}, + DonorIdentityUserData: identity.UserData{OK: true}, + CertificateProvider: actor.CertificateProvider{Mobile: "07535111111"}, + }, mock.Anything). + Return(nil) + + donorStore := newMockDonorStore(t) + donorStore. + On("Get", r.Context()). + Return(&page.Lpa{ + Version: 2, + ID: "lpa-id", + Donor: actor.Donor{CanSign: form.No}, + DonorIdentityUserData: identity.UserData{OK: true}, + CertificateProvider: actor.CertificateProvider{Mobile: "07535111111"}, + }, nil) + + err := WitnessingYourSignature(nil, witnessCodeSender, donorStore)(testAppData, w, r, lpa) + resp := w.Result() + + assert.Nil(t, err) + assert.Equal(t, http.StatusFound, resp.StatusCode) + assert.Equal(t, page.Paths.WitnessingAsIndependentWitness.Format("lpa-id"), resp.Header.Get("Location")) } func TestPostWitnessingYourSignatureWhenWitnessCodeSenderErrors(t *testing.T) { - testcases := map[string]func(*mockWitnessCodeSender){ - "SendToCertificateProvider": func(witnessCodeSender *mockWitnessCodeSender) { - witnessCodeSender. - On("SendToCertificateProvider", mock.Anything, mock.Anything, mock.Anything). - Return(expectedError) + lpa := &page.Lpa{Donor: actor.Donor{CanSign: form.No}, CertificateProvider: actor.CertificateProvider{Mobile: "07535111111"}} + + testcases := map[string]struct { + setupWitnessCodeSender func(witnessCodeSender *mockWitnessCodeSender) + setupDonorStore func(donorStore *mockDonorStore) + }{ + "SendToCertificateProvider": { + setupWitnessCodeSender: func(witnessCodeSender *mockWitnessCodeSender) { + witnessCodeSender. + On("SendToCertificateProvider", mock.Anything, mock.Anything, mock.Anything). + Return(expectedError) + }, + setupDonorStore: func(donorStore *mockDonorStore) {}, }, - "SendToIndependentWitness": func(witnessCodeSender *mockWitnessCodeSender) { - witnessCodeSender. - On("SendToCertificateProvider", mock.Anything, mock.Anything, mock.Anything). - Return(nil) - witnessCodeSender. - On("SendToIndependentWitness", mock.Anything, mock.Anything, mock.Anything). - Return(expectedError) + "SendToIndependentWitness": { + setupWitnessCodeSender: func(witnessCodeSender *mockWitnessCodeSender) { + witnessCodeSender. + On("SendToCertificateProvider", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + witnessCodeSender. + On("SendToIndependentWitness", mock.Anything, mock.Anything, mock.Anything). + Return(expectedError) + }, + setupDonorStore: func(donorStore *mockDonorStore) { + donorStore. + On("Get", mock.Anything, mock.Anything, mock.Anything). + Return(lpa, nil) + }, }, } - for name, setupWitnessCodeSender := range testcases { + for name, tc := range testcases { t.Run(name, func(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodPost, "/", nil) - lpa := &page.Lpa{Donor: actor.Donor{CanSign: form.No}, CertificateProvider: actor.CertificateProvider{Mobile: "07535111111"}} - witnessCodeSender := newMockWitnessCodeSender(t) - setupWitnessCodeSender(witnessCodeSender) + tc.setupWitnessCodeSender(witnessCodeSender) + + donorStore := newMockDonorStore(t) + tc.setupDonorStore(donorStore) - err := WitnessingYourSignature(nil, witnessCodeSender)(testAppData, w, r, lpa) + err := WitnessingYourSignature(nil, witnessCodeSender, donorStore)(testAppData, w, r, lpa) assert.Equal(t, expectedError, err) }) } diff --git a/internal/page/fixtures/donor.go b/internal/page/fixtures/donor.go index 43d0e16b3b..85379bd495 100644 --- a/internal/page/fixtures/donor.go +++ b/internal/page/fixtures/donor.go @@ -1,6 +1,7 @@ package fixtures import ( + "context" "encoding/base64" "net/http" "slices" @@ -16,12 +17,19 @@ import ( "github.com/ministryofjustice/opg-modernising-lpa/internal/sesh" ) +type DocumentStore interface { + GetAll(context.Context) (page.Documents, error) + Put(context.Context, page.Document) error + Create(ctx context.Context, lpa *page.Lpa, filename string, data []byte) (page.Document, error) +} + func Donor( tmpl template.Template, sessionStore sesh.Store, donorStore DonorStore, certificateProviderStore CertificateProviderStore, attorneyStore AttorneyStore, + documentStore DocumentStore, ) page.Handler { progressValues := []string{ "provideYourDetails", @@ -53,6 +61,8 @@ func Donor( peopleToNotify = r.FormValue("peopleToNotify") replacementAttorneys = r.FormValue("replacementAttorneys") feeType = r.FormValue("feeType") + paymentTaskProgress = r.FormValue("paymentTaskProgress") + withVirus = r.FormValue("withVirus") == "1" ) if r.Method != http.MethodPost && !r.URL.Query().Has("redirect") { @@ -186,11 +196,31 @@ func Donor( } if progress >= slices.Index(progressValues, "payForTheLpa") { - if feeType == "half-fee" { - lpa.FeeType = page.HalfFee - lpa.Evidence = page.Evidence{Documents: []page.Document{ - {Key: "evidence-key", Filename: "supporting-evidence.png", Sent: time.Now()}, - }} + if feeType != "" && feeType != "FullFee" { + feeType, err := page.ParseFeeType(feeType) + if err != nil { + return err + } + + lpa.FeeType = feeType + + document, err := documentStore.Create( + page.ContextWithSessionData(r.Context(), &page.SessionData{SessionID: donorSessionID}), + lpa, + "supporting-evidence.png", + make([]byte, 64), + ) + + if err != nil { + return err + } + + document.Scanned = true + document.VirusDetected = withVirus + if err := documentStore.Put(page.ContextWithSessionData(r.Context(), &page.SessionData{SessionID: donorSessionID}), document); err != nil { + return err + } + } else { lpa.FeeType = page.FullFee } @@ -199,7 +229,17 @@ func Donor( PaymentReference: random.String(12), PaymentId: random.String(12), }) + lpa.Tasks.PayForLpa = actor.PaymentTaskCompleted + + if paymentTaskProgress != "" { + taskState, err := actor.ParsePaymentTask(paymentTaskProgress) + if err != nil { + return err + } + + lpa.Tasks.PayForLpa = taskState + } } if progress >= slices.Index(progressValues, "confirmYourIdentity") { diff --git a/internal/page/paths.go b/internal/page/paths.go index 0debdc4fc3..8629d4945d 100644 --- a/internal/page/paths.go +++ b/internal/page/paths.go @@ -172,6 +172,7 @@ type AppPaths struct { SignYourLpa LpaPath TaskList LpaPath UploadEvidence LpaPath + UploadEvidenceSSE LpaPath UseExistingAddress LpaPath WhatACertificateProviderDoes LpaPath WhenCanTheLpaBeUsed LpaPath @@ -313,6 +314,7 @@ var Paths = AppPaths{ Start: "/start", TaskList: "/task-list", UploadEvidence: "/upload-evidence", + UploadEvidenceSSE: "/upload-evidence-sse", UseExistingAddress: "/use-existing-address", WhatACertificateProviderDoes: "/what-a-certificate-provider-does", WhenCanTheLpaBeUsed: "/when-can-the-lpa-be-used", diff --git a/internal/s3/client.go b/internal/s3/client.go index 8309322e4a..c32bd08522 100644 --- a/internal/s3/client.go +++ b/internal/s3/client.go @@ -14,7 +14,8 @@ type s3Service interface { PutObject(context.Context, *s3.PutObjectInput, ...func(*s3.Options)) (*s3.PutObjectOutput, error) PutObjectTagging(context.Context, *s3.PutObjectTaggingInput, ...func(*s3.Options)) (*s3.PutObjectTaggingOutput, error) DeleteObject(context.Context, *s3.DeleteObjectInput, ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) - GetObjectTagging(ctx context.Context, params *s3.GetObjectTaggingInput, optFns ...func(*s3.Options)) (*s3.GetObjectTaggingOutput, error) + GetObjectTagging(context.Context, *s3.GetObjectTaggingInput, ...func(*s3.Options)) (*s3.GetObjectTaggingOutput, error) + DeleteObjects(context.Context, *s3.DeleteObjectsInput, ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) } type Client struct { @@ -37,6 +38,22 @@ func (c *Client) DeleteObject(ctx context.Context, key string) error { return err } +func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { + var objectIdentifier []types.ObjectIdentifier + for _, k := range keys { + objectIdentifier = append(objectIdentifier, types.ObjectIdentifier{Key: aws.String(k)}) + } + + _, err := c.svc.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: aws.String(c.bucket), + Delete: &types.Delete{ + Objects: objectIdentifier, + }, + }) + + return err +} + func (c *Client) GetObjectTags(ctx context.Context, key string) ([]types.Tag, error) { output, err := c.svc.GetObjectTagging(ctx, &s3.GetObjectTaggingInput{ Bucket: aws.String(c.bucket), diff --git a/internal/s3/mock_s3Service_test.go b/internal/s3/mock_s3Service_test.go index faec27971b..443212eaa2 100644 --- a/internal/s3/mock_s3Service_test.go +++ b/internal/s3/mock_s3Service_test.go @@ -47,24 +47,57 @@ func (_m *mockS3Service) DeleteObject(_a0 context.Context, _a1 *services3.Delete return r0, r1 } -// GetObjectTagging provides a mock function with given fields: ctx, params, optFns -func (_m *mockS3Service) GetObjectTagging(ctx context.Context, params *services3.GetObjectTaggingInput, optFns ...func(*services3.Options)) (*services3.GetObjectTaggingOutput, error) { - _va := make([]interface{}, len(optFns)) - for _i := range optFns { - _va[_i] = optFns[_i] +// DeleteObjects provides a mock function with given fields: _a0, _a1, _a2 +func (_m *mockS3Service) DeleteObjects(_a0 context.Context, _a1 *services3.DeleteObjectsInput, _a2 ...func(*services3.Options)) (*services3.DeleteObjectsOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] } var _ca []interface{} - _ca = append(_ca, ctx, params) + _ca = append(_ca, _a0, _a1) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + var r0 *services3.DeleteObjectsOutput + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *services3.DeleteObjectsInput, ...func(*services3.Options)) (*services3.DeleteObjectsOutput, error)); ok { + return rf(_a0, _a1, _a2...) + } + if rf, ok := ret.Get(0).(func(context.Context, *services3.DeleteObjectsInput, ...func(*services3.Options)) *services3.DeleteObjectsOutput); ok { + r0 = rf(_a0, _a1, _a2...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*services3.DeleteObjectsOutput) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *services3.DeleteObjectsInput, ...func(*services3.Options)) error); ok { + r1 = rf(_a0, _a1, _a2...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetObjectTagging provides a mock function with given fields: _a0, _a1, _a2 +func (_m *mockS3Service) GetObjectTagging(_a0 context.Context, _a1 *services3.GetObjectTaggingInput, _a2 ...func(*services3.Options)) (*services3.GetObjectTaggingOutput, error) { + _va := make([]interface{}, len(_a2)) + for _i := range _a2 { + _va[_i] = _a2[_i] + } + var _ca []interface{} + _ca = append(_ca, _a0, _a1) _ca = append(_ca, _va...) ret := _m.Called(_ca...) var r0 *services3.GetObjectTaggingOutput var r1 error if rf, ok := ret.Get(0).(func(context.Context, *services3.GetObjectTaggingInput, ...func(*services3.Options)) (*services3.GetObjectTaggingOutput, error)); ok { - return rf(ctx, params, optFns...) + return rf(_a0, _a1, _a2...) } if rf, ok := ret.Get(0).(func(context.Context, *services3.GetObjectTaggingInput, ...func(*services3.Options)) *services3.GetObjectTaggingOutput); ok { - r0 = rf(ctx, params, optFns...) + r0 = rf(_a0, _a1, _a2...) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*services3.GetObjectTaggingOutput) @@ -72,7 +105,7 @@ func (_m *mockS3Service) GetObjectTagging(ctx context.Context, params *services3 } if rf, ok := ret.Get(1).(func(context.Context, *services3.GetObjectTaggingInput, ...func(*services3.Options)) error); ok { - r1 = rf(ctx, params, optFns...) + r1 = rf(_a0, _a1, _a2...) } else { r1 = ret.Error(1) } diff --git a/internal/validation/error.go b/internal/validation/error.go index 5b585697d1..09406365e3 100644 --- a/internal/validation/error.go +++ b/internal/validation/error.go @@ -29,6 +29,19 @@ func (e FileError) Format(l Localizer) string { }) } +type FilesInfectedError struct { + Label string + Filenames []string +} + +func (e FilesInfectedError) Format(l Localizer) string { + joinedFilenames := l.Concat(e.Filenames, l.T("and")) + + return l.FormatCount("errorFilesInfected", len(e.Filenames), map[string]interface{}{ + "Filenames": joinedFilenames, + }) +} + type SelectError struct { Label string } diff --git a/internal/validation/mock_Localizer_test.go b/internal/validation/mock_Localizer_test.go index f61df58f43..3075b67003 100644 --- a/internal/validation/mock_Localizer_test.go +++ b/internal/validation/mock_Localizer_test.go @@ -9,6 +9,20 @@ type mockLocalizer struct { mock.Mock } +// Concat provides a mock function with given fields: _a0, _a1 +func (_m *mockLocalizer) Concat(_a0 []string, _a1 string) string { + ret := _m.Called(_a0, _a1) + + var r0 string + if rf, ok := ret.Get(0).(func([]string, string) string); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + // Format provides a mock function with given fields: _a0, _a1 func (_m *mockLocalizer) Format(_a0 string, _a1 map[string]interface{}) string { ret := _m.Called(_a0, _a1) @@ -23,6 +37,20 @@ func (_m *mockLocalizer) Format(_a0 string, _a1 map[string]interface{}) string { return r0 } +// FormatCount provides a mock function with given fields: _a0, _a1, _a2 +func (_m *mockLocalizer) FormatCount(_a0 string, _a1 int, _a2 map[string]interface{}) string { + ret := _m.Called(_a0, _a1, _a2) + + var r0 string + if rf, ok := ret.Get(0).(func(string, int, map[string]interface{}) string); ok { + r0 = rf(_a0, _a1, _a2) + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + // T provides a mock function with given fields: _a0 func (_m *mockLocalizer) T(_a0 string) string { ret := _m.Called(_a0) diff --git a/internal/validation/validation.go b/internal/validation/validation.go index 6ba6b5e768..4d578a45a8 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -2,7 +2,9 @@ package validation //go:generate mockery --testonly --inpackage --name Localizer --structname mockLocalizer type Localizer interface { + Concat([]string, string) string Format(string, map[string]any) string + FormatCount(string, int, map[string]interface{}) string T(string) string } diff --git a/lang/cy.json b/lang/cy.json index 75a568013e..5410102beb 100644 --- a/lang/cy.json +++ b/lang/cy.json @@ -793,6 +793,13 @@ "errorFileIncorrectType": "{{.Filename}}: Welsh", "errorFileEmpty": "{{.Filename}}: Welsh", "errorGenericUploadProblemFile": "{{.Filename}}: Welsh", + "errorFilesInfected": { + "one": "{{.Filenames}} Welsh", + "two": "{{.Filenames}} Welsh", + "few": "{{.Filenames}} Welsh", + "many": "{{.Filenames}} Welsh", + "other": "{{.Filenames}} Welsh" + }, "halfFeeNextSteps": "
Welsh
", "referenceNumber": "Welsh", "lastUpdated": "Welsh", @@ -819,13 +826,6 @@ "uploadedFiles": "Welsh", "fileName": "Welsh", "delete": "Welsh", - "nFilesSuccessfullyUploaded": { - "one": "{{.PluralCount}} Welsh", - "two": "{{.PluralCount}} Welsh", - "few": "{{.PluralCount}} Welsh", - "many": "{{.PluralCount}} Welsh", - "other": "{{.PluralCount}} Welsh" - }, "tipsForTakingPhotosAndCopies": "Welsh", "tipsForTakingPhotosAndCopiesDetails": "Welsh
Welsh
Welsh:
Welsh
", "applicationNoFee": "welsh", "applicationHalfFee": "welsh", - "applicationHardshipFee": "welsh" + "applicationHardshipFee": "welsh", + "yourFilesAreUploading": "Welsh", + "0OfNFilesUploaded": "Welsh {{ .TotalFilesCount }} welsh", + "yourFilesAreUploadingContent": "Welsh", + "cancelUpload": "Welsh" } diff --git a/lang/en.json b/lang/en.json index 41ce42a7ed..bc07eb7f4d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -734,12 +734,16 @@ "imHavingProblemWithCode": "I’m having a problem with the code", "imHavingProblemWithCodeContent": "The code can take a few minutes to arrive. We can send the code again if it is not working or you did not receive it.
If the mobile number we have for you is incorrect, you can change the number we send the code to.
", "asTheCertificateProviderTypeTheCode": "{{.CertificateProviderFirstNames}}, as the certificate provider for {{.DonorFullName}}, please type in the code to prove that you witnessed their LPA being signed.", - "errorGenericUploadProblem": "Generic upload error", + "errorGenericUploadProblem": "There was a problem uploading your files. Try again later.", "errorTooManyFiles": "Too many files uploaded", "errorFileTooBig": "{{.Filename}}: File size too big", "errorFileIncorrectType": "{{.Filename}}: File is not a supported file type", "errorFileEmpty": "{{.Filename}}: File is empty", "errorGenericUploadProblemFile": "{{.Filename}}: Generic file error", + "errorFilesInfected": { + "one": "{{.Filenames}} cannot be uploaded as it contains a virus", + "other": "{{.Filenames}} cannot be uploaded as they contain a virus" + }, "halfFeeNextSteps": "Content detailing next steps when donor has paid a half fee and is waiting for evidence to be checked by a case worker
", "referenceNumber": "Reference number", "lastUpdated": "Last updated", @@ -766,10 +770,6 @@ "uploadedFiles": "Uploaded files", "fileName": "File name", "delete": "Delete", - "nFilesSuccessfullyUploaded": { - "one": "{{.PluralCount}} file successfully uploaded", - "other": "{{.PluralCount}} files successfully uploaded" - }, "tipsForTakingPhotosAndCopies": "Tips for taking photos and copies", "tipsForTakingPhotosAndCopiesDetails": "Any photos or scanned copies of your documents should be clear and high quality so we can read the information.
If you’re taking a photo, it helps if the area you are in is well lit.
Make sure that the document is:
We will review your LPA application and supporting evidence.
You can still sign your LPA while we’re reviewing your application. Your certificate provider must be there as a witness when you sign it.
However, we will not contact your certificate provider to provide their certificate until your {{.Application}} has been approved.
Once we have approved your payment, we will contact your certificate provider to provide their certificate.
We will contact you if we need more information or if your application is unsuccessful.
If your application is unsuccessful, you can appeal within 4 weeks of the decision by writing to the Head of Corporate Services.
If the original decision is upheld, it will be referred to the Public Guardian for confirmation.