diff --git a/app/controllers/AlcoholByVolumeQuestionController.scala b/app/controllers/AlcoholByVolumeQuestionController.scala new file mode 100644 index 00000000..11721d74 --- /dev/null +++ b/app/controllers/AlcoholByVolumeQuestionController.scala @@ -0,0 +1,75 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import connectors.CacheConnector +import controllers.actions._ +import forms.AlcoholByVolumeQuestionFormProvider + +import javax.inject.Inject +import models.Mode +import navigation.Navigator +import pages.AlcoholByVolumeQuestionPage +import play.api.i18n.{I18nSupport, MessagesApi} +import play.api.mvc.{Action, AnyContent, MessagesControllerComponents} +import uk.gov.hmrc.play.bootstrap.frontend.controller.FrontendBaseController +import views.html.AlcoholByVolumeQuestionView + +import scala.concurrent.{ExecutionContext, Future} + +class AlcoholByVolumeQuestionController @Inject() ( + override val messagesApi: MessagesApi, + cacheConnector: CacheConnector, + navigator: Navigator, + identify: IdentifierAction, + getData: DataRetrievalAction, + requireData: DataRequiredAction, + formProvider: AlcoholByVolumeQuestionFormProvider, + val controllerComponents: MessagesControllerComponents, + view: AlcoholByVolumeQuestionView +)(implicit ec: ExecutionContext) + extends FrontendBaseController + with I18nSupport { + + val form = formProvider() + + def onPageLoad(mode: Mode): Action[AnyContent] = (identify andThen getData andThen requireData) { implicit request => + val preparedForm = request.userAnswers.get(AlcoholByVolumeQuestionPage) match { + case None => form + case Some(value) => form.fill(value) + } + + Ok(view(preparedForm, mode)) + } + + def onSubmit(mode: Mode): Action[AnyContent] = (identify andThen getData andThen requireData).async { + implicit request => + form + .bindFromRequest() + .fold( + formWithErrors => Future.successful(BadRequest(view(formWithErrors, mode))), + value => + for { + updatedAnswers <- + Future.fromTry( + request.userAnswers.set(AlcoholByVolumeQuestionPage, value) + ) + _ <- cacheConnector.set(updatedAnswers) + } yield Redirect(navigator.nextPage(AlcoholByVolumeQuestionPage, mode, updatedAnswers)) + ) + } +} diff --git a/app/controllers/DraughtReliefQuestionController.scala b/app/controllers/DraughtReliefQuestionController.scala index 86183f1e..dd534649 100644 --- a/app/controllers/DraughtReliefQuestionController.scala +++ b/app/controllers/DraughtReliefQuestionController.scala @@ -21,7 +21,7 @@ import controllers.actions._ import forms.DraughtReliefQuestionFormProvider import javax.inject.Inject -import models.{Mode, UserAnswers} +import models.Mode import navigation.Navigator import pages.DraughtReliefQuestionPage import play.api.i18n.{I18nSupport, MessagesApi} @@ -37,6 +37,7 @@ class DraughtReliefQuestionController @Inject() ( navigator: Navigator, identify: IdentifierAction, getData: DataRetrievalAction, + requireData: DataRequiredAction, formProvider: DraughtReliefQuestionFormProvider, val controllerComponents: MessagesControllerComponents, view: DraughtReliefQuestionView @@ -46,8 +47,8 @@ class DraughtReliefQuestionController @Inject() ( val form = formProvider() - def onPageLoad(mode: Mode): Action[AnyContent] = (identify andThen getData) { implicit request => - val preparedForm = request.userAnswers.flatMap(_.get(DraughtReliefQuestionPage)) match { + def onPageLoad(mode: Mode): Action[AnyContent] = (identify andThen getData andThen requireData) { implicit request => + val preparedForm = request.userAnswers.get(DraughtReliefQuestionPage) match { case None => form case Some(value) => form.fill(value) } @@ -55,19 +56,20 @@ class DraughtReliefQuestionController @Inject() ( Ok(view(preparedForm, mode)) } - def onSubmit(mode: Mode): Action[AnyContent] = (identify andThen getData).async { implicit request => - form - .bindFromRequest() - .fold( - formWithErrors => Future.successful(BadRequest(view(formWithErrors, mode))), - value => - for { - updatedAnswers <- - Future.fromTry( - request.userAnswers.getOrElse(UserAnswers(request.userId)).set(DraughtReliefQuestionPage, value) - ) - _ <- cacheConnector.set(updatedAnswers) - } yield Redirect(navigator.nextPage(DraughtReliefQuestionPage, mode, updatedAnswers)) - ) + def onSubmit(mode: Mode): Action[AnyContent] = (identify andThen getData andThen requireData).async { + implicit request => + form + .bindFromRequest() + .fold( + formWithErrors => Future.successful(BadRequest(view(formWithErrors, mode))), + value => + for { + updatedAnswers <- + Future.fromTry( + request.userAnswers.set(DraughtReliefQuestionPage, value) + ) + _ <- cacheConnector.set(updatedAnswers) + } yield Redirect(navigator.nextPage(DraughtReliefQuestionPage, mode, updatedAnswers)) + ) } } diff --git a/app/forms/AlcoholByVolumeQuestionFormProvider.scala b/app/forms/AlcoholByVolumeQuestionFormProvider.scala new file mode 100644 index 00000000..28fddeec --- /dev/null +++ b/app/forms/AlcoholByVolumeQuestionFormProvider.scala @@ -0,0 +1,36 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package forms + +import forms.mappings.Mappings +import javax.inject.Inject +import play.api.data.Form + +class AlcoholByVolumeQuestionFormProvider @Inject() extends Mappings { + + def apply(): Form[BigDecimal] = + Form( + "value" -> bigDecimal( + "alcoholByVolumeQuestion.error.required", + "alcoholByVolumeQuestion.error.nonNumeric", + "alcoholByVolumeQuestion.error.twoDecimalPlaces" + ) + .verifying(minimumValue(BigDecimal(0.01), "alcoholByVolumeQuestion.error.minimumRequired")) + .verifying(maximumValue(BigDecimal(100), "alcoholByVolumeQuestion.error.maximumRequired")) + ) + +} diff --git a/app/forms/mappings/Formatters.scala b/app/forms/mappings/Formatters.scala index ca94135d..45b58c1f 100644 --- a/app/forms/mappings/Formatters.scala +++ b/app/forms/mappings/Formatters.scala @@ -38,6 +38,39 @@ trait Formatters { Map(key -> value) } + private[mappings] def bigDecimalFormatter( + requiredKey: String, + nonNumericKey: String, + twoDecimalPlacesKey: String, + args: Seq[String] = Seq.empty + ): Formatter[BigDecimal] = + new Formatter[BigDecimal] { + val decimalRegexp = """^[+-]?[0-9]*(\.[0-9]{0,2})?$""" + + private val baseFormatter = stringFormatter(requiredKey, args) + + override def bind(key: String, data: Map[String, String]) = + baseFormatter + .bind(key, data) + .map(_.replace(",", "")) + .flatMap { s => + nonFatalCatch + .either(BigDecimal(s)) + .left + .map(_ => Seq(FormError(key, nonNumericKey, args))) + .flatMap { res => + if (res.toString().matches(decimalRegexp)) { + Right(res) + } else { + Left(Seq(FormError(key, twoDecimalPlacesKey, args))) + } + } + } + + override def unbind(key: String, value: BigDecimal) = + baseFormatter.unbind(key, value.toString) + } + private[mappings] def booleanFormatter( requiredKey: String, invalidKey: String, diff --git a/app/forms/mappings/Mappings.scala b/app/forms/mappings/Mappings.scala index e5d9c59e..a7d07cd5 100644 --- a/app/forms/mappings/Mappings.scala +++ b/app/forms/mappings/Mappings.scala @@ -35,6 +35,14 @@ trait Mappings extends Formatters with Constraints { ): FieldMapping[Int] = of(intFormatter(requiredKey, wholeNumberKey, nonNumericKey, args)) + protected def bigDecimal( + requiredKey: String = "error.required", + nonNumericKey: String = "error.nonNumeric", + twoDecimalPlacesKey: String = "error.twoDecimalPlaces", + args: Seq[String] = Seq.empty + ): FieldMapping[BigDecimal] = + of(bigDecimalFormatter(requiredKey, nonNumericKey, twoDecimalPlacesKey, args)) + protected def boolean( requiredKey: String = "error.required", invalidKey: String = "error.boolean", diff --git a/app/navigation/Navigator.scala b/app/navigation/Navigator.scala index c4c723ef..bc2f724e 100644 --- a/app/navigation/Navigator.scala +++ b/app/navigation/Navigator.scala @@ -27,8 +27,10 @@ import models._ class Navigator @Inject() () { private val normalRoutes: Page => UserAnswers => Call = { - case DraughtReliefQuestionPage => _ => routes.SmallProducerReliefQuestionController.onPageLoad(NormalMode) - case _ => + case ProductNamePage => _ => routes.AlcoholByVolumeQuestionController.onPageLoad(NormalMode) + case AlcoholByVolumeQuestionPage => _ => routes.DraughtReliefQuestionController.onPageLoad(NormalMode) + case DraughtReliefQuestionPage => _ => routes.SmallProducerReliefQuestionController.onPageLoad(NormalMode) + case _ => _ => routes.IndexController.onPageLoad } diff --git a/app/pages/AlcoholByVolumeQuestionPage.scala b/app/pages/AlcoholByVolumeQuestionPage.scala new file mode 100644 index 00000000..5dd41b7d --- /dev/null +++ b/app/pages/AlcoholByVolumeQuestionPage.scala @@ -0,0 +1,26 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package pages + +import play.api.libs.json.JsPath + +case object AlcoholByVolumeQuestionPage extends QuestionPage[BigDecimal] { + + override def path: JsPath = JsPath \ toString + + override def toString: String = "alcoholByVolumeQuestion" +} diff --git a/app/viewmodels/checkAnswers/AlcoholByVolumeQuestionSummary.scala b/app/viewmodels/checkAnswers/AlcoholByVolumeQuestionSummary.scala new file mode 100644 index 00000000..8e6b4579 --- /dev/null +++ b/app/viewmodels/checkAnswers/AlcoholByVolumeQuestionSummary.scala @@ -0,0 +1,40 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package viewmodels.checkAnswers + +import controllers.routes +import models.{CheckMode, UserAnswers} +import pages.AlcoholByVolumeQuestionPage +import play.api.i18n.Messages +import uk.gov.hmrc.govukfrontend.views.viewmodels.summarylist.SummaryListRow +import viewmodels.govuk.summarylist._ +import viewmodels.implicits._ + +object AlcoholByVolumeQuestionSummary { + + def row(answers: UserAnswers)(implicit messages: Messages): Option[SummaryListRow] = + answers.get(AlcoholByVolumeQuestionPage).map { answer => + SummaryListRowViewModel( + key = "alcoholByVolumeQuestion.checkYourAnswersLabel", + value = ValueViewModel(answer.toString), + actions = Seq( + ActionItemViewModel("site.change", routes.AlcoholByVolumeQuestionController.onPageLoad(CheckMode).url) + .withVisuallyHiddenText(messages("alcoholByVolumeQuestion.change.hidden")) + ) + ) + } +} diff --git a/app/views/AlcoholByVolumeQuestionView.scala.html b/app/views/AlcoholByVolumeQuestionView.scala.html new file mode 100644 index 00000000..20a0d4c8 --- /dev/null +++ b/app/views/AlcoholByVolumeQuestionView.scala.html @@ -0,0 +1,60 @@ +@* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *@ + +@import viewmodels.InputWidth._ +@import components.{Link, SectionHeading, PageHeading} +@import uk.gov.hmrc.govukfrontend.views.viewmodels.content.Empty + + +@this( + layout: templates.Layout, + formHelper: FormWithCSRF, + govukErrorSummary: GovukErrorSummary, + govukInput: GovukInput, + govukButton: GovukButton, + sectionHeading: SectionHeading, + pageHeading: PageHeading +) + +@(form: Form[_], mode: Mode)(implicit request: Request[_], messages: Messages) + +@layout(pageTitle = title(form, messages("alcoholByVolumeQuestion.title"))) { + + @formHelper(action = routes.AlcoholByVolumeQuestionController.onSubmit(mode), Symbol("autoComplete") -> "off") { + + @if(form.errors.nonEmpty) { + @govukErrorSummary(ErrorSummaryViewModel(form)) + } + @sectionHeading( + id = "draught-relief-question-section", + text = messages("section.alcoholDutyReturn"), + ) + + @govukInput( + InputViewModel( + field = form("value"), + label = LabelViewModel(messages("alcoholByVolumeQuestion.heading")).asPageHeading() + ) + .withSuffix(PrefixOrSuffix(content = "%")) + .asNumeric() + .withWidth(Fixed10) + ) + + @govukButton( + ButtonViewModel("saveAndContinueButton",messages("site.saveAndContinue")) + ) + } +} diff --git a/conf/app.routes b/conf/app.routes index cc087f24..93d62b00 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -36,3 +36,7 @@ POST /smallProducerReliefQuestion controllers.Small GET /changeSmallProducerReliefQuestion controllers.SmallProducerReliefQuestionController.onPageLoad(mode: Mode = CheckMode) POST /changeSmallProducerReliefQuestion controllers.SmallProducerReliefQuestionController.onSubmit(mode: Mode = CheckMode) +GET /alcoholByVolumeQuestion controllers.AlcoholByVolumeQuestionController.onPageLoad(mode: Mode = NormalMode) +POST /alcoholByVolumeQuestion controllers.AlcoholByVolumeQuestionController.onSubmit(mode: Mode = NormalMode) +GET /changeAlcoholByVolumeQuestion controllers.AlcoholByVolumeQuestionController.onPageLoad(mode: Mode = CheckMode) +POST /changeAlcoholByVolumeQuestion controllers.AlcoholByVolumeQuestionController.onSubmit(mode: Mode = CheckMode) diff --git a/conf/messages.en b/conf/messages.en index 48b5133f..1c4ba000 100644 --- a/conf/messages.en +++ b/conf/messages.en @@ -6,7 +6,7 @@ site.change = Change site.no = No site.yes = Yes site.continue = Continue -site.saveAndContinue = Save and Continue +site.saveAndContinue = Save and continue site.start = Start now site.startAgain = Start again site.signIn = Sign in @@ -69,7 +69,7 @@ draughtReliefQuestion.guidance.link.text = You can get a reduced rate of duty on productName.title = What name do you want to give this product? productName.heading = What name do you want to give this product? productName.p1 = Give your product a name so you can identify it on your return. -productName.p2 = You may want to use an internal reference number or a description of the product's alcohol type and ABV strength. For example, ‘Beer, 5.3%’ +productName.p2 = You may want to use an internal reference number or a description of the product’s alcohol type and ABV strength. For example, ‘Beer, 5.3%’ productName.checkYourAnswersLabel = Name productName.error.required = Enter the name you want to give this product productName.error.length = Product name must be 50 characters or less @@ -79,4 +79,14 @@ smallProducerReliefQuestion.title = Is this product eligible for Small Producer smallProducerReliefQuestion.heading = Is this product eligible for Small Producer Relief? smallProducerReliefQuestion.error.required = Select yes if this product is eligible for Small Producer Relief smallProducerReliefQuestion.guidance.link.url = https://www.gov.uk/guidance/check-if-youre-eligible-for-small-producer-relief-on-alcohol-duty -smallProducerReliefQuestion.guidance.link.text = If you're a small producer you may be able to pay a lower rate of duty on this product. +smallProducerReliefQuestion.guidance.link.text = If you’re a small producer you may be able to pay a lower rate of duty on this product. + +alcoholByVolumeQuestion.title = What is this product’s Alcohol by Volume (ABV) strength? +alcoholByVolumeQuestion.heading = What is this product’s Alcohol by Volume (ABV) strength? +alcoholByVolumeQuestion.checkYourAnswersLabel = AlcoholByVolumeQuestion +alcoholByVolumeQuestion.error.nonNumeric = This product’s Alcohol by Volume (ABV) strength must be a number +alcoholByVolumeQuestion.error.required = Enter this product’s Alcohol by Volume (ABV) strength +alcoholByVolumeQuestion.error.minimumRequired = This product’s Alcohol by Volume (ABV) strength must be 0.01 or more +alcoholByVolumeQuestion.error.maximumRequired = This product’s Alcohol by Volume (ABV) strength must be 100 or less +alcoholByVolumeQuestion.error.twoDecimalPlaces = This product’s Alcohol by Volume (ABV) must be a number to two decimal places +alcoholByVolumeQuestion.change.hidden = Alcohol by Volume (ABV) strength diff --git a/test-utils/generators/Generators.scala b/test-utils/generators/Generators.scala index 3b01c4e1..e1c939e0 100644 --- a/test-utils/generators/Generators.scala +++ b/test-utils/generators/Generators.scala @@ -61,6 +61,28 @@ trait Generators extends ModelGenerators { .suchThat(!_.isValidInt) .map("%f".format(_)) + def decimalsNotTwoDecimalPlaces: Gen[String] = + arbitrary[BigDecimal] + .suchThat(_.abs < Int.MaxValue) + .suchThat(!_.abs.toString.matches("""^[0-9]*(\.[0-9]{0,2})?$""")) + .suchThat(!_.isValidInt) + .map("%f".format(_)) + + val to2dp: BigDecimal => BigDecimal = _.setScale(2, BigDecimal.RoundingMode.HALF_UP) + def bigDecimalsBelowValue(value: BigDecimal): Gen[String] = + bigDecimalsInRangeWithCommas(Double.MinValue, value) + + def bigDecimalsAboveValue(value: BigDecimal): Gen[String] = + bigDecimalsInRangeWithCommas(value, 10000) + + def bigDecimalsOutsideRange(min: BigDecimal, max: BigDecimal): Gen[BigDecimal] = + arbitrary[BigDecimal] suchThat (x => x < min || x > max) + + def bigDecimalsInRangeWithCommas(min: BigDecimal, max: BigDecimal): Gen[String] = { + val numberGen = choose[BigDecimal](min, max).map(to2dp).map(_.toString) + genIntersperseString(numberGen, ",") + } + def intsBelowValue(value: Int): Gen[Int] = arbitrary[Int] suchThat (_ < value) diff --git a/test/controllers/AlcoholByVolumeQuestionControllerSpec.scala b/test/controllers/AlcoholByVolumeQuestionControllerSpec.scala new file mode 100644 index 00000000..5d128c1e --- /dev/null +++ b/test/controllers/AlcoholByVolumeQuestionControllerSpec.scala @@ -0,0 +1,164 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package controllers + +import base.SpecBase +import connectors.CacheConnector +import forms.AlcoholByVolumeQuestionFormProvider +import models.{NormalMode, UserAnswers} +import navigation.{FakeNavigator, Navigator} +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.when +import org.scalatestplus.mockito.MockitoSugar +import pages.AlcoholByVolumeQuestionPage +import play.api.inject.bind +import play.api.mvc.Call +import play.api.test.FakeRequest +import play.api.test.Helpers._ +import uk.gov.hmrc.http.HttpResponse +import views.html.AlcoholByVolumeQuestionView + +import scala.concurrent.Future + +class AlcoholByVolumeQuestionControllerSpec extends SpecBase with MockitoSugar { + + val formProvider = new AlcoholByVolumeQuestionFormProvider() + val form = formProvider() + + def onwardRoute = Call("GET", "/foo") + + val validAnswer = BigDecimal(10.23) + + lazy val alcoholByVolumeQuestionRoute = routes.AlcoholByVolumeQuestionController.onPageLoad(NormalMode).url + + "AlcoholByVolumeQuestion Controller" - { + + "must return OK and the correct view for a GET" in { + + val application = applicationBuilder(userAnswers = Some(emptyUserAnswers)).build() + + running(application) { + val request = FakeRequest(GET, alcoholByVolumeQuestionRoute) + + val result = route(application, request).value + + val view = application.injector.instanceOf[AlcoholByVolumeQuestionView] + + status(result) mustEqual OK + contentAsString(result) mustEqual view(form, NormalMode)(request, messages(application)).toString + } + } + + "must populate the view correctly on a GET when the question has previously been answered" in { + + val userAnswers = UserAnswers(userAnswersId).set(AlcoholByVolumeQuestionPage, validAnswer).success.value + + val application = applicationBuilder(userAnswers = Some(userAnswers)).build() + + running(application) { + val request = FakeRequest(GET, alcoholByVolumeQuestionRoute) + + val view = application.injector.instanceOf[AlcoholByVolumeQuestionView] + + val result = route(application, request).value + + status(result) mustEqual OK + contentAsString(result) mustEqual view(form.fill(validAnswer), NormalMode)( + request, + messages(application) + ).toString + } + } + + "must redirect to the next page when valid data is submitted" in { + + val mockCacheConnector = mock[CacheConnector] + + when(mockCacheConnector.set(any())(any())) thenReturn Future.successful(mock[HttpResponse]) + + val application = + applicationBuilder(userAnswers = Some(emptyUserAnswers)) + .overrides( + bind[Navigator].toInstance(new FakeNavigator(onwardRoute)), + bind[CacheConnector].toInstance(mockCacheConnector) + ) + .build() + + running(application) { + val request = + FakeRequest(POST, alcoholByVolumeQuestionRoute) + .withFormUrlEncodedBody(("value", validAnswer.toString)) + + val result = route(application, request).value + + status(result) mustEqual SEE_OTHER + redirectLocation(result).value mustEqual onwardRoute.url + } + } + + "must return a Bad Request and errors when invalid data is submitted" in { + + val application = applicationBuilder(userAnswers = Some(emptyUserAnswers)).build() + + running(application) { + val request = + FakeRequest(POST, alcoholByVolumeQuestionRoute) + .withFormUrlEncodedBody(("value", "invalid value")) + + val boundForm = form.bind(Map("value" -> "invalid value")) + + val view = application.injector.instanceOf[AlcoholByVolumeQuestionView] + + val result = route(application, request).value + + status(result) mustEqual BAD_REQUEST + contentAsString(result) mustEqual view(boundForm, NormalMode)(request, messages(application)).toString + } + } + + "must redirect to Journey Recovery for a GET if no existing data is found" in { + + val application = applicationBuilder(userAnswers = None).build() + + running(application) { + val request = FakeRequest(GET, alcoholByVolumeQuestionRoute) + + val result = route(application, request).value + + status(result) mustEqual SEE_OTHER + redirectLocation(result).value mustEqual routes.JourneyRecoveryController.onPageLoad().url + } + } + + "must redirect to Journey Recovery for a POST if no existing data is found" in { + + val application = applicationBuilder(userAnswers = None).build() + + running(application) { + val request = + FakeRequest(POST, alcoholByVolumeQuestionRoute) + .withFormUrlEncodedBody(("value", validAnswer.toString)) + + val result = route(application, request).value + + status(result) mustEqual SEE_OTHER + + redirectLocation(result).value mustEqual routes.JourneyRecoveryController.onPageLoad().url + } + } + } +} diff --git a/test/controllers/DraughtReliefQuestionControllerSpec.scala b/test/controllers/DraughtReliefQuestionControllerSpec.scala index 83a3931b..3893314e 100644 --- a/test/controllers/DraughtReliefQuestionControllerSpec.scala +++ b/test/controllers/DraughtReliefQuestionControllerSpec.scala @@ -124,35 +124,35 @@ class DraughtReliefQuestionControllerSpec extends SpecBase with MockitoSugar { contentAsString(result) mustEqual view(boundForm, NormalMode)(request, messages(application)).toString } } - //uncomment tests when we bring back requiredData: -// "must redirect to Journey Recovery for a GET if no existing data is found" in { -// -// val application = applicationBuilder(userAnswers = None).build() -// -// running(application) { -// val request = FakeRequest(GET, draughtReliefQuestionRoute) -// -// val result = route(application, request).value -// -// status(result) mustEqual SEE_OTHER -// redirectLocation(result).value mustEqual routes.JourneyRecoveryController.onPageLoad().url -// } -// } -// -// "must redirect to Journey Recovery for a POST if no existing data is found" in { -// -// val application = applicationBuilder(userAnswers = None).build() -// -// running(application) { -// val request = -// FakeRequest(POST, draughtReliefQuestionRoute) -// .withFormUrlEncodedBody(("value", "true")) -// -// val result = route(application, request).value -// -// status(result) mustEqual SEE_OTHER -// redirectLocation(result).value mustEqual routes.JourneyRecoveryController.onPageLoad().url -// } -// } + + "must redirect to Journey Recovery for a GET if no existing data is found" in { + + val application = applicationBuilder(userAnswers = None).build() + + running(application) { + val request = FakeRequest(GET, draughtReliefQuestionRoute) + + val result = route(application, request).value + + status(result) mustEqual SEE_OTHER + redirectLocation(result).value mustEqual routes.JourneyRecoveryController.onPageLoad().url + } + } + + "must redirect to Journey Recovery for a POST if no existing data is found" in { + + val application = applicationBuilder(userAnswers = None).build() + + running(application) { + val request = + FakeRequest(POST, draughtReliefQuestionRoute) + .withFormUrlEncodedBody(("value", "true")) + + val result = route(application, request).value + + status(result) mustEqual SEE_OTHER + redirectLocation(result).value mustEqual routes.JourneyRecoveryController.onPageLoad().url + } + } } } diff --git a/test/forms/AlcoholByVolumeQuestionFormProviderSpec.scala b/test/forms/AlcoholByVolumeQuestionFormProviderSpec.scala new file mode 100644 index 00000000..4549abcd --- /dev/null +++ b/test/forms/AlcoholByVolumeQuestionFormProviderSpec.scala @@ -0,0 +1,70 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package forms + +import forms.behaviours.BigDecimalFieldBehaviours +import play.api.data.FormError + +import scala.collection.immutable.ArraySeq + +class AlcoholByVolumeQuestionFormProviderSpec extends BigDecimalFieldBehaviours { + + val form = new AlcoholByVolumeQuestionFormProvider()() + + ".value" - { + + val fieldName = "value" + + val minimum = 0.01 + val maximum = 100.01 + + val validDataGenerator = bigDecimalsInRangeWithCommas(minimum, maximum) + + behave like fieldThatBindsValidData( + form, + fieldName, + validDataGenerator + ) + + behave like bigDecimalField( + form, + fieldName, + nonNumericError = FormError(fieldName, "alcoholByVolumeQuestion.error.nonNumeric"), + twoDecimalPlacesError = FormError(fieldName, "alcoholByVolumeQuestion.error.twoDecimalPlaces") + ) + + behave like bigDecimalFieldWithMinimum( + form, + fieldName, + minimum = minimum, + expectedError = FormError(fieldName, "alcoholByVolumeQuestion.error.minimumRequired", ArraySeq(minimum)) + ) + + behave like bigDecimalFieldWithMaximum( + form, + fieldName, + maximum = maximum, + expectedError = FormError(fieldName, "alcoholByVolumeQuestion.error.maximumRequired", ArraySeq(100)) + ) + + behave like mandatoryField( + form, + fieldName, + requiredError = FormError(fieldName, "alcoholByVolumeQuestion.error.required") + ) + } +} diff --git a/test/forms/behaviours/BigDecimalFieldBehaviours.scala b/test/forms/behaviours/BigDecimalFieldBehaviours.scala new file mode 100644 index 00000000..2eb44528 --- /dev/null +++ b/test/forms/behaviours/BigDecimalFieldBehaviours.scala @@ -0,0 +1,90 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package forms.behaviours + +import play.api.data.{Form, FormError} + +trait BigDecimalFieldBehaviours extends FieldBehaviours { + + def bigDecimalField( + form: Form[_], + fieldName: String, + nonNumericError: FormError, + twoDecimalPlacesError: FormError + ): Unit = { + + "not bind non-numeric numbers" in { + + forAll(nonNumerics -> "nonNumeric") { nonNumeric => + val result = form.bind(Map(fieldName -> nonNumeric)).apply(fieldName) + result.errors must contain only nonNumericError + } + } + + "not bind decimals that are not two decimal places" in { + + forAll(decimalsNotTwoDecimalPlaces -> "decimal") { decimal => + val result = form.bind(Map(fieldName -> decimal)).apply(fieldName) + result.errors must contain only twoDecimalPlacesError + } + } + } + + def bigDecimalFieldWithMinimum( + form: Form[_], + fieldName: String, + minimum: BigDecimal, + expectedError: FormError + ): Unit = + s"not bind big decimals below $minimum" in { + + forAll(bigDecimalsBelowValue(minimum) -> "bigDecimalBelowMin") { number: String => + val result = form.bind(Map(fieldName -> number)).apply(fieldName) + result.errors must contain only expectedError + } + } + + def bigDecimalFieldWithMaximum( + form: Form[_], + fieldName: String, + maximum: BigDecimal, + expectedError: FormError + ): Unit = + s"not bind big decimals above $maximum" in { + + forAll(bigDecimalsAboveValue(maximum) -> "bigDecimalAboveMax") { number: String => + val result = form.bind(Map(fieldName -> number)).apply(fieldName) + result.errors must contain only expectedError + } + } + + def bigDecimalFieldWithRange( + form: Form[_], + fieldName: String, + minimum: BigDecimal, + maximum: BigDecimal, + expectedError: FormError + ): Unit = + s"not bind big decimals outside the range $minimum to $maximum" in { + + forAll(bigDecimalsOutsideRange(minimum, maximum) -> "bigDecimalOutsideRange") { number => + val result = form.bind(Map(fieldName -> number.toString)).apply(fieldName) + result.errors must contain only expectedError + } + } + +} diff --git a/test/forms/mappings/MappingsSpec.scala b/test/forms/mappings/MappingsSpec.scala index 4faa8c2c..6b87f700 100644 --- a/test/forms/mappings/MappingsSpec.scala +++ b/test/forms/mappings/MappingsSpec.scala @@ -167,4 +167,52 @@ class MappingsSpec extends AnyFreeSpec with Matchers with OptionValues with Mapp result.errors must contain(FormError("value", "error.required")) } } + + "bigDecimal" - { + + val testForm: Form[BigDecimal] = + Form( + "value" -> bigDecimal() + ) + + "must bind a valid bigDecimal" - { + "without decimal points" in { + val result = testForm.bind(Map("value" -> "1")) + result.get mustEqual BigDecimal(1) + } + "with decimal points" in { + val result = testForm.bind(Map("value" -> "1.1")) + result.get mustEqual BigDecimal(1.1) + } + "with commas" in { + val result = testForm.bind(Map("value" -> "1,001.1")) + result.get mustEqual BigDecimal(1001.1) + } + "with minus" in { + val result = testForm.bind(Map("value" -> "-102.12")) + result.get mustEqual BigDecimal(-102.12) + } + } + + "must not bind an empty value" in { + val result = testForm.bind(Map("value" -> "")) + result.errors must contain(FormError("value", "error.required")) + } + + "must not bind a non numeric value" in { + val result = testForm.bind(Map("value" -> "abc")) + result.errors must contain(FormError("value", "error.nonNumeric")) + } + + "must not bind a non numeric value with multiple dots" in { + val result = testForm.bind(Map("value" -> "10.12.1")) + result.errors must contain(FormError("value", "error.nonNumeric")) + } + + "must not bind a non numeric value with 3 decimal digits" in { + val result = testForm.bind(Map("value" -> "1.349")) + result.errors must contain(FormError("value", "error.twoDecimalPlaces")) + } + + } } diff --git a/test/navigation/NavigatorSpec.scala b/test/navigation/NavigatorSpec.scala index de9280c1..20cbe290 100644 --- a/test/navigation/NavigatorSpec.scala +++ b/test/navigation/NavigatorSpec.scala @@ -35,6 +35,24 @@ class NavigatorSpec extends SpecBase { navigator.nextPage(UnknownPage, NormalMode, UserAnswers("id")) mustBe routes.IndexController.onPageLoad } + "must go from Product name page to Alcohol by volume page" in { + + navigator.nextPage( + ProductNamePage, + NormalMode, + UserAnswers("id") + ) mustBe routes.AlcoholByVolumeQuestionController.onPageLoad(NormalMode) + } + + "must go from Alcohol by volume page to Draught relief question page" in { + + navigator.nextPage( + AlcoholByVolumeQuestionPage, + NormalMode, + UserAnswers("id") + ) mustBe routes.DraughtReliefQuestionController.onPageLoad(NormalMode) + } + "must go from the Draught relief question page to Small producer relief question page" in { navigator.nextPage(