diff --git a/CLAUDE.md b/CLAUDE.md index 4e6554936a..16d2b40b16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ The goal is a full http4s migration — replace Lift Web across all version file **Key files**: `Http4s700.scala` (v7.0.0 endpoints), `Http4s200.scala` (v2.0.0 endpoints — 37 own + path-rewriting bridge to Http4s140), `Http4s140.scala` (v1.4.0 endpoints — 11 own + path-rewriting bridge to Http4s130), `Http4s130.scala` (v1.3.0 endpoints — 3 own + path-rewriting bridge to Http4s121), `Http4s121.scala` (v1.2.1 endpoints — all 323 API1_2_1Test scenarios), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `IdempotencyMiddleware.scala` (Redis-backed idempotency, opt-in via `Idempotency-Key` header, nested inside ResourceDocMiddleware), `RequestScopeConnection.scala` (DB transaction propagation to Futures). -**v7.0.0 native endpoints** (117 ResourceDocs). The authoritative list is the `resourceDocs += ResourceDoc(...)` calls in `Http4s700.scala` — read them there rather than from a copy in this file, which is what went stale before: this paragraph used to enumerate 49 names when the file held more than twice that. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. One of those 20 has since come back: `getCurrentUser` is native in v7 again, because its response gained genuinely v7-specific content (the caller's own mobile phone fields, and `on_behalf_of`). Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. +**v7.0.0 native endpoints** (117 ResourceDocs). The authoritative list is the `resourceDocs += ResourceDoc(...)` calls in `Http4s700.scala` — read them there rather than from a copy in this file, which is what went stale before: this paragraph used to enumerate 49 names when the file held more than twice that. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Two of those 20 have since come back: `getCurrentUser` is native in v7 again, because its response gained genuinely v7-specific content (the caller's own mobile phone fields, and `on_behalf_of`), and `getCustomersAtOneBank` (`Http4s700Customers.scala`), because it filters by Customer Attributes and returns each Customer's `customer_attributes`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. **Tests**: `Http4s700RoutesTest` (179 scenarios). The suite drives `Http4s700.wrappedRoutesV700Services.orNotFound` in-process; it starts no server and listens on no port. `makeHttpRequest(path, headers)` returns `(Int, JValue, Map[String, String])` and is GET-only; `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT; `makeHttpRequestWithMethod(method, path, headers)` for a verb with no body, such as DELETE. ## Migrating a Lift Endpoint to http4s diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 90157ceaf3..b47f3766bb 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -7728,6 +7728,9 @@ object Http4s700 { // Consumer account access: a view of an account granted to a Consumer, for an application acting as itself. resourceDocs ++= Http4s700ConsumerAccountAccess.resourceDocs + // Customers at a bank, each with its Customer Attributes, filtered by Customer Attributes. + resourceDocs ++= Http4s700Customers.resourceDocs + // The order in which the routes are tried. ResourceDocMiddleware selects the doc of the first // route that serves a request, so it is given this same list: the doc that validates a request // is the doc of the route that runs it. diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala new file mode 100644 index 0000000000..7577c9ea2f --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700Customers.scala @@ -0,0 +1,189 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v7_0_0 + +import code.api.Constant.ApiPathZero +import code.api.util.APIUtil.{EmptyBody, Http4sRoute, ResourceDoc, userAuthenticationMessage} +import code.api.util.ApiRole.canGetCustomersAtOneBank +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.{APIUtil, CallContext, CustomJsonFormats, NewStyle, OBPAscending, OBPLimit, OBPOffset, OBPOrdering, OBPQueryParam} +import code.util.Helper +import net.liftweb.common.{Empty, Failure, Full} +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.customerWithAttributesJsonV600 +import code.api.v6_0_0.JSONFactory600 +import code.bankconnectors.Connector +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{BankId, Customer} +import com.openbankproject.commons.util.ApiVersion +import org.http4s._ +import org.http4s.dsl.io._ +import org.json4s.Formats + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +object Http4s700Customers { + + implicit val formats: Formats = CustomJsonFormats.formats + + private val implementedInApiVersion = ApiVersion.v7_0_0 + private val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + val resourceDocs = ArrayBuffer[ResourceDoc]() + + /** The query parameters that page and sort the list. */ + val PagingParameters: List[String] = List("limit", "offset", "sort_direction") + + /** + * Query parameters that are never taken for attribute names: the paging parameters, the other + * parameters OBP-API reads on list endpoints, and names starting with obp_ or _ (cache busters). + */ + private val ReservedParameters: Set[String] = PagingParameters.toSet ++ Set("from_date", "to_date", "sort_by") + private def reserved(name: String): Boolean = { + val lower = name.toLowerCase + ReservedParameters.contains(lower) || lower.startsWith("obp_") || lower.startsWith("_") + } + + /** Customers of a page are loaded this many at a time, however large the page. */ + private val LoadBatchSize = 10 + + /** + * name -> accepted values, from the query parameters that are not reserved. A value lists several + * accepted values separated by commas (a name cannot be repeated: the middleware refuses duplicate + * query parameter names on every endpoint). A name whose values are all empty maps to Nil. + */ + def attributeFilters(query: Map[String, List[String]]): Map[String, List[String]] = + query.collect { case (name, values) if !reserved(name) => + name -> values.flatMap(_.split(",").map(_.trim)).filter(_.nonEmpty).distinct + } + + /** + * The ids of the bank's Customers that have, for every filter name, an attribute of that name with + * one of its values. Each name is asked of the connector on its own and the results are + * intersected, so the rule does not depend on how a connector combines several names. + */ + private def matchingCustomerIds(bankId: BankId, filters: Map[String, List[String]], cc: CallContext): Future[Set[String]] = + Future.sequence(filters.toList.map { case (name, values) => + NewStyle.function.getCustomerIdsByAttributeNameValues(bankId, Map(name -> values), Some(cc)).map(_._1.map(_.value).toSet) + }).map(_.reduceOption(_ intersect _).getOrElse(Set.empty[String])) + + /** + * One page of the matching Customers, ordered by customer_id (sort_direction ASC or DESC). Only the + * page is loaded. An attribute whose Customer no longer exists is skipped; a connector failure fails + * the request. + */ + private def pageOfMatchingCustomers(bankId: BankId, ids: Set[String], queryParams: List[OBPQueryParam], cc: CallContext): Future[List[Customer]] = { + val ascending = queryParams.collectFirst { case OBPOrdering(_, order) => order }.forall(_ == OBPAscending) + val ordered = if (ascending) ids.toList.sorted else ids.toList.sorted.reverse + val offset = math.max(queryParams.collectFirst { case OBPOffset(value) => value }.getOrElse(0), 0) + val limit = queryParams.collectFirst { case OBPLimit(value) => math.max(value, 0) } + val pageIds = limit.fold(ordered.drop(offset))(l => ordered.slice(offset, offset + l)) + def load(id: String): Future[Option[Customer]] = + Connector.connector.vend.getCustomerByCustomerId(id, Some(cc)).map { + case Full((customer, _)) => Some(customer) + case Empty => None + case failure: Failure => Some(APIUtil.connectorEmptyResponse[Customer](failure, Some(cc))) + } + pageIds.grouped(LoadBatchSize).foldLeft(Future.successful(List.empty[Customer])) { (loaded, batch) => + loaded.flatMap(done => Future.sequence(batch.map(load)).map(found => done ++ found.flatten)) + }.map(_.filter(_.bankId == bankId.value)) + } + + // Route: GET /obp/v7.0.0/banks/BANK_ID/customers + lazy val getCustomersAtOneBank: Http4sRoute = Http4sRoute { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val filters = attributeFilters(req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList }) + val withoutValue = filters.collect { case (name, Nil) => name }.toList.sorted + for { + _ <- Helper.booleanToFuture(s"$InvalidUrlParameters Customer Attribute filters need a value: ${withoutValue.mkString(", ")}", 400, Some(cc)) { + withoutValue.isEmpty + } + (queryParams, _) <- NewStyle.function.extractQueryParams(req.uri.renderString, PagingParameters, Some(cc)) + customers <- + if (filters.isEmpty) NewStyle.function.getCustomers(bank.bankId, Some(cc), queryParams) + else matchingCustomerIds(bank.bankId, filters, cc).flatMap(pageOfMatchingCustomers(bank.bankId, _, queryParams, cc)) + (withAttributes, _) <- + if (customers.isEmpty) Future.successful((Nil, Some(cc))) + else NewStyle.function.getCustomerAttributesForCustomers(customers, Some(cc)) + } yield CustomersWithAttributesJsonV700(withAttributes.map(ca => JSONFactory600.createCustomerWithAttributesJson(ca.customer, ca.attributes))) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getCustomersAtOneBank), + "GET", + "/banks/BANK_ID/customers", + "Get Customers at Bank", + s"""Get the Customers at the bank, each with its Customer Attributes, optionally filtered by Customer Attributes. + | + |**Filtering by Customer Attributes:** a query parameter is the name of a Customer Attribute and its value + |the attribute value a Customer must have. Several accepted values are separated by commas, so a value + |cannot itself contain a comma; different names must all match. A name can be given only once (as on every + |endpoint, a repeated query parameter name is refused), and a filter without a value is refused. + | + |Not taken for attribute names, in any letter case: limit, offset, sort_direction, from_date, to_date, + |sort_by, and names starting with obp_ or _. Apart from limit, offset and sort_direction (lower case) + |they are accepted and ignored, as in v6.0.0. + | + |A single value of a parameter whose name ends in currency or currency_code that looks like a currency + |code is upper-cased by OBP-API before it is matched, as on every endpoint; comma-separated values are + |matched as given. + | + |Examples: + | + |* `?organisation_kind=university` returns the Customers whose attribute `organisation_kind` is `university`. + |* `?organisation_kind=university,regulator` returns those whose `organisation_kind` is either. + |* `?organisation_kind=university&city=Muscat` returns those with both attributes. + | + |**Paging:** + | + |* limit: maximum number of Customers to return (default 50) + |* offset: number of Customers to skip (default 0) + |* sort_direction: ASC or DESC (default DESC) + | + |Without attribute filters the Customers at the bank are ordered by last_ok_date, as in v6.0.0. With + |attribute filters the matching Customers are ordered by customer_id, and limit and offset count the + |matching Customers only. A Customer that no longer exists but still has an attribute is left out, so + |such a page can hold fewer Customers than limit. The ids of all matching Customers are read before + |the page is chosen. + | + |Dates are returned as **YYYY-MM-DD**. + | + |${userAuthenticationMessage(true)} + |""".stripMargin, + EmptyBody, + CustomersWithAttributesJsonV700(List(customerWithAttributesJsonV600)), + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidUrlParameters, InvalidFilterParameterFormat, InvalidConnectorResponse, UnknownError), + List(apiTagCustomer, apiTagUser), + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCustomersAtOneBank) + ) +} diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Customers.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Customers.scala new file mode 100644 index 0000000000..5b7f491610 --- /dev/null +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory700Customers.scala @@ -0,0 +1,31 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v7_0_0 + +import code.api.v6_0_0.CustomerWithAttributesJsonV600 + +/** The Customers of a bank, each with its Customer Attributes. */ +case class CustomersWithAttributesJsonV700(customers: List[CustomerWithAttributesJsonV600]) diff --git a/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala b/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala new file mode 100644 index 0000000000..98527c51ce --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/CustomersAtOneBankV700Test.scala @@ -0,0 +1,209 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) +*/ +package code.api.v7_0_0 + +import java.util.Date + +import code.api.util.APIUtil.OAuth._ +import code.api.util.APIUtil.generateUUID +import code.api.util.ApiRole.CanGetCustomersAtOneBank +import code.api.util.ErrorMessages._ +import code.api.v6_0_0.V600ServerSetup +import code.customer.CustomerX +import code.customerattribute.CustomerAttributeX +import code.entitlement.Entitlement +import com.openbankproject.commons.model.enums.CustomerAttributeType +import com.openbankproject.commons.model.{BankId, CustomerFaceImage, CustomerId, ErrorMessage} +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class CustomersAtOneBankV700Test extends V600ServerSetup { + + def v7 = baseRequest / "obp" / "v7.0.0" + object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString) + object CustomersAtOneBank extends Tag("getCustomersAtOneBank") + + private lazy val bankId = testBankId1.value + + private def withRole[T](f: => T): T = { + val grant = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, CanGetCustomersAtOneBank.toString) + try f finally Entitlement.entitlement.vend.deleteEntitlement(grant) + } + + /** A fresh Customer of the test bank with the given attributes; returns its id. */ + private def customer(attributes: (String, String)*): String = customerAt(bankId, attributes: _*) + + private def customerAt(bank: String, attributes: (String, String)*): String = { + val created = CustomerX.customerProvider.vend.addCustomer( + BankId(bank), generateUUID().take(12), s"Organisation ${generateUUID().take(6)}", "+968 1234 5678", "org@example.com", + CustomerFaceImage(new Date(), "http://example.com/face.png"), + new Date(), "single", 0, Nil, "Bachelor", "employed", false, new Date(), + None, None, "", "", "", "CORPORATE", "" + ).openOrThrowException("expected the customer to be created") + attributes.foreach { case (name, value) => + Await.result(CustomerAttributeX.customerAttributeProvider.vend.createOrUpdateCustomerAttribute( + BankId(bank), CustomerId(created.customerId), None, name, CustomerAttributeType.STRING, value), 10.seconds) + .openOrThrowException("expected the attribute to be created") + } + created.customerId + } + + /** Query parameters go into the URL; a name may repeat. */ + private def get(query: (String, String)*) = { + val request = query.foldLeft((v7 / "banks" / bankId / "customers").GET) { case (r, (k, v)) => r.addQueryParameter(k, v) } + makeGetRequest(request <@ (user1)) + } + + private def ids(query: (String, String)*): List[String] = { + val response = withRole { get(query: _*) } + withClue(response.body) { response.code should equal(200) } + response.body.extract[CustomersWithAttributesJsonV700].customers.map(_.customer_id) + } + + feature(s"Get Customers at Bank, filtered by Customer Attributes - GET /banks/BANK_ID/customers - $VersionOfApi") { + + scenario("Customers of another bank are never listed, and the Role is needed at this bank", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val here = customer("organisation_kind" -> s"university-$tag") + customerAt(testBankId2.value, "organisation_kind" -> s"university-$tag") + ids("organisation_kind" -> s"university-$tag") should equal(List(here)) + + val grantAtOtherBank = Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, CanGetCustomersAtOneBank.toString) + val response = try get("organisation_kind" -> s"university-$tag") finally Entitlement.entitlement.vend.deleteEntitlement(grantAtOtherBank) + response.code should equal(403) + } + + scenario("An attribute parameter without a value is refused", CustomersAtOneBank, VersionOfApi) { + Seq("", ",", " , ").foreach { value => + val response = withRole { get("organisation_kind" -> value) } + withClue(s"value '$value': ${response.body}") { response.code should equal(400) } + response.body.extract[ErrorMessage].message should include(InvalidUrlParameters) + } + } + + scenario("Other OBP query parameters are not taken for attribute names", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val matching = customer("organisation_kind" -> s"bank-$tag") + ids("organisation_kind" -> s"bank-$tag", "from_date" -> "2020-01-01T00:00:00.000Z", "to_date" -> "2099-01-01T00:00:00.000Z", + "sort_by" -> "name", "obp_limit" -> "10", "_" -> "123") should equal(List(matching)) + } + + scenario("sort_direction orders the matching Customers by customer_id, DESC by default, and pages follow that order", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val created = (1 to 3).map(_ => customer("cohort" -> s"s-$tag")).toList.sorted + ids("cohort" -> s"s-$tag", "sort_direction" -> "ASC") should equal(created) + ids("cohort" -> s"s-$tag", "sort_direction" -> "DESC") should equal(created.reverse) + ids("cohort" -> s"s-$tag") should equal(created.reverse) + ids("cohort" -> s"s-$tag", "sort_direction" -> "ASC", "limit" -> "2", "offset" -> "0") should equal(created.take(2)) + ids("cohort" -> s"s-$tag", "sort_direction" -> "ASC", "limit" -> "2", "offset" -> "2") should equal(created.drop(2)) + } + + scenario("Reserved names in another case are not taken for attribute names, and invalid paging is refused", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val created = (1 to 2).map(_ => customer("cohort" -> s"c-$tag")).toList + // As in v6.0.0 only lower-case paging names page the list; LIMIT is ignored, not an attribute filter. + ids("cohort" -> s"c-$tag", "LIMIT" -> "1", "Sort_Direction" -> "ASC").toSet should equal(created.toSet) + Seq("limit" -> "abc", "offset" -> "-1", "sort_direction" -> "sideways").foreach { bad => + val response = withRole { get("cohort" -> s"c-$tag", bad) } + withClue(s"$bad: ${response.body}") { response.code should equal(400) } + } + } + + scenario("An attribute whose Customer no longer exists is skipped", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val existing = customer("cohort" -> s"d-$tag") + Await.result(CustomerAttributeX.customerAttributeProvider.vend.createOrUpdateCustomerAttribute( + BankId(bankId), CustomerId(generateUUID()), None, "cohort", CustomerAttributeType.STRING, s"d-$tag"), 10.seconds) + .openOrThrowException("expected the attribute to be created") + ids("cohort" -> s"d-$tag") should equal(List(existing)) + } + + scenario("Without attribute parameters limit and offset still page the list", CustomersAtOneBank, VersionOfApi) { + customer("cohort" -> s"p-${generateUUID().take(6)}") + customer("cohort" -> s"p-${generateUUID().take(6)}") + ids("limit" -> "1") should have size 1 + ids("limit" -> "1", "offset" -> "1") should have size 1 + } + + scenario("It needs the Role", CustomersAtOneBank, VersionOfApi) { + val response = get() + response.code should equal(403) + response.body.extract[ErrorMessage].message should include(UserHasMissingRoles) + } + + scenario("Without attribute parameters it lists the bank's Customers with their attributes", CustomersAtOneBank, VersionOfApi) { + val kind = s"university-${generateUUID().take(6)}" + val customerId = customer("organisation_kind" -> kind) + val response = withRole { get("limit" -> "1000") } + withClue(response.body) { response.code should equal(200) } + And("it is served by v7.0.0 itself, not passed on to v6.0.0") + response.headers.flatMap(h => Option(h.get("X-OBP-Version-Served"))) should equal(None) + val listed = response.body.extract[CustomersWithAttributesJsonV700].customers.find(_.customer_id == customerId) + listed.map(_.customer_attributes.map(a => a.name -> a.value)) should equal(Some(List("organisation_kind" -> kind))) + } + + scenario("An attribute parameter returns only the Customers that have that value", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val university = customer("organisation_kind" -> s"university-$tag") + customer("organisation_kind" -> s"regulator-$tag") + customer() + ids("organisation_kind" -> s"university-$tag") should equal(List(university)) + ids("organisation_kind" -> s"nothing-$tag") should equal(Nil) + } + + scenario("Comma-separated values match any of them, different names must all match", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val university = customer("organisation_kind" -> s"university-$tag", "city" -> s"muscat-$tag") + val regulator = customer("organisation_kind" -> s"regulator-$tag", "city" -> s"sohar-$tag") + customer("organisation_kind" -> s"partner-$tag", "city" -> s"muscat-$tag") + + ids("organisation_kind" -> s"university-$tag,regulator-$tag").toSet should equal(Set(university, regulator)) + ids("organisation_kind" -> s"university-$tag", "city" -> s"muscat-$tag") should equal(List(university)) + ids("organisation_kind" -> s"regulator-$tag", "city" -> s"muscat-$tag") should equal(Nil) + } + + scenario("A query parameter name given twice is refused, as on every endpoint", CustomersAtOneBank, VersionOfApi) { + val response = withRole { get("organisation_kind" -> "university", "organisation_kind" -> "regulator") } + response.code should equal(400) + response.body.extract[ErrorMessage].message should include(DuplicateQueryParameters) + } + + scenario("limit and offset page through the matching Customers only", CustomersAtOneBank, VersionOfApi) { + val tag = generateUUID().take(6) + val matching = Set(customer("cohort" -> s"c-$tag"), customer("cohort" -> s"c-$tag")) + customer("cohort" -> s"other-$tag") + val first = ids("cohort" -> s"c-$tag", "limit" -> "1", "offset" -> "0") + val second = ids("cohort" -> s"c-$tag", "limit" -> "1", "offset" -> "1") + first.size should equal(1) + second.size should equal(1) + (first ++ second).toSet should equal(matching) + ids("cohort" -> s"c-$tag", "limit" -> "1", "offset" -> "2") should equal(Nil) + } + } +}