commit 4efa3d8bbf51ba479fc99cdb25aaa0f82cf0da7b Author: Jérémy Munsch Date: Sun Nov 1 14:50:34 2026 +0100 feat: initial commit - Z3 backend (Clean Architecture / DDD) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..87482e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +#* +#!target/*-runner +#!target/*-runner.jar +#!target/lib/* +#!target/quarkus-app/* \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b27a0cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +#Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +release.properties +.flattened-pom.xml + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ + +# KEYS +*.pem diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..6bd0ec6 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,56 @@ +image: quay.io/buildah/stable + +stages: + - test + - release + - build + +branch-release: + image: docker.io/bitnami/git + variables: + GIT_STRATEGY: clone + stage: release + script: + # create branch ; if the tag is v2024.01, the branch will be rel/2024.01 (without the v) + - git switch -c rel/${CI_COMMIT_TAG:1} + - git push origin rel/${CI_COMMIT_TAG:1} + rules: + # only for tags like v2024.01, v2024.01.01, v2024.01-1, v2024.01.01-1 + - if: $CI_COMMIT_TAG =~ /^v\d{4}\.\d{2}(\.\d{2})?(-\d+)?$/ + +build-staging: + stage: build + variables: + IMAGE_TAG: $CI_REGISTRY_IMAGE:staging + before_script: + - buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + script: + - buildah bud -t "$IMAGE_TAG" --build-arg GITLAB_MVN_TOKEN=$CI_JOB_TOKEN . + - buildah push "$IMAGE_TAG" + only: + - main + +build-feat: + stage: build + before_script: + # CI_COMMIT_BRANCH without "feat/" prefix + - export IMAGE_TAG=$CI_REGISTRY_IMAGE:${CI_COMMIT_BRANCH/feat\//feat-} + - buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + script: + - buildah bud -t "$IMAGE_TAG" --build-arg GITLAB_MVN_TOKEN=$CI_JOB_TOKEN . + - buildah push "$IMAGE_TAG" + rules: + - when: manual + if: '$CI_COMMIT_BRANCH =~ /^feat\//' + +build-release: + stage: build + variables: + IMAGE_TAG: $CI_REGISTRY_IMAGE:latest + before_script: + - buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + script: + - buildah bud -t "$IMAGE_TAG" --build-arg GITLAB_MVN_TOKEN=$CI_JOB_TOKEN . + - buildah push "$IMAGE_TAG" + only: + - /^rel\// diff --git a/.mvn/wrapper/.gitignore b/.mvn/wrapper/.gitignore new file mode 100644 index 0000000..e72f5e8 --- /dev/null +++ b/.mvn/wrapper/.gitignore @@ -0,0 +1 @@ +maven-wrapper.jar diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000..84d1e60 --- /dev/null +++ b/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 java.io.IOException; +import java.io.InputStream; +import java.net.Authenticator; +import java.net.PasswordAuthentication; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; + +public final class MavenWrapperDownloader +{ + private static final String WRAPPER_VERSION = "3.2.0"; + + private static final boolean VERBOSE = Boolean.parseBoolean( System.getenv( "MVNW_VERBOSE" ) ); + + public static void main( String[] args ) + { + log( "Apache Maven Wrapper Downloader " + WRAPPER_VERSION ); + + if ( args.length != 2 ) + { + System.err.println( " - ERROR wrapperUrl or wrapperJarPath parameter missing" ); + System.exit( 1 ); + } + + try + { + log( " - Downloader started" ); + final URL wrapperUrl = new URL( args[0] ); + final String jarPath = args[1].replace( "..", "" ); // Sanitize path + final Path wrapperJarPath = Paths.get( jarPath ).toAbsolutePath().normalize(); + downloadFileFromURL( wrapperUrl, wrapperJarPath ); + log( "Done" ); + } + catch ( IOException e ) + { + System.err.println( "- Error downloading: " + e.getMessage() ); + if ( VERBOSE ) + { + e.printStackTrace(); + } + System.exit( 1 ); + } + } + + private static void downloadFileFromURL( URL wrapperUrl, Path wrapperJarPath ) + throws IOException + { + log( " - Downloading to: " + wrapperJarPath ); + if ( System.getenv( "MVNW_USERNAME" ) != null && System.getenv( "MVNW_PASSWORD" ) != null ) + { + final String username = System.getenv( "MVNW_USERNAME" ); + final char[] password = System.getenv( "MVNW_PASSWORD" ).toCharArray(); + Authenticator.setDefault( new Authenticator() + { + @Override + protected PasswordAuthentication getPasswordAuthentication() + { + return new PasswordAuthentication( username, password ); + } + } ); + } + try ( InputStream inStream = wrapperUrl.openStream() ) + { + Files.copy( inStream, wrapperJarPath, StandardCopyOption.REPLACE_EXISTING ); + } + log( " - Downloader complete" ); + } + + private static void log( String msg ) + { + if ( VERBOSE ) + { + System.out.println( msg ); + } + } + +} diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..11f5868 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0340f3c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,245 @@ +## (2025-03-27) +## 1.1.0 (2025-03-27) + +### Features + +* **global:** Add some statistics resources to get some graphics ([8f12c0f](https://gitlab.pegase.tech/z3/z3-backend/commit/8f12c0f1947ff9c61e1dbe9fe608d4e6b03c8f21)) +* **global:** Add statistics domain and simplify UUID handling ([377249d](https://gitlab.pegase.tech/z3/z3-backend/commit/377249d5de7f3ec788c3372b661dbb61d638a2b9)) + +### Bug Fixes + +* **domain:** Allow Referer path to be empty ([1884049](https://gitlab.pegase.tech/z3/z3-backend/commit/188404953b2171df239c0a10b775bae79bda8919)) +* **domain:** DomainNameService could not reliably allow assign a fqdn to a brand that already owns it ([07e02be](https://gitlab.pegase.tech/z3/z3-backend/commit/07e02be02d2c025148726a0e44e92e383b707fa8)), closes [#13](https://gitlab.pegase.tech/z3/z3-backend/issues/13) +* **domain:** Fix CreateFingerPrintTest ([bce237d](https://gitlab.pegase.tech/z3/z3-backend/commit/bce237d76428f7498a53b33c396067fe43277572)) +* **domain:** Link Statistic for visits was no correctly using nullable ([7e885a4](https://gitlab.pegase.tech/z3/z3-backend/commit/7e885a4558390b565f9ae406dbd956b68a5f3868)) +* **infrastructure:** GeoIpServiceImpl was not loading correctly resources files ([e918708](https://gitlab.pegase.tech/z3/z3-backend/commit/e918708cb995b54aac3f4db800ad625017c55d4f)) +* **infrastructure:** UserAgentAnalyser was not loading correctly yml rule files ([190dc6e](https://gitlab.pegase.tech/z3/z3-backend/commit/190dc6e790cdf1e34364cdd75fc3a1df10ff0e87)) +## 1.0.5 (2025-03-05) + +### Features + +* **global:** Allow brands to have an associated list of Fqdn ([fe7708d](https://gitlab.pegase.tech/z3/z3-backend/commit/fe7708da0baf8954ab4a0703a6d3b5f143fb9aa2)) +* **global:** Link Export now uses brand first available fqdn or the resolver fqdn ([011c261](https://gitlab.pegase.tech/z3/z3-backend/commit/011c2613b3ceb604df8b69a42629d87459de1440)) +* **global:** Link resolve change of behavior and using `host` parameter ([d56b0a6](https://gitlab.pegase.tech/z3/z3-backend/commit/d56b0a6043dbcd94e4bb9091093809c59c4ac329)) + +### Bug Fixes + +* **application:** Craete and update brand properly validates fqdns ([93492f9](https://gitlab.pegase.tech/z3/z3-backend/commit/93492f96e30c8bf7c4d27d3c9d6f912bdf770dc4)) +* **application:** Fix create brand request validation ([639c577](https://gitlab.pegase.tech/z3/z3-backend/commit/639c5774c6eae62b98293d5c8a27da080eddb571)) +* **application:** Fixes [#4](https://gitlab.pegase.tech/z3/z3-backend/issues/4) choosing tag while importing links ([ad37d91](https://gitlab.pegase.tech/z3/z3-backend/commit/ad37d91fbf4bf53f8677334a0389e67d251f8227)) +* **application:** Handle Unicode BOM on csv file import ([214c827](https://gitlab.pegase.tech/z3/z3-backend/commit/214c827c39b11a4e5e5b28205605fac1e20b5cb2)) +* **application:** RedirectLinkOutputTest was wrong after rebase with main ([22d74c1](https://gitlab.pegase.tech/z3/z3-backend/commit/22d74c19fced37424c21c437d75f636f8bd341f2)) +* **domain:** Closes [#7](https://gitlab.pegase.tech/z3/z3-backend/issues/7) ([0300d7f](https://gitlab.pegase.tech/z3/z3-backend/commit/0300d7f5fcc3e3aee7fc7d79c19b2fb6f6aefac2)) +* **domain:** Correct Validation error field name on Create and Update brand ([96ca5f0](https://gitlab.pegase.tech/z3/z3-backend/commit/96ca5f0c3b066852f7ad38f72be6f44f1a46f2c6)) +* **domain:** Prevent link upsert on other brands while importing ([a81ffde](https://gitlab.pegase.tech/z3/z3-backend/commit/a81ffdeeafa527a64a805250a3ecd0137ccdff7c)) +* **infrastructure:** LinkRepository could output null for count queries ([9ed8300](https://gitlab.pegase.tech/z3/z3-backend/commit/9ed8300ede944574d96f91d835b07f6bdb20389e)) +* **infrastructure:** ReportingSubscriptionDatabaseRepository fix querying by userUuid ([82c24bf](https://gitlab.pegase.tech/z3/z3-backend/commit/82c24bf5acb24acf936e3bd484b6fa4e2e831ced)) +* **infrastructure:** Simplify repositories cache usage ([f939928](https://gitlab.pegase.tech/z3/z3-backend/commit/f939928d812ff9b3c9d0b4f0b50b03968683ab82)) +## 1.0.3 (2025-01-09) + +### ⚠ BREAKING CHANGES + +* **global:** SendMessage can now output it success status + +### Features + +* **global:** Add Reporting Subscriptions for users ([da78839](https://gitlab.pegase.tech/z3/z3-backend/commit/da78839458761d5be909e71785c0af9a2810fe72)) +* **global:** Send scheduled invalid links reports ([fc059b5](https://gitlab.pegase.tech/z3/z3-backend/commit/fc059b576506084fdebf17f2f95bcc7666e4cef3)) + +### Bug Fixes + +* **cli:** Fix Hibernate warning on compilation ([0b8dec5](https://gitlab.pegase.tech/z3/z3-backend/commit/0b8dec52decba550bc059a8ce68e9dd645c43971)) +* **domain:** GetGlobalStatistics count is wrong + add more tests ([8df73ea](https://gitlab.pegase.tech/z3/z3-backend/commit/8df73ea5ea75423dbca7b3346408ef5f2bfe263c)) +* **domain:** Remove lang3 dep in SendReports use case ([60d3e72](https://gitlab.pegase.tech/z3/z3-backend/commit/60d3e72d242069eeb1fd325775a074fd29e28876)) +* **domain:** When Link is updated it now changes its updatedAt timestamp ([8eeb8d9](https://gitlab.pegase.tech/z3/z3-backend/commit/8eeb8d934da239ac0aeb5a2e863798012eeab85b)) +* **global:** GetLinksStatistics regression fix ([e9cc5df](https://gitlab.pegase.tech/z3/z3-backend/commit/e9cc5dfd6881ae853d9e2b80b298b09108ccf67c)) +* **global:** Use Flyway to apply a migration with seeded data ([be00a1a](https://gitlab.pegase.tech/z3/z3-backend/commit/be00a1a5807adf9445574d9083bda91423b8baba)) +* **global:** Use ZonedDateTime as value-object and return them from presenter for Links usecases ([d0ee9a6](https://gitlab.pegase.tech/z3/z3-backend/commit/d0ee9a673cc592c63f950dc3b1cc523cda2893c7)) + +### Code Refactoring + +* **global:** SendMessage can now output it success status ([6b68299](https://gitlab.pegase.tech/z3/z3-backend/commit/6b68299c853021112f68fa016cf7e99a883d3a42)) +## 1.0.2 (2024-12-10) + +### Features + +* **application:** Ajout d'un message sur une demande de redirection d'un lien invalide ([4c28c8f](https://gitlab.pegase.tech/z3/z3-backend/commit/4c28c8f67c08c68f727c222b933451d8aea8b01f)) +* **domain:** Allow to put any text on source and slugify it on import ([a9f69b7](https://gitlab.pegase.tech/z3/z3-backend/commit/a9f69b75ebc21a5853584bc358b830d305a77cc2)) +* **global:** Allow to set tag while importing links ([3656677](https://gitlab.pegase.tech/z3/z3-backend/commit/3656677833632159dfc284904c5b107b8176b609)) + +### Bug Fixes + +* **application:** Add no-cache header for public redirection ([20c85f7](https://gitlab.pegase.tech/z3/z3-backend/commit/20c85f7c1f563c4448176b736c25262727193372)) +* **application:** Correction of import for user requests ([9942f65](https://gitlab.pegase.tech/z3/z3-backend/commit/9942f658baf69c75362c4085837863d3621f90b3)) +* **application:** Slugify source on create link endpoint ([3e760a3](https://gitlab.pegase.tech/z3/z3-backend/commit/3e760a321dc79e809706252d5992df63542bfdfb)) +* **domain:** Import links for catalog was not setting correct brand ([02d6507](https://gitlab.pegase.tech/z3/z3-backend/commit/02d650728ad394c0df86993ef6f59051cf1436d5)) +* **domain:** Import links was returning the wrong imported links number ([9ec5c2d](https://gitlab.pegase.tech/z3/z3-backend/commit/9ec5c2dc377ebf859e8c1b8d8579501b8ec93cc2)) +* **domain:** Reset user password was not persisting the updated credential ([5e811bd](https://gitlab.pegase.tech/z3/z3-backend/commit/5e811bd3c428054fa00dc5dfb300b16900b74d19)) +* **infrastructure:** Slugifyer now replaces undescores ([34ab963](https://gitlab.pegase.tech/z3/z3-backend/commit/34ab963a68464be0ff0257b5fec91154b0d229c5)) +## 1.0.1 (2024-12-08) + +### ⚠ BREAKING CHANGES + +* **global:** Allow to assign some brands to any user + +### Features + +* **global:** Allow to assign some brands to any user ([6e820e7](https://gitlab.pegase.tech/z3/z3-backend/commit/6e820e772e690e9f129ce55b63fb377da9c4d1fb)) +* **global:** Change user password ([acb99eb](https://gitlab.pegase.tech/z3/z3-backend/commit/acb99ebcec6a6de5480a3ef3f57ffa274192339d)) +* **global:** Complete Global statistics feature ([0f5074d](https://gitlab.pegase.tech/z3/z3-backend/commit/0f5074da602002af7988656781afa302c3cdd711)) +* **global:** Get statistics for Brand, Catalog and Tag ([26db02b](https://gitlab.pegase.tech/z3/z3-backend/commit/26db02b5df14151972fbfab8c73f6072267b5df6)) +* **global:** Reset user password feature ([a59ae02](https://gitlab.pegase.tech/z3/z3-backend/commit/a59ae027ff944c182f168bdf89406f27a8d39091)) + +### Bug Fixes + +* **application:** Add not null to create and update users requests ([a35dfe4](https://gitlab.pegase.tech/z3/z3-backend/commit/a35dfe40430a889361118049b1fcea882e05d5df)) +* **application:** Allow admin users to update users ([13f141c](https://gitlab.pegase.tech/z3/z3-backend/commit/13f141c24e6512d79ab6250a59f6341fee2c7c5f)) +* **application:** Remove Role check on get brand resource ([492d842](https://gitlab.pegase.tech/z3/z3-backend/commit/492d842664842e1baa477e2fbbf75e1f228b06bd)) +## 0.0.1 (2024-09-10) +## (2025-03-27) + +### Features + +* **global:** Add some statistics resources to get some graphics ([8f12c0f](https://gitlab.pegase.tech/z3/z3-backend/commit/8f12c0f1947ff9c61e1dbe9fe608d4e6b03c8f21)) +* **global:** Add statistics domain and simplify UUID handling ([377249d](https://gitlab.pegase.tech/z3/z3-backend/commit/377249d5de7f3ec788c3372b661dbb61d638a2b9)) + +### Bug Fixes + +* **domain:** Allow Referer path to be empty ([1884049](https://gitlab.pegase.tech/z3/z3-backend/commit/188404953b2171df239c0a10b775bae79bda8919)) +* **domain:** Link Statistic for visits was no correctly using nullable ([7e885a4](https://gitlab.pegase.tech/z3/z3-backend/commit/7e885a4558390b565f9ae406dbd956b68a5f3868)) +## (2025-03-05) + +## [1.0.5](https://gitlab.pegase.tech/z3/z3-backend/compare/1.0.4...1.0.5) (2025-02-04) + +### Features + +* **global:** Allow brands to have an associated list of + Fqdn ([fe7708d](https://gitlab.pegase.tech/z3/z3-backend/commit/fe7708da0baf8954ab4a0703a6d3b5f143fb9aa2)) +* **global:** Link Export now uses brand first available fqdn or the resolver + fqdn ([011c261](https://gitlab.pegase.tech/z3/z3-backend/commit/011c2613b3ceb604df8b69a42629d87459de1440)) +* **global:** Link resolve change of behavior and using `host` + parameter ([d56b0a6](https://gitlab.pegase.tech/z3/z3-backend/commit/d56b0a6043dbcd94e4bb9091093809c59c4ac329)) + +### Bug Fixes + +* **application:** Craete and update brand properly validates + fqdns ([93492f9](https://gitlab.pegase.tech/z3/z3-backend/commit/93492f96e30c8bf7c4d27d3c9d6f912bdf770dc4)) +* **application:** RedirectLinkOutputTest was wrong after rebase with + main ([22d74c1](https://gitlab.pegase.tech/z3/z3-backend/commit/22d74c19fced37424c21c437d75f636f8bd341f2)) +* **domain:** Correct Validation error field name on Create and Update + brand ([96ca5f0](https://gitlab.pegase.tech/z3/z3-backend/commit/96ca5f0c3b066852f7ad38f72be6f44f1a46f2c6)) + +## (2025-03-05) + +## [1.0.4](https://gitlab.pegase.tech/z3/z3-backend/compare/1.0.3...1.0.4) (2025-02-04) + +### Bug Fixes + +* **application:** Fix create brand request + validation ([639c577](https://gitlab.pegase.tech/z3/z3-backend/commit/639c5774c6eae62b98293d5c8a27da080eddb571)) +* **application:** Fixes [#4](https://gitlab.pegase.tech/z3/z3-backend/issues/4) choosing tag while + importing + links ([ad37d91](https://gitlab.pegase.tech/z3/z3-backend/commit/ad37d91fbf4bf53f8677334a0389e67d251f8227)) +* **application:** Handle Unicode BOM on csv file + import ([214c827](https://gitlab.pegase.tech/z3/z3-backend/commit/214c827c39b11a4e5e5b28205605fac1e20b5cb2)) +* **domain:** + Closes [#7](https://gitlab.pegase.tech/z3/z3-backend/issues/7) ([0300d7f](https://gitlab.pegase.tech/z3/z3-backend/commit/0300d7f5fcc3e3aee7fc7d79c19b2fb6f6aefac2)) +* **domain:** Prevent link upsert on other brands while + importing ([a81ffde](https://gitlab.pegase.tech/z3/z3-backend/commit/a81ffdeeafa527a64a805250a3ecd0137ccdff7c)) +* **infrastructure:** LinkRepository could output null for count + queries ([9ed8300](https://gitlab.pegase.tech/z3/z3-backend/commit/9ed8300ede944574d96f91d835b07f6bdb20389e)) +* **infrastructure:** ReportingSubscriptionDatabaseRepository fix querying by + userUuid ([82c24bf](https://gitlab.pegase.tech/z3/z3-backend/commit/82c24bf5acb24acf936e3bd484b6fa4e2e831ced)) +* **infrastructure:** Simplify repositories cache + usage ([f939928](https://gitlab.pegase.tech/z3/z3-backend/commit/f939928d812ff9b3c9d0b4f0b50b03968683ab82)) + +## [1.0.3](https://gitlab.pegase.tech/z3/z3-backend/compare/1.0.2...1.0.3) (2025-01-09) + +### ⚠ BREAKING CHANGES + +* **global:** SendMessage can now output it success status + +### Features + +* **global:** Add Reporting Subscriptions for + users ([da78839](https://gitlab.pegase.tech/z3/z3-backend/commit/da78839458761d5be909e71785c0af9a2810fe72)) +* **global:** Send scheduled invalid links + reports ([fc059b5](https://gitlab.pegase.tech/z3/z3-backend/commit/fc059b576506084fdebf17f2f95bcc7666e4cef3)) + +### Bug Fixes + +* **cli:** Fix Hibernate warning on + compilation ([0b8dec5](https://gitlab.pegase.tech/z3/z3-backend/commit/0b8dec52decba550bc059a8ce68e9dd645c43971)) +* **domain:** GetGlobalStatistics count is wrong + add more + tests ([8df73ea](https://gitlab.pegase.tech/z3/z3-backend/commit/8df73ea5ea75423dbca7b3346408ef5f2bfe263c)) +* **domain:** Remove lang3 dep in SendReports use + case ([60d3e72](https://gitlab.pegase.tech/z3/z3-backend/commit/60d3e72d242069eeb1fd325775a074fd29e28876)) +* **domain:** When Link is updated it now changes its updatedAt + timestamp ([8eeb8d9](https://gitlab.pegase.tech/z3/z3-backend/commit/8eeb8d934da239ac0aeb5a2e863798012eeab85b)) +* **global:** GetLinksStatistics regression + fix ([e9cc5df](https://gitlab.pegase.tech/z3/z3-backend/commit/e9cc5dfd6881ae853d9e2b80b298b09108ccf67c)) +* **global:** Use Flyway to apply a migration with seeded + data ([be00a1a](https://gitlab.pegase.tech/z3/z3-backend/commit/be00a1a5807adf9445574d9083bda91423b8baba)) +* **global:** Use ZonedDateTime as value-object and return them from presenter for Links + usecases ([d0ee9a6](https://gitlab.pegase.tech/z3/z3-backend/commit/d0ee9a673cc592c63f950dc3b1cc523cda2893c7)) + +### Code Refactoring + +* **global:** SendMessage can now output it success + status ([6b68299](https://gitlab.pegase.tech/z3/z3-backend/commit/6b68299c853021112f68fa016cf7e99a883d3a42)) + +## [1.0.2](https://gitlab.pegase.tech/z3/z3-backend/compare/1.0.1...1.0.2) (2024-12-10) + +### Features + +* **application:** Ajout d'un message sur une demande de redirection d'un lien + invalide ([4c28c8f](https://gitlab.pegase.tech/z3/z3-backend/commit/4c28c8f67c08c68f727c222b933451d8aea8b01f)) +* **domain:** Allow to put any text on source and slugify it on + import ([a9f69b7](https://gitlab.pegase.tech/z3/z3-backend/commit/a9f69b75ebc21a5853584bc358b830d305a77cc2)) +* **global:** Allow to set tag while importing + links ([3656677](https://gitlab.pegase.tech/z3/z3-backend/commit/3656677833632159dfc284904c5b107b8176b609)) + +### Bug Fixes + +* **application:** Add no-cache header for public + redirection ([20c85f7](https://gitlab.pegase.tech/z3/z3-backend/commit/20c85f7c1f563c4448176b736c25262727193372)) +* **application:** Correction of import for user + requests ([9942f65](https://gitlab.pegase.tech/z3/z3-backend/commit/9942f658baf69c75362c4085837863d3621f90b3)) +* **application:** Slugify source on create link + endpoint ([3e760a3](https://gitlab.pegase.tech/z3/z3-backend/commit/3e760a321dc79e809706252d5992df63542bfdfb)) +* **domain:** Import links for catalog was not setting correct + brand ([02d6507](https://gitlab.pegase.tech/z3/z3-backend/commit/02d650728ad394c0df86993ef6f59051cf1436d5)) +* **domain:** Import links was returning the wrong imported links + number ([9ec5c2d](https://gitlab.pegase.tech/z3/z3-backend/commit/9ec5c2dc377ebf859e8c1b8d8579501b8ec93cc2)) +* **domain:** Reset user password was not persisting the updated + credential ([5e811bd](https://gitlab.pegase.tech/z3/z3-backend/commit/5e811bd3c428054fa00dc5dfb300b16900b74d19)) +* **infrastructure:** Slugifyer now replaces + undescores ([34ab963](https://gitlab.pegase.tech/z3/z3-backend/commit/34ab963a68464be0ff0257b5fec91154b0d229c5)) + +## [1.0.1](https://gitlab.pegase.tech/z3/z3-backend/compare/a59ae027ff944c182f168bdf89406f27a8d39091...1.0.1) (2024-12-08) + +### ⚠ BREAKING CHANGES + +* **global:** Allow to assign some brands to any user + +### Features + +* **global:** Allow to assign some brands to any + user ([6e820e7](https://gitlab.pegase.tech/z3/z3-backend/commit/6e820e772e690e9f129ce55b63fb377da9c4d1fb)) +* **global:** Change user + password ([acb99eb](https://gitlab.pegase.tech/z3/z3-backend/commit/acb99ebcec6a6de5480a3ef3f57ffa274192339d)) +* **global:** Complete Global statistics + feature ([0f5074d](https://gitlab.pegase.tech/z3/z3-backend/commit/0f5074da602002af7988656781afa302c3cdd711)) +* **global:** Get statistics for Brand, Catalog and + Tag ([26db02b](https://gitlab.pegase.tech/z3/z3-backend/commit/26db02b5df14151972fbfab8c73f6072267b5df6)) +* **global:** Reset user password + feature ([a59ae02](https://gitlab.pegase.tech/z3/z3-backend/commit/a59ae027ff944c182f168bdf89406f27a8d39091)) + +### Bug Fixes + +* **application:** Add not null to create and update users + requests ([a35dfe4](https://gitlab.pegase.tech/z3/z3-backend/commit/a35dfe40430a889361118049b1fcea882e05d5df)) +* **application:** Allow admin users to update + users ([13f141c](https://gitlab.pegase.tech/z3/z3-backend/commit/13f141c24e6512d79ab6250a59f6341fee2c7c5f)) +* **application:** Remove Role check on get brand + resource ([492d842](https://gitlab.pegase.tech/z3/z3-backend/commit/492d842664842e1baa477e2fbbf75e1f228b06bd)) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cae3722 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,64 @@ +FROM quay.io/quarkus/ubi-quarkus-mandrel-builder-image:jdk-21 AS build + +ARG GITLAB_MVN_TOKEN +COPY < + + + gitlab-maven + + + + Job-Token + $GITLAB_MVN_TOKEN + + + + + + +EOF + +COPY --chown=quarkus:quarkus config /code/config + +COPY --chown=quarkus:quarkus mvnw /code/mvnw +COPY --chown=quarkus:quarkus .mvn /code/.mvn +COPY --chown=quarkus:quarkus pom.xml /code/pom.xml + +COPY --chown=quarkus:quarkus z3-application/pom.xml /code/z3-application/pom.xml +COPY --chown=quarkus:quarkus z3-application/src /code/z3-application/src + +COPY --chown=quarkus:quarkus z3-cli/pom.xml /code/z3-cli/pom.xml +COPY --chown=quarkus:quarkus z3-cli/src /code/z3-cli/src + +COPY --chown=quarkus:quarkus z3-domain/pom.xml /code/z3-domain/pom.xml +COPY --chown=quarkus:quarkus z3-domain/src /code/z3-domain/src + +COPY --chown=quarkus:quarkus z3-infrastructure/pom.xml /code/z3-infrastructure/pom.xml +COPY --chown=quarkus:quarkus z3-infrastructure/src /code/z3-infrastructure/src + +COPY --chown=quarkus:quarkus z3-shared/pom.xml /code/z3-shared/pom.xml +COPY --chown=quarkus:quarkus z3-shared/src /code/z3-shared/src + +COPY --chown=quarkus:quarkus z3-statistics-domain/pom.xml /code/z3-statistics-domain/pom.xml +COPY --chown=quarkus:quarkus z3-statistics-domain/src /code/z3-statistics-domain/src + +COPY --chown=quarkus:quarkus z3-statistics-infrastructure/pom.xml /code/z3-statistics-infrastructure/pom.xml +COPY --chown=quarkus:quarkus z3-statistics-infrastructure/src /code/z3-statistics-infrastructure/src + +USER quarkus +WORKDIR /code +RUN ./mvnw package -DskipTests -Dquarkus.package.jar.type=uber-jar -s /mvn-settings.xml + +FROM registry.access.redhat.com/ubi8/openjdk-21:1.20 + +ENV LANGUAGE='en_US:en' + +COPY --from=build --chown=185 /code/z3-application/target/*-runner.jar /deployments/quarkus-run.jar + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cd4455 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# Z3 – A link to the path + +*Z3* est un raccourcisseur d'URL. + +*Z3* s'inspire dans la forme des principes de clean architecture et de DDD. On trouve ainsi quatre +modules Maven : + +- `z3`: module parent ; +- `z3-domain` : contient les entités, les objets-valeur, les use cases et les ports ; +- `z3-infrastructure` : contient les implémentations des ports (adapters) ; +- `z3-application` : contient les services applicatifs. + +Le nom *Z3* est un clin d'œil à *Zelda 3 : A Link to the Past*, un jeu vidéo sorti en 1991 sur Super +Nintendo. + +## GENARATE CHANGELOG + +- Package node à util `https://github.com/conventional-changelog/conventional-changelog` +- Commande à utiliser `conventional-changelog -p conventionalcommits -i CHANGELOG.md -s` + +## SCHEDULED TASKS + +Liste des taches planifiés : + +- Envoyer les rapports de liens invalides -> `APP_REPORT_INVALID_LINKS` + +## CLI + +- Pour lancer la cli en DEV : `mvn quarkus:dev -Dquarkus.args=seed +- Ou `java -jar target/z3-cli-1.1.0-SNAPSHOT-runner.jar seed` + +## Configuration + +### BDD supportées + +- Postgres + +### LOG + +- Le loglevel peut être set avec `APP_LOG_LEVEL` -> `APP_LOG_LEVEL=DEBUG` + +### UPLOADS + +**! Cette valeur dois être raccord avec la partie front (Sveltekit pas exemple) !** + +- La taille max des uploads peut être définie `APP_MAX_UPLOAD_SIZE` -> `APP_MAX_UPLOAD_SIZE=100M` + +### SECURITE + +**! Toutes les informations de configuration de cette partie doivent être sauvegardés pour conserver +des données exploitables !** + +#### JWT + +Usage : + +```text +APP_JWT_ISSUER=http://localhost:8080/issuer +APP_JWT_PUBLIC_KEY=publicKey.pem +APP_JWT_PRIVATE_KEY=privateKey.pem +``` + +Génerer des clés : + +```shell +openssl genrsa -out rsaPrivateKey.pem 2048 +openssl rsa -pubout -in rsaPrivateKey.pem -out publicKey.pem +openssl pkcs8 -topk8 -nocrypt -inform pem -in rsaPrivateKey.pem -outform pem -out privateKey.pem +``` + +#### ENCRYPTION + +example : + +```text +AES_KEY="password" +AES_SALT="salt" +AES_KEY_LENGTH=256 +``` + +Notes : + +- `KEY` Est une valeur texte quelconque +- `SALT` Est une valeur texte quelconque +- `KEY_LENGTH` Devrais être laissée à 256 + +### MAIL + +Exemple (pour mailpit) : + +```text +APP_MAIL_FROM=z3@example.net +APP_MAIL_HOST=127.0.0.1 +APP_MAIL_PORT=1025 +APP_MAIL_START_TLS=OPTIONAL +APP_MAIL_TLS=false +APP_MAIL_USERNAME=null +APP_MAIL_PASSWORD=null +APP_MAIL_DEV_SIMULATE=false +``` + +Notes : + +- `APP_MAIL_FROM` Est l'adresse email qui est inscrite dans le mail dans la partie `FROM:` +- `MAIL_DEV_SIMULATE` Permet de simuler l'envoie de mails et donc de se passer d'un serveur SMTP. + +### Création d'un compte initial + +**! Cette partie nécessite un serveur SMTP, en local il est possible d'utiliser Mailpit par +exemple !** + +Mailpit : + +```shell +podman run -d \ +--name=mailpit \ +--restart unless-stopped \ +-p 8025:8025 \ +-p 1025:1025 \ +axllent/mailpit +``` + +Exemple : + +```text +APP_CREATE_USER_ON_START=toto@example.net +``` + +Notes : + +- `CREATE_USER_ON_START` Peut etre vide, s'il ne l'est pas un nouvel utilisateur **Administrateur** + sera crée. Les informations de connexion seront envoyés par email. + +## TESTS + +```sh +mvn clean verify +``` + +Reports for each modules can be found under target dirs. + +## SECURITY + +### Checking + +Check for vulnerabilities + +First time running requires downloading a list of CVE. + +You can request an API key from [here](https://nvd.nist.gov/developers/request-an-api-key) to +accelerate downloading theses. They are stored in +`~/.m2/repository/org/owasp/dependency-check-data/`. + +You can find more information form +the [plugin Github repository](https://github.com/jeremylong/DependencyCheck). + +```sh +mvn org.owasp:dependency-check-maven:check +``` + +To pass an api key you can use, or replace `$ENV_API_KEY_VAR` directly with the api key. + +```sh +mvn verify -Ddependency-check-maven.api_key=$ENV_API_KEY_VAR +``` + +### Report + +You may find report after ward in `target/dependency-check-report.html` + +They are several reports configured but you can add more looking +at [the documentation](https://jeremylong.github.io/DependencyCheck/dependency-check-maven/configuration.html). + +Available are `HTML, XML, CSV, JSON, JUNIT, SARIF, JENKINS, GITLAB, ALL`. + +Now Z3 has enabled `HTML, JSON, SARIF and GITLAB`. \ No newline at end of file diff --git a/Uml/Z3Backend.gaphor b/Uml/Z3Backend.gaphor new file mode 100644 index 0000000..8602492 --- /dev/null +++ b/Uml/Z3Backend.gaphor @@ -0,0 +1,9190 @@ + + + + + + +Z3-Backend + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Domain Entities + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Link + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 15.632781982421875, 53.54996894153217) + + +(0.0, 0.0) + + +264.0 + + +210.0 + + + + + + + + +0 + + + + + + + + + + +1 + + +source + + +Source + + +private + + + + +Default -Entities + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 57.496246337890625, -118.81243896484375) + + +(0.0, 0.0) + + +1135.1521911621094 + + +688.4366648033485 + + + + + + + + + + + + + + + + + + + + + + + +Default - Value objects + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 64.18765258789062, 618.65625) + + +(0.0, -11.437478186004796) + + +1109.8162536621094 + + +642.8295677739017 + + + + + + + + + + + + + + + + + + + + + + + +Source + + + + + + + + + + + + + + + + + + + + + + + +value + + +string + + + + + + + +target + + +Url + + +private + + + + + + + +isValid + + +bool + + +private + + + + +Brand + + +Is use to provide context for Catalog and Links + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 15.632781982421875, 307.46564526143715) + + +(0.0, 0.0) + + +183.0 + + +117.0 + + + + + + + + +0 + + + + + + + + + + +1 + + +uuid + + +Uuid + + +private + + + + + + + +slug + + +Slug + + +private + + + + + + + +getSlug + + + + + + + + + + + + +getName + + + + + + + + + + + + +setName + + + + + + + + + + +return + + + + + +Slug + + + + +return + + + + + +string + + + + +return + + + + + +void + + + + +in + + +string + + + + + + + +Url + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 863.7889862060547, 52.738258361816406) + + +(0.0, 0.0) + + +112.0 + + +91.0 + + + + + + + + +0 + + + + + + + + + + +value + + +string + + + + +User + + +firstName, lastName and email should be encrypted + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 549.3824691772461, 90.79603980334855) + + +(38.60559844970703, 0.0) + + +255.0 + + +168.0 + + + + + + + + +0 + + + + + + + + + + +getId + + + + + + + + + + + + +getFirstName + + + + + + + + + + + + +1 + + +uuid + + +Uuid + + +private + + + + + + + +firstName + + +string + + +private + + + + + + + +lastName + + +string + + +private + + + + + + + +setFirstName + + + + + + + + + + + + + +getLastName + + + + + + + + + + + + +setLastName + + + + + + + + + +in + + +lastName + + + + + +String + + + + +return + + + + + +void + + + + +return + + + + + +string + + + + +return + + + + + +Uuid + + + + + + + + + + +0 + + +0 + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 890.2260208129883, 156.31276153938563) + + +[(-47.237953186035156, 11.425784453356755), (45.43528747558594, 7.747602857723081)] + + + + + + + + + + +Catalog + + +Catalog is use to regroup links inside a Brand + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 307.1077768969918, 203.24919532092667) + + +(0.0, 0.0) + + +208.1929128002738 + + +132.7690636159922 + + + + + + + + +0 + + + + + + + + + + +1 + + +uuid + + +Uuid + + +private + + + + + + + +slug + + +Slug + + +private + + + + +Tag + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 307.1077768969918, 56.749195320926674) + + +(0.0, 0.0) + + +183.0 + + +125.0 + + + + + + + + +0 + + + + + + + + + + +1 + + +uuid + + +Uuid + + +private + + + + + + + +name + + +string + + +private + + + + + + + +color + + +Color + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 598.8591613769531, 52.738258361816406) + + +(0.0, 0.0) + + +178.0 + + +125.0 + + + + + + + + + + + + + +Color + + +The color is stored as unsigned integer, +the input color has to be an html hex color without +transparency + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +getColor + + + + + + + + + +return + + + + + +string + + + + + + + +setColor + + + + + + + + + +in + + +color + + + + + +string + + + + + + + +value + + +uint + + + + + + + +Domain Entities - Value Objects Relations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 387.8281555175781, 96.58595275878906) + + +(22.949188232421875, 0.0) + + +264.0 + + +272.83204650878906 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 887.7497863769531, -0.0507659912109375) + + +(0.0, 0.0) + + +112.0 + + +117.0 + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 887.7497863769531, 280.21875) + + +(0.0, 0.0) + + +112.0 + + +117.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 241.56248474121094, 209.88782792927515) + + +[(433.21485900878906, -59.44330105383307), (646.1873016357422, -132.00961549294544)] + + + + + + + + + + + + + + + + +url reduite + + + + + + + + + + + + + + + + + + + + + + + + +source 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 216.63279724121094, 231.59919469859534) + + +[(458.14454650878906, 58.43270755285522), (671.1169891357422, 119.75038012976387)] + + + + + + + + + + + + + + + + +url cible + + + + + + + + + + + + + + + + + + + + + + + + +target 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 524.5273590087891, 430.8825859294052) + + +(-92.0, 0.0) + + +204.53128051757812 + + +141.99114235062882 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 793.4763793945312, 435.96615339244397) + + +(0.0, 0.0) + + +178.0 + + +118.8240074245511 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 143.17970275878906, 416.6883449813738) + + +[(493.8789367675781, 75.07422774947156), (650.2966766357422, 76.06840375696464)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +color 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + + + + +visits + + +uint + + +private + + + + +Slug + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 407.85911560058594, 52.738258361816406) + + +(0.0, 0.0) + + +120.0 + + +91.0 + + + + + + + + +0 + + + + + + + + + + +value + + +string + + + + +in + + +firstName + + + + + +string + + + + + + + +0 + + +0 + + +catalog + + +Uuid + + +1 + + +1 + + +private + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 400.1953125, 248.819274948064) + + +[(10.58203125, -143.82140961132802), (-169.74217987060547, -136.53106237377722)] + + + + + + + + + + + + + +Domain Entities - Relationships + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 82.74670653480976, -853.1484069824219) + + +(-0.4550628662109375, 7.06006794387099) + + +283.0 + + +242.219510138136 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 714.0330288381389, -846.0883390385509) + + +(0.0, 0.0) + + +209.56066012670487 + + +143.88577791951718 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 330.65623474121094, -550.9781822825557) + + +[(34.635408927387886, -202.48393724427058), (383.37679409692794, -202.48393724427058)] + + + + + + + + + + + + + + + + +Est référencé dans + + + + + + + + + + + + + + + + + + + + + + + +catalog + + + + + + + + +1 + + +1 + + +private + + + + + + + + + + +0 + + +0 + + +uuid + + + + + +1 + + +1 + + +private + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 405.39451599121094, 439.7665455491292) + + +[(5.3828277587890625, -224.17862037748841), (-174.9413833618164, -218.49112037748841)] + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 534.8005218505859, 527.78955888264) + + +[(-102.27316284179688, -32.599390366899286), (-304.3473892211914, -26.723901777920446)] + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 132.2916436685988, -306.62890625) + + +(0.0, 0.0) + + +183.0 + + +138.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 211.72264099121094, -453.90234375) + + +[(7.508374444303854, -149.96648515041488), (5.461681573973976, 147.2734375)] + + + + + + + + + + + + + + + + +Possede + + + + + + + + + + + + + + + + + + + + + + + +tag + + + + + + + + +1 + + +1 + + +private + + + + + + + + + + +0 + + +0 + + +uuid + + + + + +1 + + +1 + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 446.5155601501465, 624.6405478695317) + + +(0.0, 0.0) + + +183.0 + + +174.71835971313044 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 859.4763793945312, 585.3512902260969) + + +(0.0, 0.0) + + +112.0 + + +117.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 461.3437957763672, 701.812027882174) + + +[(168.1717643737793, -10.21558441916568), (398.13258361816406, -62.02350992998015)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +slug 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 421.7929992675781, 865.3788592753989) + + +(0.0, 0.0) + + +226.0 + + +206.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 416.640625, 931.2093869803954) + + +[(5.152374267578125, 33.33422792273302), (-186.18749237060547, 38.527185657449195)] + + + + + + + + + + + + + +email + + +Email + + +private + + + + + + + +setEmail + + + + + + + + + + +return + + + + + +void + + + + + + + +getEmail + + + + + + + + + +return + + + + + +string + + + + +in + + +email + + + + + +string + + + + +Uuid + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 222.63278198242188, 52.738258361816406) + + +(0.0, 0.0) + + +112.0 + + +91.0 + + + + + + + + +0 + + + + + + + + + + +value + + +string + + + + +(1.0, 0.0, 0.0, 1.0, 245.28601837158203, 52.741511942362536) + + +(0.0, 0.0) + + +118.0 + + +133.0 + + + + + + + + +0 + + +0 + + + + + + + +AccessLevel + + + + + + + + + + + + + + + + + + + + + + + + + +Administrator + + + + + + + +Manager + + + + + + + +Publisher + + + + +Role + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 23.19799041748047, 64.91729319236254) + + +(0.0, 0.0) + + +100.0 + + +91.0 + + + + + + + + + + + + + +Role + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 38.40734100341797, 219.89395493653012) + + +(-3.8058090209960938, 0.0) + + +402.93353271484375 + + +199.44921875 + + + + + + + + + + + + + + + + + + + + + + + +value + + +private + + + + + + + + + + +0 + + +0 + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 70.42862701416016, 91.17176534938562) + + +[(52.76936340332031, 26.66688483221094), (174.85739135742188, 26.66688483221094)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + +(1.0, 0.0, 0.0, 1.0, 935.6613082885742, 110.81069327434392) + + +(0.0, 0.0) + + +100.0 + + +95.47855133437648 + + + + + + + + +0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +role + + + + + + + + +private + + + + +composite + + + + + +value + + + + + + + + +private + + + + + + + +role + + +Role + + +private + + + + + + + +setPassword + + + + + + + + + +in + + +password + + + + + +string + + + + + + + +getPassword + + + + + + + + + +return + + + + + +string + + + + + + + +setRole + + + + + + + + + + +return + + + + + +void + + + + +in + + +role + + + + + +Role + + + + + + + +setLogin + + + + + + + + + + +return + + + + + +void + + + + +in + + +login + + + + + +string + + + + +return + + + + + +string + + + + +LoginHash + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 493.9081268310547, 246.73260511812668) + + +(0.0, 0.0) + + +122.0 + + +91.0 + + + + + + + + +0 + + + + + + + + + + +value + + +string + + +private + + + + +Credential + + +Used to authenticate User + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 307.10777689699177, 368.00700313352945) + + +(0.0, 0.0) + + +195.0 + + +108.0 + + + + + + + + + + + + + + + + +1 + + +uuid + + +Uuid + + +private + + + + + + + +login + + +LoginHash + + +private + + + + + + + +password + + +PasswordHash + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 445.27734375, -132.64480211569253) + + +(0.0, 0.0) + + +195.0 + + +121.0 + + + + + + + + + + +PasswordHash + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 654.8591613769531, 246.73260511812668) + + +(0.0, 0.0) + + +122.0 + + +91.0 + + + + + + + + +0 + + + + + + + + + + +value + + +string + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 164.19141387939453, -124.14480211569253) + + +(0.0, 0.0) + + +122.0 + + +117.0 + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 765.7497863769531, -124.14480211569253) + + +(0.0, 0.0) + + +122.0 + + +117.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 459.7421875, -68.66650333277289) + + +[(-14.46484375, 2.129385776325961), (-173.55077362060547, 9.330346346231664)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +password 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 579.9961547851562, -83.85521936343378) + + +[(60.28118896484375, 10.989007545980257), (185.75363159179688, 17.39883133326012)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +login 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 82.29164366859882, -1207.4945684742152) + + +(0.0, 0.0) + + +273.81504763587725 + + +181.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 704.5482909987392, -1168.594057044088) + + +(0.0, 0.0) + + +195.0 + + +121.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 358.23122624286077, -1113.6224439098594) + + +[(-2.124534938384727, -24.384005964114976), (346.3170647558784, -25.296731977803574)] + + + + + + + + + + + + + + + + +Possède des + + + + + + + + + + + + + + + + + + + + + + + +credentialUuid 1 + + + + + + + + +private + + + + + + + + + + +uuid 1 + + + + + +private + + + + + + + +0 + + +0 + + +brands + + +Uuid + + +* + + +* + + +private + + + + + + + +0 + + +0 + + +tag + + +Uuid + + +1 + + +1 + + +private + + + + + + + +name + + +string + + +private + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 614.9180297851562, 946.2555039962874) + + +[(32.874969482421875, 22.12335527911148), (244.558349609375, 23.097810877329152)] + + + + + + + + + + + + + +0 + + +0 + + +brand + + +Uuid + + +1 + + +1 + + +private + + + + + + + +1 + + +createdAt + + +zDateTime + + +private + + + + + + + +updatedAt + + +zDateTime + + +private + + + + + + + +1 + + +brand + + +Uuid + + +private + + + + + + + +1 + + +credentialUuid + + +Uuid + + +private + + + + + + + +name + + +string + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 108.19141387939453, 46.675087436329704) + + +(0.0, 0.0) + + +122.26171875 + + +117.0 + + + + + + + + + + + + + + + + +étiquette (tag) + + + + + + + + + + + + + + + + + + + + + + + + +tag 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 108.19141387939453, 170.0879251716408) + + +(0.0, 0.0) + + +122.26171875 + + +117.0 + + + + + + + + + + + + + + + + +catalog + + + + + + + + + + + + + + + + + + + + + + + + +catalog 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 108.19141387939453, 293.21875) + + +(0.0, 0.0) + + +122.26171875 + + +117.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 419.05076599121094, 479.61032606670733) + + +[(-8.273422241210938, -124.4855669988541), (-188.5976333618164, -116.74731586537246)] + + + + + + + + + + + + + + + + +enseigne (brand) + + + + + + + + + + + + + + + + + + + + + + + + +brand 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 108.19141387939453, 449.87815710471955) + + +(0.0, 0.0) + + +122.26171875 + + +117.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +uuid 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 108.19141387939453, 659.9997277260969) + + +(0.0, 0.0) + + +122.26171875 + + +117.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 544.8005218505859, 537.78955888264) + + +[(-98.28496170043945, 174.21016884345693), (-314.3473892211914, 180.71016884345693)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +uuid 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 108.19141387939453, 922.9999530253988) + + +(0.0, 0.0) + + +122.26171875 + + +117.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +uuid 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 859.4763793945312, 922.9999530253988) + + +(0.0, 0.0) + + +122.26171875 + + +109.0 + + + + + +0 + + + + + + + + + + + + + +enseigne (brand) + + + + + + + + + + + + + + + + + + + + + + + + +brand 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 881.7380981445312, 1148.2499771118164) + + +(0.0, 0.0) + + +100.0 + + +109.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 720.2481384277344, 1220.2823238478536) + + +[(-57.95513916015625, -18.78267561327243), (161.48995971679688, -11.571782648731414)] + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 368.6874008178711, 1153.7499771118164) + + +(38.60559844970703, 0.0) + + +255.0 + + +181.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 70.05080032348633, 1129.150722097396) + + +(0.0, 0.0) + + +119.8554458618164 + + +117.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 426.640625, 941.8617307303954) + + +[(-19.347625732421875, 222.22894207240972), (-236.73437881469727, 224.4167484105859)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +uuid + + + + + + + + +private + + + + +composite + + + + + +value + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 67.64452743530273, 1266.924159597396) + + +(0.0, 0.0) + + +122.26171875 + + +117.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 391.00390625, 1482.3212648206236) + + +[(16.289093017578125, -156.28526482282132), (-201.09766006469727, -149.7839800852705)] + + + + + + + + + + + + + + + + +brands (enseignes) + + + + + + + + + + + + + + + + + + + + + + + + +brands + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + +0 + + +0 + + +value + + + + + + + + +* + + +* + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 861.8826522827148, 1287.3929401149742) + + +(0.0, 0.0) + + +119.8554458618164 + + +117.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 401.00390625, 1492.3212648206236) + + +[(261.2890930175781, -173.39710522322753), (460.87874603271484, -169.4557027879248)] + + + + + + + + + + + + + + + + +credentials (identifiants) + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +credentialUuid 1 + + + + + + + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 729.528271316042, -564.384046270536) + + +(0.0, 0.0) + + +183.0 + + +130.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 221.72264099121094, -439.6499064564801) + + +[(143.56900267738789, -168.47135973745475), (507.8056303248311, -69.39684427528613)] + + + + + + + + + + + + + + + + +Appartient à + + + + + + + + + + + + + + + + + + + + + + + +brand + + + + + + + + +1 + + +1 + + +private + + + + + + + + + + +0 + + +0 + + +uuid + + + + + +1 + + +1 + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 114.45068537216233, -11.325046499910847) + + +(0.0, 0.0) + + +209.56066012670487 + + +143.88577791951718 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 729.528271316042, -11.325046499910837) + + +(0.0, 0.0) + + +183.0 + + +130.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 231.72264099121094, -429.6499064564801) + + +[(92.28870450765623, 490.2677489163278), (497.8056303248309, 499.97724943856383)] + + + + + + + + + + + + + + + + +Appartient à + + + + + + + + + + + + + + + + + + + + + + + +brand + + + + + + + + +1 + + +1 + + +private + + + + + + + + + + +uuid 1 + + + + + +private + + + + +UserReportingSubscription + + +- users are unique for this association +- brand is unique for all reporting List + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 582.3944396972656, 368.00700313352945) + + +(0.0, 0.0) + + +235.98043823242188 + + +117.0 + + + + + + + + +0 + + + + + + + + + + +1 + + +uuid + + +Uuid + + +private + + + + + + + +1 + + +user + + +Uuid + + +1 + + +1 + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 99.19410344897413, -1502.9178922569356) + + +(0.0, 0.0) + + +235.98043823242188 + + +130.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 676.8255794043449, -1495.1885711929817) + + +(0.0, 0.0) + + +183.0 + + +130.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 368.23122624286077, -1103.6224439098594) + + +[(-33.05668456146486, -315.1743649560076), (308.59435316148404, -306.32782149004925)] + + + + + + + + + + + + + + + + +Concerne + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +brand + + + + + + + + +* + + +* + + +private + + + + + + + + + + +0 + + +0 + + +uuid + + + + + +* + + +* + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 62.50109285476887, -1817.9362318313495) + + +(38.60559844970703, 0.0) + + +255.0 + + +181.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 378.23122624286066, -1093.6224439098594) + + +[(-142.08119497338765, -409.2954483470762), (-142.08119497338765, -543.3137879214901)] + + + + + + + + + + + + + + + + +Est inscrit sur une + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +user + + + + + + + + +* + + +* + + +private + + + + + + + + + + +0 + + +0 + + +uuid + + + + + +* + + +* + + +private + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 433.4402052751747, -1065.734422930028) + + +[(-77.33351397069856, -649.1685134884519), (104.72915785767668, -649.1685134884519), (250.93952581637188, -616.2290546515713), (326.8433546770053, -535.5110555105284), (326.8433546770054, -429.4541482629538)] + + + + + + + + + + + + + + + + +Est associé avec + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +brands + + + + + + + + +* + + +* + + +private + + + + + + + + + + +0 + + +0 + + +uuid + + + + + +* + + +* + + +private + + + + + + + +1 + + +brand + + +Uuid + + +1 + + +1 + + +private + + + + +Validity + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 291.2211496997261, 74.71206625583955) + + +(0.0, 0.0) + + +118.0 + + +133.0 + + + + + + + + +0 + + +0 + + + + + + + + + + +unknow + + + + + + + +valid + + + + + + + +invalid + + + + +Link Validity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 594.7771759033203, 384.2515256230531) + + +(0.0, 0.0) + + +447.1954040527344 + + +230.91796875 + + + + + + + + + + + + + + + + + + + + +LinkValidity + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 31.581985473632812, 81.29545129290273) + + +(0.0, 0.0) + + +123.0 + + +91.0 + + + + + + + + +0 + + + + + + + + + + +value + + +Validity + + +private + + + + + + + + + + +0 + + +0 + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 143.4335479736328, 118.68592118171318) + + +[(11.1484375, 9.441108190771502), (147.78760172609327, 8.109530111189542)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 + + +1 + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + +(1.0, 0.0, 0.0, 1.0, 887.7497863769531, 158.7063374363297) + + +(0.0, 0.0) + + +123.0 + + +104.0 + + + + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 665.7461547851562, 216.6615662496053) + + +[(9.03118896484375, -1.28836929355748), (222.00363159179688, -0.93290054355748)] + + + + + + + + + + + + + + + + +validité + + + + + + + + + + + + + + + + + + + + + + + + +value 1 + + + + + + + + +private + + + + +composite + + + + + +isValid 1 + + + + + + + + +private + + + + +Fqdn + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 855.3928568530465, 246.73260511812663) + + +(0.0, 0.0) + + +105.0 + + +91.0 + + + + + + + + +0 + + + + + + + + + + +value + + +string + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 862.9763793945312, 744.8589075826621) + + +(0.0, 0.0) + + +105.0 + + +109.0 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 471.3437957763672, 713.0697632517057) + + +[(158.1717643737793, 50.92996447439123), (391.63258361816406, 90.70636771130262)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +fqdns + + + + + + + + +* + + +* + + +private + + + + +composite + + + + + +value 1 + + + + + + + + +private + + + + + + + +fqdns + + +Fqdn + + +* + + +* + + +private + + + + +Default Domain + + + + + + + + + + + + + + + + + + + + + + + + +Statistics Domain + + + + + + + + + + + + + + + + + + + + + + + + + + + +Domain Entities - Relationships + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Domain Entities - Value Objects Relations + + + + +Shared + + + + + + + + + + + + + + + + + + + + +Diagrams + + + + + + + + + + + + + + + + + +Diagrams + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Shared - Value objects + + + + + + + + + + + + + + + + + + + + +Statistics - Entities + + + + + + + + + + + + + + + + + + + + + + + + + +Statistics - Value objects + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 344.3294065413365, 542.2971065465091) + + +(0.0, 0.0) + + +120.0 + + +126.0 + + + + + +0 + + + + + + + +Locale + + + + + + + + + + + + + + + + + + + + + + + + + +lang + + +String + + +private + + + + + + + +region + + +String + + +private + + + + +Country + + + + + + + + + + + + + + + + + + + + + + + + + ++ afghanistan: Integer + + + + + + + ++ france: Integer + + + + +(1.0, 0.0, 0.0, 1.0, 571.9614216367424, 202.92638912480888) + + +(0.0, 0.0) + + +168.0 + + +113.0 + + + + + +0 + + +0 + + + + + + + +ScreenProperties + + + + + + + + + + + + + + + + + + + + + + + + + + +height + + +Integer + + +private + + + + + + + +width + + +Integer + + +private + + + + + + + +dpi + + +Integer + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 97.12194886063179, 533.7971065465091) + + +(0.0, 0.0) + + +143.0 + + +143.0 + + + + + +0 + + + + + + + +BrowserInfo + + + + + + + + + + + + + + + + + + + + + + + + + + +classType + + +String + + +private + + + + + + + +name + + +String + + +private + + + + + + + +version + + +String + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 200.03558380531598, -205.35877035697592) + + +(0.0, 0.0) + + +135.0 + + +143.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 571.9614216367424, 367.63828046426926) + + +(0.0, 0.0) + + +176.0 + + +130.0 + + + + + +0 + + + + + + + +OperatingSystemInfo + + + + + + + + + + + + + + + + + + + + + + + + + + +classType + + +String + + +private + + + + + + + +name + + +String + + +private + + + + + + + +version + + +String + + +private + + + + +Fingerprint + + + + + + + + + + + + + + + + + + + + + + + + +fingerprint + + +String + + +private + + + + +(1.0, 0.0, 0.0, 1.0, 571.9614216367426, 54.536234503749434) + + +(0.0, 0.0) + + +143.0 + + +109.0 + + + + + +0 + + + + + + + +UserType + + + + + + + + + + + + + + + + + + + + + + + + + + + + ++ UNKNOWN: 0 + + + + + + + ++ HUMAN: 1 + + + + + + + ++ CRAWLER: 2 + + + + + + + ++ SPIDER: 3 + + + + + + + ++BOT : 4 + + + + +(1.0, 0.0, 0.0, 1.0, -149.80474090576172, 63.2638722729738) + + +(0.0, 0.0) + + +118.0 + + +177.0 + + + + + +0 + + +0 + + + + + + + +StatisticLineWithEntity + + + + + + + + + + + + + + + + + + + + + + + + + +count + + +Integer + + +private + + + + + + + +entity + + +String + + +private + + + + + + + +value + + +string + + +private + + + + +StatisticLinePerInterval + + + + + + + + + + + + + + + + + + + + + + + + + +count + + +Integer + + +private + + + + + + + +interval + + +ZonedDateTime + + +private + + + + + + + +value + + +string + + +private + + + + +StatisticLine + + + + + + + + + + + + + + + + + + + + + + + + +count + + +Integer + + +private + + + + + + + +value + + +String + + +private + + + + +RawUserAgent + + + + + + + + + + + + + + + + + + + + + + + + +value + + +String + + +private + + + + + + + +Domain Entities + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 462.7929706838091, 223.5157258836807) + + +(0.0, 0.0) + + +118.0 + + +193.0 + + + + + +0 + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 650.1210829832698, 386.09728823691665) + + +(0.0, 0.0) + + +143.0 + + +151.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 650.1210829832696, 29.425222843155034) + + +(0.0, 0.0) + + +123.0 + + +117.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 650.1210829832697, 189.51572588368063) + + +(0.0, 0.0) + + +176.0 + + +151.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, -126.65316682637649, 399.0972882369166) + + +(0.0, 0.0) + + +113.0 + + +134.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 48.35273109459308, 223.5157258836807) + + +(0.0, 0.0) + + +143.0 + + +117.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 48.35273109459303, 395.09728823691654) + + +(0.0, 0.0) + + +168.0 + + +142.0 + + + + + +0 + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, -126.65316682637648, 178.51823291842595) + + +(0.0, 0.0) + + +135.0 + + +151.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, -141.8253023334736, -75.37792891725309) + + +(0.0, 0.0) + + +1033.3872182168398 + + +680.7873096018675 + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 401.2058343162597, 16.425222843154927) + + +(0.0, 0.0) + + +192.0 + + +130.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 170.3670828875819, 10.425222843155048) + + +(0.0, 0.0) + + +195.0 + + +130.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, 25.646672478422705, 14.425222843155113) + + +(0.0, 0.0) + + +120.0 + + +126.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, -126.65316682637649, 14.425222843155112) + + +(0.0, 0.0) + + +123.0 + + +109.0 + + + + + +0 + + + + + + + +(1.0, 0.0, 0.0, 1.0, -43.17136408385036, 738.7192612544118) + + +(0.0, 0.0) + + +617.0768939428644 + + +566.8045887403125 + + + + + + + + + + + + + + + +LinkStatistic + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 128.81803656227305, 141.78895954150855) + + +(0.0, 0.0) + + +333.0 + + +304.0 + + + + + + + + +0 + + + + + + + + + + +uuid + + +UUID + + +private + + + + + + + +linkSource + + +Source + + +private + + + + + + + +linkWasValid + + +Boolean + + +private + + + + + + + +doNotTrack + + +Boolean + + +private + + + + + + + +userType + + +UserType + + +private + + + + + + + +0 + + +0 + + +country + + +Country + + +1 + + +1 + + +private + + + + + + + +0 + + +0 + + +brand + + +String + + +1 + + +1 + + +private + + + + + + + +0 + + +0 + + +catalog + + +String + + +1 + + +1 + + +private + + + + + + + +0 + + +0 + + +fingerprint + + +Fingerprint + + +1 + + +1 + + +private + + + + + + + +0 + + +0 + + +locale + + +Locale + + +1 + + +1 + + +private + + + + + + + +0 + + +0 + + +referrer + + +Referrer + + +1 + + +1 + + +private + + + + + + + +0 + + +0 + + +browser + + +BrowserInfo + + +1 + + +1 + + +private + + + + + + + +0 + + +0 + + +screen + + +ScreenProperties + + +1 + + +1 + + +private + + + + + + + +0 + + +0 + + +operatingSystem + + +OperatingSystemInfo + + +1 + + +1 + + +private + + + + + + + +visitedAt + + +ZonedDateTime + + +private + + + + +Referrer + + + + + + + + + + + + + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 407.19238522105553, 307.3936548009338) + + +(0.0, 0.0) + + +100.0 + + +100.0 + + + + + + + + +0 + + + + + + + + + + +host + + +String + + +private + + + + + + + +path + + +string + + +private + + + + +(1.0, 0.0, 0.0, 1.0, -140.80474090576172, 282.29710654650916) + + +(0.0, 0.0) + + +100.0 + + +134.0 + + + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 97.12194886063179, 104.07088662361211) + + +(0.0, -49.53465211986264) + + +340.8272698893682 + + +387.53465211986264 + + + + + +0 + + + + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 262.1574194786565, -62.068190478976476) + + +[(8.067246489989088, -0.2905798779994484), (8.067246489989088, 116.60442498272596)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +composite + + + + + +0 + + +0 + + +browserInfo + + + + + + + + +1 + + +1 + + +private + + + + + + + + + + + + + +1 + + +1 + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 94.88836379985864, 133.53306572998952) + + +[(2.2335850607731516, 0.0), (-126.69310470562036, 0.0)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +userType + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 93.66668611678327, 328.8538141317102) + + +[(3.4552627438485217, 2.4679584448509218), (-134.471427022545, 2.4679584448509218)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +referrer + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 434.76106329261916, 335.8154629689585) + + +[(-38.31711554824017, 106.25542365465361), (-38.31711554824017, 206.48164357755059)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +locale + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 166.7779397085811, 444.15684448894916) + + +[(0.0, -2.0859578653370363), (0.0, 89.64026205755994)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +screenProperties + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 440.0739406585517, 414.7465745899644) + + +[(-2.1247219085516917, 27.324312033647743), (131.88748097819075, -33.16996965094188)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +operatingSystem + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 436.38523168523477, 260.34644021910407) + + +[(1.563987064765172, -12.042879655423292), (135.57618995150767, -12.042879655423292)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +country + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + + + + + + + +0 + + +0 + + + + + + + + +(1.0, 0.0, 0.0, 1.0, 438.40242041775446, 108.81103717276017) + + +[(-0.4532016677546835, -54.2748026690107), (133.559001218988, 0.22519733098926054)] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +0 + + +0 + + +fingerprint + + + + + + + + +1 + + +1 + + +private + + + + +composite + + + + + + + + + + + +1 + + +1 + + + + \ No newline at end of file diff --git a/brunoCollection/Auth/Change Password.bru b/brunoCollection/Auth/Change Password.bru new file mode 100644 index 0000000..3b195ea --- /dev/null +++ b/brunoCollection/Auth/Change Password.bru @@ -0,0 +1,18 @@ +meta { + name: Change Password + type: http + seq: 3 +} + +put { + url: http://localhost:8080/api/v1/auth/password + body: json + auth: inherit +} + +body:json { + { + "password" : "asd", + "new-password" : "asd" + } +} diff --git a/brunoCollection/Auth/Login.bru b/brunoCollection/Auth/Login.bru new file mode 100644 index 0000000..e21112c --- /dev/null +++ b/brunoCollection/Auth/Login.bru @@ -0,0 +1,28 @@ +meta { + name: Login + type: http + seq: 1 +} + +post { + url: http://localhost:8080/api/v1/auth/login + body: json + auth: none +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "login": "z3.admin", + "password": "g4nWmGs0w^" + } +} + +tests { + let response = res.getBody(); + bru.setVar("jwt_token", response.token); +} diff --git a/brunoCollection/Auth/Me.bru b/brunoCollection/Auth/Me.bru new file mode 100644 index 0000000..da7e46e --- /dev/null +++ b/brunoCollection/Auth/Me.bru @@ -0,0 +1,16 @@ +meta { + name: Me + type: http + seq: 2 +} + +get { + url: http://localhost:8080/api/v1/auth/me + body: none + auth: inherit +} + +headers { + Content-Type: application/json + Accept: application/json +} diff --git a/brunoCollection/Auth/folder.bru b/brunoCollection/Auth/folder.bru new file mode 100644 index 0000000..afa4461 --- /dev/null +++ b/brunoCollection/Auth/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Auth +} diff --git a/brunoCollection/Brands/Create Brand.bru b/brunoCollection/Brands/Create Brand.bru new file mode 100644 index 0000000..f2488e1 --- /dev/null +++ b/brunoCollection/Brands/Create Brand.bru @@ -0,0 +1,26 @@ +meta { + name: Create Brand + type: http + seq: 2 +} + +post { + url: http://localhost:8080/api/v1/brands + body: json + auth: inherit +} + +headers { + Accept: application/json + Content-Type: application/json +} + +body:json { + { + "name" : "super brand 2", + "fqdns": [ + "example.net", + "example.io" + ] + } +} diff --git a/brunoCollection/Brands/Get Brand.bru b/brunoCollection/Brands/Get Brand.bru new file mode 100644 index 0000000..24252cb --- /dev/null +++ b/brunoCollection/Brands/Get Brand.bru @@ -0,0 +1,15 @@ +meta { + name: Get Brand + type: http + seq: 1 +} + +get { + url: http://localhost:8080/api/v1/brands/2abe8b15-b204-4021-9847-6437188ec6d8 + body: none + auth: inherit +} + +headers { + Accept: application/json +} diff --git a/brunoCollection/Brands/List Brands.bru b/brunoCollection/Brands/List Brands.bru new file mode 100644 index 0000000..13cd601 --- /dev/null +++ b/brunoCollection/Brands/List Brands.bru @@ -0,0 +1,18 @@ +meta { + name: List Brands + type: http + seq: 4 +} + +get { + url: http://localhost:8080/api/v1/brands?page=1&perPage=15 + body: none + auth: inherit +} + +params:query { + page: 1 + perPage: 15 + ~orderBy: source + ~direction: desc +} diff --git a/brunoCollection/Brands/Update Brand.bru b/brunoCollection/Brands/Update Brand.bru new file mode 100644 index 0000000..1a689b1 --- /dev/null +++ b/brunoCollection/Brands/Update Brand.bru @@ -0,0 +1,26 @@ +meta { + name: Update Brand + type: http + seq: 3 +} + +put { + url: http://localhost:8080/api/v1/brands/super-brand-2 + body: json + auth: inherit +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "name": "Super Brand 2", + "fqdns": [ + "example.net", + "example.io" + ] + } +} diff --git a/brunoCollection/Brands/folder.bru b/brunoCollection/Brands/folder.bru new file mode 100644 index 0000000..5da2147 --- /dev/null +++ b/brunoCollection/Brands/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Brands +} diff --git a/brunoCollection/Catalogs/Create Catalog.bru b/brunoCollection/Catalogs/Create Catalog.bru new file mode 100644 index 0000000..4f8375a --- /dev/null +++ b/brunoCollection/Catalogs/Create Catalog.bru @@ -0,0 +1,23 @@ +meta { + name: Create Catalog + type: http + seq: 1 +} + +post { + url: http://localhost:8080/api/v1/catalogs + body: json + auth: inherit +} + +headers { + Accept: application/json + Content-Type: application/json +} + +body:json { + { + "name" : "super catalog", + "brand": "1e86a493-ee75-494d-8da5-c61971c37b67" + } +} diff --git a/brunoCollection/Catalogs/Delete Catalog.bru b/brunoCollection/Catalogs/Delete Catalog.bru new file mode 100644 index 0000000..04f2efa --- /dev/null +++ b/brunoCollection/Catalogs/Delete Catalog.bru @@ -0,0 +1,11 @@ +meta { + name: Delete Catalog + type: http + seq: 3 +} + +delete { + url: http://localhost:8080/api/v1/catalogs/super-tag + body: none + auth: inherit +} diff --git a/brunoCollection/Catalogs/Get Catalog.bru b/brunoCollection/Catalogs/Get Catalog.bru new file mode 100644 index 0000000..cf14d4d --- /dev/null +++ b/brunoCollection/Catalogs/Get Catalog.bru @@ -0,0 +1,15 @@ +meta { + name: Get Catalog + type: http + seq: 5 +} + +get { + url: http://localhost:8080/api/v1/catalogs/super-catalog + body: none + auth: inherit +} + +headers { + Accept: application/json +} diff --git a/brunoCollection/Catalogs/List Catalogs.bru b/brunoCollection/Catalogs/List Catalogs.bru new file mode 100644 index 0000000..94a26f8 --- /dev/null +++ b/brunoCollection/Catalogs/List Catalogs.bru @@ -0,0 +1,19 @@ +meta { + name: List Catalogs + type: http + seq: 4 +} + +get { + url: http://localhost:8080/api/v1/catalogs?page=1&brand=b5a90f20-dd1c-4a8a-b820-aed3aa62e857&perPage=15 + body: none + auth: inherit +} + +params:query { + page: 1 + brand: b5a90f20-dd1c-4a8a-b820-aed3aa62e857 + perPage: 15 + ~orderBy: source + ~direction: desc +} diff --git a/brunoCollection/Catalogs/Update Catalog.bru b/brunoCollection/Catalogs/Update Catalog.bru new file mode 100644 index 0000000..1094c4e --- /dev/null +++ b/brunoCollection/Catalogs/Update Catalog.bru @@ -0,0 +1,18 @@ +meta { + name: Update Catalog + type: http + seq: 2 +} + +put { + url: http://localhost:8080/api/v1/catalogs/97295da4-d7c8-4e15-86fb-81ee9f53a47e + body: json + auth: inherit +} + +body:json { + { + "name" : "super tagwrere", + "color": "#fff" + } +} diff --git a/brunoCollection/Catalogs/folder.bru b/brunoCollection/Catalogs/folder.bru new file mode 100644 index 0000000..6706167 --- /dev/null +++ b/brunoCollection/Catalogs/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Catalogs +} diff --git a/brunoCollection/Links/Create Link.bru b/brunoCollection/Links/Create Link.bru new file mode 100644 index 0000000..ce6b358 --- /dev/null +++ b/brunoCollection/Links/Create Link.bru @@ -0,0 +1,26 @@ +meta { + name: Create Link + type: http + seq: 3 +} + +post { + url: http://localhost:8080/api/v1/links + body: json + auth: inherit +} + +headers { + Content-Type: application/json + Accept: application/json +} + +body:json { + { + "source" : "testkey", + "target" : "https://example.org", + "is_external": true, + "tag": "", + "catalog": "" + } +} diff --git a/brunoCollection/Links/Get Link.bru b/brunoCollection/Links/Get Link.bru new file mode 100644 index 0000000..0ec0e27 --- /dev/null +++ b/brunoCollection/Links/Get Link.bru @@ -0,0 +1,11 @@ +meta { + name: Get Link + type: http + seq: 1 +} + +get { + url: http://localhost:8080/api/v1/links/3cisvgdo7r8fdqo774ci3apagt + body: none + auth: inherit +} diff --git a/brunoCollection/Links/Link export no brand.bru b/brunoCollection/Links/Link export no brand.bru new file mode 100644 index 0000000..eb16787 --- /dev/null +++ b/brunoCollection/Links/Link export no brand.bru @@ -0,0 +1,11 @@ +meta { + name: Link export no brand + type: http + seq: 5 +} + +get { + url: http://localhost:8080/api/v1/links-export + body: none + auth: inherit +} diff --git a/brunoCollection/Links/Link export with brand.bru b/brunoCollection/Links/Link export with brand.bru new file mode 100644 index 0000000..188ebfd --- /dev/null +++ b/brunoCollection/Links/Link export with brand.bru @@ -0,0 +1,11 @@ +meta { + name: Link export with brand + type: http + seq: 6 +} + +get { + url: http://localhost:8080/api/v1/links-export/brands/new-branddd + body: none + auth: inherit +} diff --git a/brunoCollection/Links/Link export with catalog.bru b/brunoCollection/Links/Link export with catalog.bru new file mode 100644 index 0000000..09e186a --- /dev/null +++ b/brunoCollection/Links/Link export with catalog.bru @@ -0,0 +1,11 @@ +meta { + name: Link export with catalog + type: http + seq: 7 +} + +get { + url: http://localhost:8080/api/v1/links-export/catalogs/new-catalog + body: none + auth: inherit +} diff --git a/brunoCollection/Links/Link import.bru b/brunoCollection/Links/Link import.bru new file mode 100644 index 0000000..c2c4347 --- /dev/null +++ b/brunoCollection/Links/Link import.bru @@ -0,0 +1,19 @@ +meta { + name: Link import + type: http + seq: 8 +} + +post { + url: http://localhost:8080/api/v1/links-import/brands/1e86a493-ee75-494d-8da5-c61971c37b67 + body: multipartForm + auth: inherit +} + +headers { + Accept: application/json +} + +body:multipart-form { + file: @file(postman-cloud:///1efaca96-04b8-4ff0-968f-73a0020ae3a0) +} diff --git a/brunoCollection/Links/List Links.bru b/brunoCollection/Links/List Links.bru new file mode 100644 index 0000000..f1626a9 --- /dev/null +++ b/brunoCollection/Links/List Links.bru @@ -0,0 +1,22 @@ +meta { + name: List Links + type: http + seq: 4 +} + +get { + url: http://localhost:8080/api/v1/links?page=1&perPage=15&orderBy=source&direction=desc + body: none + auth: inherit +} + +params:query { + page: 1 + perPage: 15 + orderBy: source + direction: desc + ~tag: 1fefab47-ec13-4f9e-815a-1cef5e7d7db4 + ~searchOn: source + ~keywords: example.net + ~catalog: 77657d43-7d33-48cd-88aa-7f73fa6d6ded +} diff --git a/brunoCollection/Links/Statistics/For all Brands.bru b/brunoCollection/Links/Statistics/For all Brands.bru new file mode 100644 index 0000000..f446224 --- /dev/null +++ b/brunoCollection/Links/Statistics/For all Brands.bru @@ -0,0 +1,11 @@ +meta { + name: For all Brands + type: http + seq: 1 +} + +get { + url: http://localhost:8080/api/v1/links/statistics/brands + body: none + auth: inherit +} diff --git a/brunoCollection/Links/Statistics/For all Catalogs of.bru b/brunoCollection/Links/Statistics/For all Catalogs of.bru new file mode 100644 index 0000000..f177efb --- /dev/null +++ b/brunoCollection/Links/Statistics/For all Catalogs of.bru @@ -0,0 +1,19 @@ +meta { + name: For all Catalogs of + type: http + seq: 4 +} + +get { + url: http://localhost:8080/api/v1/links/statistics/catalog?brand=582807fe-7a37-462d-97e4-10f30d055abb + body: none + auth: bearer +} + +params:query { + brand: 582807fe-7a37-462d-97e4-10f30d055abb +} + +auth:bearer { + token: {{jwt_token}} +} diff --git a/brunoCollection/Links/Statistics/For all Tag of.bru b/brunoCollection/Links/Statistics/For all Tag of.bru new file mode 100644 index 0000000..6ed8ed3 --- /dev/null +++ b/brunoCollection/Links/Statistics/For all Tag of.bru @@ -0,0 +1,19 @@ +meta { + name: For all Tag of + type: http + seq: 5 +} + +get { + url: http://localhost:8080/api/v1/links/statistics/tag?brand=582807fe-7a37-462d-97e4-10f30d055abb + body: none + auth: bearer +} + +params:query { + brand: 582807fe-7a37-462d-97e4-10f30d055abb +} + +auth:bearer { + token: {{jwt_token}} +} diff --git a/brunoCollection/Links/Statistics/Global Statistics For Brand.bru b/brunoCollection/Links/Statistics/Global Statistics For Brand.bru new file mode 100644 index 0000000..773c109 --- /dev/null +++ b/brunoCollection/Links/Statistics/Global Statistics For Brand.bru @@ -0,0 +1,15 @@ +meta { + name: Global Statistics For Brand + type: http + seq: 3 +} + +get { + url: http://localhost:8080/api/v1/links/statistics/582807fe-7a37-462d-97e4-10f30d055abb + body: none + auth: bearer +} + +auth:bearer { + token: {{jwt_token}} +} diff --git a/brunoCollection/Links/Statistics/Global Statistics.bru b/brunoCollection/Links/Statistics/Global Statistics.bru new file mode 100644 index 0000000..9eb9196 --- /dev/null +++ b/brunoCollection/Links/Statistics/Global Statistics.bru @@ -0,0 +1,15 @@ +meta { + name: Global Statistics + type: http + seq: 2 +} + +get { + url: http://localhost:8080/api/v1/links/statistics + body: none + auth: bearer +} + +auth:bearer { + token: {{jwt_token}} +} diff --git a/brunoCollection/Links/Statistics/folder.bru b/brunoCollection/Links/Statistics/folder.bru new file mode 100644 index 0000000..992258b --- /dev/null +++ b/brunoCollection/Links/Statistics/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Statistics +} diff --git a/brunoCollection/Links/Update Link.bru b/brunoCollection/Links/Update Link.bru new file mode 100644 index 0000000..8788041 --- /dev/null +++ b/brunoCollection/Links/Update Link.bru @@ -0,0 +1,20 @@ +meta { + name: Update Link + type: http + seq: 2 +} + +put { + url: http://localhost:8080/api/v1/links/cle-1058 + body: json + auth: inherit +} + +body:json { + { + "target": "https://example.net", + "is_external": true, + "tag": "", + "catalog": "" + } +} diff --git a/brunoCollection/Links/Verify Link.bru b/brunoCollection/Links/Verify Link.bru new file mode 100644 index 0000000..77f4231 --- /dev/null +++ b/brunoCollection/Links/Verify Link.bru @@ -0,0 +1,11 @@ +meta { + name: Verify Link + type: http + seq: 9 +} + +put { + url: http://localhost:8080/api/v1/links/3cisvgdo7r8fdqo774ci3apagt/verify + body: none + auth: inherit +} diff --git a/brunoCollection/Links/folder.bru b/brunoCollection/Links/folder.bru new file mode 100644 index 0000000..d0c03ff --- /dev/null +++ b/brunoCollection/Links/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Links +} diff --git a/brunoCollection/Public/Link Redirection With Additionals.bru b/brunoCollection/Public/Link Redirection With Additionals.bru new file mode 100644 index 0000000..34c5955 --- /dev/null +++ b/brunoCollection/Public/Link Redirection With Additionals.bru @@ -0,0 +1,29 @@ +meta { + name: Link Redirection With Additionals + type: http + seq: 3 +} + +get { + url: http://localhost:8080/presentation-de-aout?ip-address=80.0.2.1&dnt=false&lang=fr®ion=FR&referrer=https%3A%2F%2Fgoogle.com%2Fi-love-linux&screen-width=1200&screen-height=1080&screen-dpi=4&user-agent=Mozilla%2F5.0%20%28compatible%3B%20Sosospider%2F2.0%3B%20%2Bhttp%3A%2F%2Fhelp.soso.com%2Fwebspider.htm%29&fingerprint=fingerprint + body: none + auth: none +} + +params:query { + ip-address: 80.0.2.1 + dnt: false + lang: fr + region: FR + referrer: https%3A%2F%2Fgoogle.com%2Fi-love-linux + screen-width: 1200 + screen-height: 1080 + screen-dpi: 4 + user-agent: Mozilla%2F5.0%20%28compatible%3B%20Sosospider%2F2.0%3B%20%2Bhttp%3A%2F%2Fhelp.soso.com%2Fwebspider.htm%29 + fingerprint: fingerprint + ~user-agent: Mozilla%252F5.0%2520%2528compatible%253B%2520Sosospider%252F2.0%253B%2520%252Bhttp%253A%252F%252Fhelp.soso.com%252Fwebspider.htm%2529 + ~user-agent: Python-urllib%2F1.17 + ~user-agent: gloubiboulga + ~user-agent: Mozilla%2F5.0%20%28X11%3B%20Linux%20x86_64%3B%20rv%3A136.0%29%20Gecko%2F20100101%20Firefox%2F136.0 + ~user-agent: Mozilla%2F5.0%20%28compatible%3B%202GDPR%2F1.2%3B%20https%3A%2F%2F2gdpr.com%29 +} diff --git a/brunoCollection/Public/Link Redirection slim.bru b/brunoCollection/Public/Link Redirection slim.bru new file mode 100644 index 0000000..88eceba --- /dev/null +++ b/brunoCollection/Public/Link Redirection slim.bru @@ -0,0 +1,11 @@ +meta { + name: Link Redirection slim + type: http + seq: 4 +} + +get { + url: http://localhost:8080/test + body: none + auth: none +} diff --git a/brunoCollection/Public/Link Redirection.bru b/brunoCollection/Public/Link Redirection.bru new file mode 100644 index 0000000..e41a7d4 --- /dev/null +++ b/brunoCollection/Public/Link Redirection.bru @@ -0,0 +1,25 @@ +meta { + name: Link Redirection + type: http + seq: 2 +} + +get { + url: http://localhost:8080/test?ip-address=80.0.2.1&dnt=false&lang=fr®ion=FR&referrer=https%3A%2F%2Fgoogle.com%2Fi-love-linux&user-agent=Mozilla%2F5.0%20%28Windows%20NT%2010.0%3B%20Win64%3B%20x64%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F133.0.0.0%20Safari%2F537.36&screen-width=1200&screen-height=1080&screen-dpi=4 + body: none + auth: none +} + +params:query { + ip-address: 80.0.2.1 + dnt: false + lang: fr + region: FR + referrer: https%3A%2F%2Fgoogle.com%2Fi-love-linux + user-agent: Mozilla%2F5.0%20%28Windows%20NT%2010.0%3B%20Win64%3B%20x64%29%20AppleWebKit%2F537.36%20%28KHTML%2C%20like%20Gecko%29%20Chrome%2F133.0.0.0%20Safari%2F537.36 + screen-width: 1200 + screen-height: 1080 + screen-dpi: 4 + ~user-agent: Mozilla%2F5.0%20%28X11%3B%20Linux%20x86_64%3B%20rv%3A136.0%29%20Gecko%2F20100101%20Firefox%2F136.0 + ~user-agent: gloubiboulga +} diff --git a/brunoCollection/Public/Root Url.bru b/brunoCollection/Public/Root Url.bru new file mode 100644 index 0000000..f8afc2a --- /dev/null +++ b/brunoCollection/Public/Root Url.bru @@ -0,0 +1,11 @@ +meta { + name: Root Url + type: http + seq: 1 +} + +get { + url: http://localhost:8080 + body: none + auth: none +} diff --git a/brunoCollection/Public/folder.bru b/brunoCollection/Public/folder.bru new file mode 100644 index 0000000..6c7a11f --- /dev/null +++ b/brunoCollection/Public/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Public +} diff --git a/brunoCollection/Reporting/Get Reporting Subscriptions.bru b/brunoCollection/Reporting/Get Reporting Subscriptions.bru new file mode 100644 index 0000000..8a0955f --- /dev/null +++ b/brunoCollection/Reporting/Get Reporting Subscriptions.bru @@ -0,0 +1,11 @@ +meta { + name: Get Reporting Subscriptions + type: http + seq: 1 +} + +get { + url: http://localhost:8080/api/v1/reporting/subscriptions/b12f92c0-361c-40bd-9fbc-48c83fcccc81 + body: none + auth: inherit +} diff --git a/brunoCollection/Reporting/Update Reproting Subscriptions.bru b/brunoCollection/Reporting/Update Reproting Subscriptions.bru new file mode 100644 index 0000000..96dab19 --- /dev/null +++ b/brunoCollection/Reporting/Update Reproting Subscriptions.bru @@ -0,0 +1,19 @@ +meta { + name: Update Reproting Subscriptions + type: http + seq: 2 +} + +post { + url: http://localhost:8080/api/v1/reporting/subscriptions/b12f92c0-361c-40bd-9fbc-48c83fcccc81 + body: json + auth: inherit +} + +body:json { + { + "brands": [ + "582807fe-7a37-462d-97e4-10f30d055abb" + ] + } +} diff --git a/brunoCollection/Statistics/Invalid Links Per Week By Brands.bru b/brunoCollection/Statistics/Invalid Links Per Week By Brands.bru new file mode 100644 index 0000000..b955429 --- /dev/null +++ b/brunoCollection/Statistics/Invalid Links Per Week By Brands.bru @@ -0,0 +1,11 @@ +meta { + name: Invalid Links Per Week By Brands + type: http + seq: 5 +} + +get { + url: http://localhost:8080/api/v1/statistics/invalid-links/per-week-per-brand + body: none + auth: inherit +} diff --git a/brunoCollection/Statistics/Most Frequent Referrers With Brands.bru b/brunoCollection/Statistics/Most Frequent Referrers With Brands.bru new file mode 100644 index 0000000..a970a2e --- /dev/null +++ b/brunoCollection/Statistics/Most Frequent Referrers With Brands.bru @@ -0,0 +1,11 @@ +meta { + name: Most Frequent Referrers With Brands + type: http + seq: 4 +} + +get { + url: http://localhost:8080/api/v1/statistics/top/referrers + body: none + auth: inherit +} diff --git a/brunoCollection/Statistics/Top Visited Sources With Brands.bru b/brunoCollection/Statistics/Top Visited Sources With Brands.bru new file mode 100644 index 0000000..9262ace --- /dev/null +++ b/brunoCollection/Statistics/Top Visited Sources With Brands.bru @@ -0,0 +1,11 @@ +meta { + name: Top Visited Sources With Brands + type: http + seq: 3 +} + +get { + url: http://localhost:8080/api/v1/statistics/top/sources + body: none + auth: inherit +} diff --git a/brunoCollection/Statistics/Unique Visitors By Catalogs.bru b/brunoCollection/Statistics/Unique Visitors By Catalogs.bru new file mode 100644 index 0000000..b11c0f5 --- /dev/null +++ b/brunoCollection/Statistics/Unique Visitors By Catalogs.bru @@ -0,0 +1,11 @@ +meta { + name: Unique Visitors By Catalogs + type: http + seq: 2 +} + +get { + url: http://localhost:8080/api/v1/statistics/unique-visitors/catalogs + body: none + auth: inherit +} diff --git a/brunoCollection/Statistics/Unique Visitors by Brands.bru b/brunoCollection/Statistics/Unique Visitors by Brands.bru new file mode 100644 index 0000000..09b6adc --- /dev/null +++ b/brunoCollection/Statistics/Unique Visitors by Brands.bru @@ -0,0 +1,11 @@ +meta { + name: Unique Visitors by Brands + type: http + seq: 1 +} + +get { + url: http://localhost:8080/api/v1/statistics/unique-visitors/brands + body: none + auth: inherit +} diff --git a/brunoCollection/Tags/Create Tag.bru b/brunoCollection/Tags/Create Tag.bru new file mode 100644 index 0000000..d3e8fc8 --- /dev/null +++ b/brunoCollection/Tags/Create Tag.bru @@ -0,0 +1,23 @@ +meta { + name: Create Tag + type: http + seq: 1 +} + +post { + url: http://localhost:8080/api/v1/tags + body: json + auth: inherit +} + +headers { + Accept: application/json + Content-Type: application/json +} + +body:json { + { + "name" : "super tag", + "color": "#fff" + } +} diff --git a/brunoCollection/Tags/Delete Tag.bru b/brunoCollection/Tags/Delete Tag.bru new file mode 100644 index 0000000..d807539 --- /dev/null +++ b/brunoCollection/Tags/Delete Tag.bru @@ -0,0 +1,11 @@ +meta { + name: Delete Tag + type: http + seq: 3 +} + +delete { + url: http://localhost:8080/api/v1/brands/super-tag + body: none + auth: inherit +} diff --git a/brunoCollection/Tags/Get Tag.bru b/brunoCollection/Tags/Get Tag.bru new file mode 100644 index 0000000..19bef42 --- /dev/null +++ b/brunoCollection/Tags/Get Tag.bru @@ -0,0 +1,15 @@ +meta { + name: Get Tag + type: http + seq: 5 +} + +get { + url: http://localhost:8080/api/v1/tags/c1c9205d-19e5-469f-83fa-b40837ea0fbe + body: none + auth: inherit +} + +headers { + Accept: application/json +} diff --git a/brunoCollection/Tags/List Tags.bru b/brunoCollection/Tags/List Tags.bru new file mode 100644 index 0000000..ef13d8f --- /dev/null +++ b/brunoCollection/Tags/List Tags.bru @@ -0,0 +1,17 @@ +meta { + name: List Tags + type: http + seq: 4 +} + +get { + url: http://localhost:8080/api/v1/tags?page=1 + body: none + auth: inherit +} + +params:query { + page: 1 + ~orderBy: source + ~direction: desc +} diff --git a/brunoCollection/Tags/Update Tag.bru b/brunoCollection/Tags/Update Tag.bru new file mode 100644 index 0000000..31dfe53 --- /dev/null +++ b/brunoCollection/Tags/Update Tag.bru @@ -0,0 +1,18 @@ +meta { + name: Update Tag + type: http + seq: 2 +} + +put { + url: http://localhost:8080/api/v1/tags/97295da4-d7c8-4e15-86fb-81ee9f53a47e + body: json + auth: inherit +} + +body:json { + { + "name" : "super tagwrere", + "color": "#fff" + } +} diff --git a/brunoCollection/Tags/folder.bru b/brunoCollection/Tags/folder.bru new file mode 100644 index 0000000..be29567 --- /dev/null +++ b/brunoCollection/Tags/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Tags +} diff --git a/brunoCollection/Users/Create User.bru b/brunoCollection/Users/Create User.bru new file mode 100644 index 0000000..21856b2 --- /dev/null +++ b/brunoCollection/Users/Create User.bru @@ -0,0 +1,28 @@ +meta { + name: Create User + type: http + seq: 1 +} + +post { + url: http://localhost:8080/api/v1/users + body: json + auth: inherit +} + +headers { + Accept: application/json + Content-Type: application/json +} + +body:json { + { + "firstName" : "new first name", + "lastName" : "new last name", + "email" : "new.email@examspwle.net", + "role": "ADMINISTRATOR", + "login": "sdsd", + "password": "NewPassword12.", + "brands": [] + } +} diff --git a/brunoCollection/Users/Delete User.bru b/brunoCollection/Users/Delete User.bru new file mode 100644 index 0000000..f1ad05a --- /dev/null +++ b/brunoCollection/Users/Delete User.bru @@ -0,0 +1,11 @@ +meta { + name: Delete User + type: http + seq: 3 +} + +delete { + url: http://localhost:8080/api/v1/users/2c98a5a6-029b-4c9a-a88b-53da3b86c65b + body: none + auth: inherit +} diff --git a/brunoCollection/Users/Get User.bru b/brunoCollection/Users/Get User.bru new file mode 100644 index 0000000..c93bc9c --- /dev/null +++ b/brunoCollection/Users/Get User.bru @@ -0,0 +1,15 @@ +meta { + name: Get User + type: http + seq: 4 +} + +get { + url: http://localhost:8080/api/v1/users/4fa30f46-d116-499e-9f3f-aa3afe04dabf + body: none + auth: inherit +} + +headers { + Accept: application/json +} diff --git a/brunoCollection/Users/List Users.bru b/brunoCollection/Users/List Users.bru new file mode 100644 index 0000000..b7f671a --- /dev/null +++ b/brunoCollection/Users/List Users.bru @@ -0,0 +1,18 @@ +meta { + name: List Users + type: http + seq: 6 +} + +get { + url: http://localhost:8080/api/v1/users?direction=desc&page=1&perPage=3 + body: none + auth: inherit +} + +params:query { + direction: desc + page: 1 + perPage: 3 + ~orderBy: source +} diff --git a/brunoCollection/Users/Reset User Password.bru b/brunoCollection/Users/Reset User Password.bru new file mode 100644 index 0000000..6275571 --- /dev/null +++ b/brunoCollection/Users/Reset User Password.bru @@ -0,0 +1,15 @@ +meta { + name: Reset User Password + type: http + seq: 5 +} + +post { + url: http://localhost:8080/api/v1/users/5380d8b1-52d4-4986-8de9-d92ae7c0c970/reset + body: none + auth: inherit +} + +headers { + Accept: application/json +} diff --git a/brunoCollection/Users/Update User.bru b/brunoCollection/Users/Update User.bru new file mode 100644 index 0000000..dc4ffb4 --- /dev/null +++ b/brunoCollection/Users/Update User.bru @@ -0,0 +1,20 @@ +meta { + name: Update User + type: http + seq: 2 +} + +put { + url: http://localhost:8080/api/v1/users/4beed79f-ef03-4beb-b691-9556ae38ac76 + body: json + auth: inherit +} + +body:json { + { + "firstName" : "new first name", + "lastName" : "new last name", + "email" : "new.email@example.net", + "brands": [] + } +} diff --git a/brunoCollection/Users/folder.bru b/brunoCollection/Users/folder.bru new file mode 100644 index 0000000..fef28b7 --- /dev/null +++ b/brunoCollection/Users/folder.bru @@ -0,0 +1,3 @@ +meta { + name: Users +} diff --git a/brunoCollection/bruno.json b/brunoCollection/bruno.json new file mode 100644 index 0000000..34871d6 --- /dev/null +++ b/brunoCollection/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "Z3", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/brunoCollection/collection.bru b/brunoCollection/collection.bru new file mode 100644 index 0000000..60c31b6 --- /dev/null +++ b/brunoCollection/collection.bru @@ -0,0 +1,20 @@ +meta { + name: Z3 +} + +headers { + Accept-Language: fr +} + +auth { + mode: bearer +} + +auth:bearer { + token: {{jwt_token}} +} + +script:pre-request { + req.setMaxRedirects(0); + +} diff --git a/config/checkstyle-suppressions.xml b/config/checkstyle-suppressions.xml new file mode 100644 index 0000000..fbbcb72 --- /dev/null +++ b/config/checkstyle-suppressions.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/config/google_checks.xml b/config/google_checks.xml new file mode 100644 index 0000000..a906995 --- /dev/null +++ b/config/google_checks.xml @@ -0,0 +1,420 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..8d937f4 --- /dev/null +++ b/mvnw @@ -0,0 +1,308 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.2.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "$(uname)" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then + if $darwin ; then + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" + else + javaExecutable="$(readlink -f "\"$javaExecutable\"")" + fi + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$(cd "$wdir/.." || exit 1; pwd) + fi + # end of workaround + done + printf '%s' "$(cd "$basedir" || exit 1; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" +else + log "Couldn't find $wrapperJarPath, downloading it ..." + + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; + esac + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget > /dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi +fi + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# shellcheck disable=SC2086 # safe args +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100755 index 0000000..c4586b5 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,205 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.2.0 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..076f907 --- /dev/null +++ b/pom.xml @@ -0,0 +1,201 @@ + + + z3 + + + + + dependency-check-maven + + false + + HTML + SARIF + JSON + GITLAB + + false + ${dependency-check-maven.api_key} + false + false + + org.owasp + ${dependency-check-maven.version} + + + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Xshare:off + + + org.jboss.logmanager.LogManager + ${maven.home} + + + ${surefire-plugin.version} + + + maven-checkstyle-plugin + + /config/google_checks.xml + true + true + false + /config/checkstyle-suppressions.xml + + + + checkstyle + com.puppycrawl.tools + ${checkstyle.version} + + + + + + check + + check-style + validate + + + org.apache.maven.plugins + ${checkstyle-maven-plugin.version} + + + + + + dependency-check-maven + org.owasp + ${dependency-check-maven.version} + + + happy-core + tech.pegase.happy + ${pegase-happy.version} + + + quarkus-smallrye-jwt + io.quarkus + + + quarkus-smallrye-jwt-build + io.quarkus + + + quarkus-cache + io.quarkus + + + rest-assured + io.rest-assured + test + + + jspecify + org.jspecify + ${jspecify.version} + + + + + + ${quarkus.platform.artifact-id} + ${quarkus.platform.group-id} + import + pom + ${quarkus.platform.version} + + + + + + gitlab-maven + https://gitlab.pegase.tech/api/v4/projects/68/packages/maven + + + + gitlab-maven + https://gitlab.pegase.tech/api/v4/projects/68/packages/maven + + + tech.pegase.z3 + + 4.0.0 + + + z3-domain + z3-infrastructure + z3-application + z3-shared + z3-cli + z3-statistics-domain + z3-statistics-infrastructure + + + pom + + + + + + native + + + native + + true + false + + + + + + 3.0.0 + 1.6.14 + 1.79 + 3.6.0 + 10.21.1 + 1.13.0 + 1.9.0 + 1.0.0 + + 12.0.0 + 3.5.2 + 0.8.12 + 1.0.0 + 2.24.3 + 3.13.0 + 21 + 2.15.0 + 2.4.2 + 5.9 + 1.0.1 + UTF-8 + UTF-8 + quarkus-bom + io.quarkus.platform + 3.17.6 + 5.5.1 + true + 76.1 + 3.0.7 + 6.4.2 + 3.5.2 + 7.30.0 + 1.1.0-SNAPSHOT + 1.1.0-SNAPSHOT + + + + + gitlab-maven + https://gitlab.pegase.tech/api/v4/projects/68/packages/maven + + + + 1.1.0-SNAPSHOT + diff --git a/z3-application/.env.example b/z3-application/.env.example new file mode 100644 index 0000000..d3cff04 --- /dev/null +++ b/z3-application/.env.example @@ -0,0 +1,43 @@ +# DATABASE +APP_DB_KIND= +APP_DB_USERNAME= +APP_DB_PASSWORD= +APP_DB_JDBC_URL= + +# EXPORT +APP_RESOLVER_FQDN=example.net + +# LOG +APP_LOG_LEVEL=INFO + +# MAX UPLOAD SIZE +APP_MAX_UPLOAD_SIZE=100M + +# SECURITY +APP_JWT_ISSUER= +APP_JWT_PUBLIC_KEY= +APP_JWT_PRIVATE_KEY= +# ENCRYPTION +AES_KEY= +AES_SALT= +AES_KEY_LENGTH= + +# MAIL +APP_MAIL_FROM=z3@example.net +APP_MAIL_HOST=localhost +APP_MAIL_PORT=1025 +APP_MAIL_START_TLS=OPTIONAL +APP_MAIL_TLS=false +APP_MAIL_USERNAME=null +APP_MAIL_PASSWORD=null +APP_MAIL_DEV_SIMULATE=false + +# - ACCOUNT CREATION - optional - an email is sent +APP_CREATE_USER_ON_START= + +# - SCHEDULING +# By default, the syntax used for cron expressions is based on Quartz +# - https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html +# You can change the syntax using the following property: +# quarkus.scheduler.cron-type=unix +APP_REPORT_INVALID_LINKS= \ No newline at end of file diff --git a/z3-application/pom.xml b/z3-application/pom.xml new file mode 100644 index 0000000..237a632 --- /dev/null +++ b/z3-application/pom.xml @@ -0,0 +1,187 @@ + + + z3-application + + + + + quarkus-maven-plugin + + + + build + generate-code + generate-code-tests + native-image-agent + + + + true + ${quarkus.platform.group-id} + ${quarkus.platform.version} + + + maven-compiler-plugin + + + -parameters + + + ${maven-compiler-plugin.version} + + + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Xshare:off + + + org.jboss.logmanager.LogManager + ${maven.home} + ${project.build.directory}/coverage-reports/jacoco.exec + + ${project.build.directory}/coverage-jacoco + + true + + + ${surefire-plugin.version} + + + maven-failsafe-plugin + + + org.jboss.logmanager.LogManager + ${maven.home} + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + integration-test + verify + + + + ${failsafe-plugin.version} + + + + + + + z3-domain + tech.pegase.z3 + ${z3.version} + + + z3-infrastructure + tech.pegase.z3 + ${z3.version} + + + z3-statistics-domain + tech.pegase.z3 + ${z3.version} + + + z3-statistics-infrastructure + tech.pegase.z3 + ${z3.version} + + + z3-shared + tech.pegase.z3 + ${z3.version} + + + quarkus-arc + io.quarkus + + + quarkus-rest + io.quarkus + + + quarkus-rest-jackson + io.quarkus + + + quarkus-narayana-jta + io.quarkus + + + quarkus-hibernate-validator + io.quarkus + + + quarkus-scheduler + io.quarkus + + + quarkus-vertx + io.quarkus + + + quarkus-jdbc-h2 + io.quarkus + test + + + quarkus-flyway + io.quarkus + + + flyway-database-postgresql + org.flywaydb + + + quarkus-junit5-mockito + io.quarkus + test + + + quarkus-jacoco + io.quarkus + test + + + happy-quarkus + tech.pegase.happy + ${pegase-happy.version} + + + opencsv + com.opencsv + ${opencsv.version} + + + tika-core + org.apache.tika + ${apache-tika.version} + + + ipaddress + com.github.seancfoley + ${seancfoley-ipaddress.version} + + + + 4.0.0 + + + z3 + tech.pegase.z3 + 1.1.0-SNAPSHOT + + + + 22 + 22 + UTF-8 + + diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/RootResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/RootResource.java new file mode 100644 index 0000000..d8f399c --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/RootResource.java @@ -0,0 +1,23 @@ +package tech.pegase.z3.application.api; + +import jakarta.annotation.security.PermitAll; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import org.jspecify.annotations.NullMarked; + +@NullMarked +@Path("/") +public class RootResource { + + @Inject + public RootResource() {} + + @GET + @Path("/") + @PermitAll + public Response getRoot() { + return Response.ok().entity("Z3 App running").build(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/ChangePasswordResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/ChangePasswordResource.java new file mode 100644 index 0000000..b2399a1 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/ChangePasswordResource.java @@ -0,0 +1,57 @@ +package tech.pegase.z3.application.api.v1.auth; + +import io.quarkus.security.Authenticated; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.application.presenter.auth.ChangePasswordPresenter; +import tech.pegase.z3.application.presenter.auth.ChangePasswordPresenter.MessageDto; +import tech.pegase.z3.application.request.auth.ChangePasswordRequest; +import tech.pegase.z3.domain.usecase.users.ChangeUserPassword; + +@Path("/api/v1/auth") +@Authenticated +public class ChangePasswordResource { + private final UseCaseExecutor useCaseExecutor; + private final ChangeUserPassword changeUserPassword; + private final ChangePasswordPresenter changePasswordPresenter; + private final UserContext userContext; + + ChangePasswordResource( + UseCaseExecutor executor, + ChangeUserPassword useCase, + ChangePasswordPresenter presenter, + UserContext userContext) { + useCaseExecutor = executor; + changeUserPassword = useCase; + changePasswordPresenter = presenter; + this.userContext = userContext; + } + + @PUT + @Path("/password") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse updateUser(@Valid ChangePasswordRequest request) { + var userContextId = userContext.getId(); + + if (userContextId == null) { + throw new ServerErrorException("Cannot get user context"); + } + + return useCaseExecutor + .forUseCase(changeUserPassword) + .withParameter( + new ChangeUserPassword.Input.WhereId( + userContextId, request.getPassword(), request.getNewPassword())) + .withPresenter(changePasswordPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/GetProfileResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/GetProfileResource.java new file mode 100644 index 0000000..da4964b --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/GetProfileResource.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.application.api.v1.auth; + +import io.quarkus.security.Authenticated; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.application.presenter.user.GetUserPresenter; +import tech.pegase.z3.application.presenter.user.GetUserPresenter.UserDto; +import tech.pegase.z3.domain.usecase.users.GetUser; + +@Path("/api/v1/auth") +@ApplicationScoped +public class GetProfileResource { + private final UseCaseExecutor useCaseExecutor; + private final GetUser getUserUseCase; + private final GetUserPresenter getUserPresenter; + private final UserContext userContext; + + public GetProfileResource( + UseCaseExecutor executor, + GetUser getUserUseCase, + GetUserPresenter getUserPresenter, + UserContext userContext) { + this.useCaseExecutor = executor; + this.getUserUseCase = getUserUseCase; + this.getUserPresenter = getUserPresenter; + this.userContext = userContext; + } + + @GET + @Path("/me") + @Authenticated + public RestResponse currentUser() { + var userContextId = userContext.getId(); + + if (userContextId == null) { + throw new ServerErrorException("Cannot get user context"); + } + + return useCaseExecutor + .forUseCase(getUserUseCase) + .withParameter(new GetUser.Input.ForId(userContextId)) + .withPresenter(getUserPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/LoginResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/LoginResource.java new file mode 100644 index 0000000..c9c6b24 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/LoginResource.java @@ -0,0 +1,45 @@ +package tech.pegase.z3.application.api.v1.auth; + +import jakarta.annotation.security.PermitAll; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.validation.Valid; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import org.jboss.resteasy.reactive.RestResponse; +import org.jspecify.annotations.NullMarked; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.auth.AuthenticateUserPresenter; +import tech.pegase.z3.application.presenter.auth.AuthenticateUserPresenter.JwtTokenJsonDto; +import tech.pegase.z3.application.request.auth.AuthRequest; +import tech.pegase.z3.domain.usecase.auth.AuthenticateUser; + +@NullMarked +@Path("/api/v1/auth") +@ApplicationScoped +public class LoginResource { + private final UseCaseExecutor useCaseExecutor; + private final AuthenticateUser authenticateUserUseCase; + private final AuthenticateUserPresenter authenticateUserPresenter; + + public LoginResource( + UseCaseExecutor executor, + AuthenticateUser useCase, + AuthenticateUserPresenter presenter) { + this.useCaseExecutor = executor; + this.authenticateUserUseCase = useCase; + this.authenticateUserPresenter = presenter; + } + + @POST + @Path("/login") + @PermitAll + public RestResponse auth(@Valid AuthRequest request) { + // TODO: add rate limiting. + return useCaseExecutor + .forUseCase(authenticateUserUseCase) + .withParameter( + new AuthenticateUser.Input.Authenticate(request.getLogin(), request.getPassword())) + .withPresenter(authenticateUserPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/package-info.java new file mode 100644 index 0000000..d70b5be --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/auth/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.api.v1.auth; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/CreateBrandResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/CreateBrandResource.java new file mode 100644 index 0000000..7d94bb6 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/CreateBrandResource.java @@ -0,0 +1,48 @@ +package tech.pegase.z3.application.api.v1.brand; + +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.brand.CreateBrandPresenter; +import tech.pegase.z3.application.presenter.brand.CreateBrandPresenter.BrandDto; +import tech.pegase.z3.application.request.brand.CreateBrandRequest; +import tech.pegase.z3.domain.usecase.brands.CreateBrand; +import tech.pegase.z3.domain.usecase.brands.CreateBrand.Input; + +@Authenticated +@Path("/api/v1/brands") +public class CreateBrandResource { + private final UseCaseExecutor useCaseExecutor; + private final CreateBrand createBrand; + private final CreateBrandPresenter createBrandPresenter; + + @Inject + public CreateBrandResource( + UseCaseExecutor executor, CreateBrand useCase, CreateBrandPresenter presenter) { + this.useCaseExecutor = executor; + this.createBrand = useCase; + this.createBrandPresenter = presenter; + } + + @POST + @Path("/") + @RolesAllowed("ADMINISTRATOR") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse create(@Valid CreateBrandRequest request) { + var parameter = new Input.With(request.getName(), request.getFqdns()); + return useCaseExecutor + .forUseCase(createBrand) + .withParameter(parameter) + .withPresenter(createBrandPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/GetBrandResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/GetBrandResource.java new file mode 100644 index 0000000..c3bdad5 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/GetBrandResource.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.application.api.v1.brand; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.brand.GetBrandPresenter; +import tech.pegase.z3.application.presenter.brand.GetBrandPresenter.BrandDto; +import tech.pegase.z3.domain.usecase.brands.GetBrand; +import tech.pegase.z3.domain.usecase.brands.GetBrand.Input.ForSlug; +import tech.pegase.z3.domain.valueobject.Slug; + +@Path("/api/v1/brands") +@Authenticated +public class GetBrandResource { + private final UseCaseExecutor useCaseExecutor; + private final GetBrand getBrand; + private final GetBrandPresenter getBrandResponsePresenter; + + @Inject + public GetBrandResource( + UseCaseExecutor executor, GetBrand useCase, GetBrandPresenter presenter) { + this.useCaseExecutor = executor; + this.getBrand = useCase; + this.getBrandResponsePresenter = presenter; + } + + @GET + @Path("/{brand}") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse getBrand(@PathParam("brand") Slug brand) { + return useCaseExecutor + .forUseCase(getBrand) + .withParameter(new ForSlug(brand)) + .withPresenter(getBrandResponsePresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/ListBrandsResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/ListBrandsResource.java new file mode 100644 index 0000000..a04a778 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/ListBrandsResource.java @@ -0,0 +1,51 @@ +package tech.pegase.z3.application.api.v1.brand; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.application.presenter.brand.ListBrandsPresenter; +import tech.pegase.z3.application.presenter.brand.ListBrandsPresenter.BrandDto; +import tech.pegase.z3.application.request.brand.PaginatedBrandListRequest; +import tech.pegase.z3.domain.usecase.brands.ListBrands; +import tech.pegase.z3.shared.pagination.Pagination; + +@Path("/api/v1/brands") +@Authenticated +public class ListBrandsResource { + private final UseCaseExecutor useCaseExecutor; + private final ListBrands listBrands; + private final ListBrandsPresenter listBrandsPresenter; + + @Inject + public ListBrandsResource( + UseCaseExecutor executor, ListBrands useCase, ListBrandsPresenter presenter) { + this.useCaseExecutor = executor; + this.listBrands = useCase; + this.listBrandsPresenter = presenter; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> getPaginatedBrandList( + @BeanParam @Valid PaginatedBrandListRequest request) { + return useCaseExecutor + .forUseCase(listBrands) + .withParameter(getParameterFromRequest(request)) + .withPresenter(listBrandsPresenter) + .run(); + } + + private ListBrands.Input getParameterFromRequest(PaginatedBrandListRequest request) { + var pagination = Pagination.fromPageNumberAndPageSize(request.getPage(), request.getPerPage()); + + return new ListBrands.Input.WithPagination(pagination); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/UpdateBrandResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/UpdateBrandResource.java new file mode 100644 index 0000000..f22cc42 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/UpdateBrandResource.java @@ -0,0 +1,45 @@ +package tech.pegase.z3.application.api.v1.brand; + +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.brand.UpdateBrandPresenter; +import tech.pegase.z3.application.presenter.brand.UpdateBrandPresenter.BrandDto; +import tech.pegase.z3.application.request.brand.UpdateBrandRequest; +import tech.pegase.z3.domain.usecase.brands.UpdateBrand; +import tech.pegase.z3.domain.usecase.brands.UpdateBrand.Input.ForSlug; +import tech.pegase.z3.domain.valueobject.Slug; + +@Path("/api/v1/brands") +@Authenticated +public class UpdateBrandResource { + private final UseCaseExecutor useCaseExecutor; + private final UpdateBrand updateBrandUseCase; + private final UpdateBrandPresenter updateBrandPresenter; + + @Inject + UpdateBrandResource( + UseCaseExecutor executor, UpdateBrand useCase, UpdateBrandPresenter presenter) { + useCaseExecutor = executor; + updateBrandUseCase = useCase; + updateBrandPresenter = presenter; + } + + @PUT + @Path("/{brand}") + @RolesAllowed("ADMINISTRATOR") + public RestResponse updateBrand( + @PathParam("brand") Slug brand, @Valid UpdateBrandRequest request) { + return useCaseExecutor + .forUseCase(updateBrandUseCase) + .withParameter(new ForSlug(brand, request.getName(), request.getFqdns())) + .withPresenter(updateBrandPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/package-info.java new file mode 100644 index 0000000..b21bcb8 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/brand/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.api.v1.brand; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/CreateCatalogResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/CreateCatalogResource.java new file mode 100644 index 0000000..9e8a179 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/CreateCatalogResource.java @@ -0,0 +1,48 @@ +package tech.pegase.z3.application.api.v1.catalog; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.catalog.CreateCatalogPresenter; +import tech.pegase.z3.application.presenter.catalog.CreateCatalogPresenter.CatalogDto; +import tech.pegase.z3.application.request.catalog.CreateCatalogRequest; +import tech.pegase.z3.domain.usecase.catalogs.CreateCatalog; +import tech.pegase.z3.domain.usecase.catalogs.CreateCatalog.Input; + +@Authenticated +@Path("/api/v1/catalogs") +public class CreateCatalogResource { + private final UseCaseExecutor useCaseExecutor; + private final CreateCatalog createCatalog; + private final CreateCatalogPresenter createCatalogPresenter; + + @Inject + public CreateCatalogResource( + UseCaseExecutor executor, + CreateCatalog useCase, + CreateCatalogPresenter presenter) { + this.useCaseExecutor = executor; + this.createCatalog = useCase; + this.createCatalogPresenter = presenter; + } + + @POST + @Path("/") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse create(@Valid CreateCatalogRequest request) { + Input parameter = new Input.With(request.getName(), request.getBrand()); + return useCaseExecutor + .forUseCase(createCatalog) + .withParameter(parameter) + .withPresenter(createCatalogPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/GetCatalogResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/GetCatalogResource.java new file mode 100644 index 0000000..8a6e1a9 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/GetCatalogResource.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.application.api.v1.catalog; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.catalog.GetCatalogPresenter; +import tech.pegase.z3.application.presenter.catalog.GetCatalogPresenter.CatalogDto; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog.Input.ForSlug; +import tech.pegase.z3.domain.valueobject.Slug; + +@Path("/api/v1/catalogs") +@Authenticated +public class GetCatalogResource { + private final UseCaseExecutor useCaseExecutor; + private final GetCatalog getCatalog; + private final GetCatalogPresenter getCatalogResponsePresenter; + + @Inject + public GetCatalogResource( + UseCaseExecutor executor, GetCatalog useCase, GetCatalogPresenter presenter) { + this.useCaseExecutor = executor; + this.getCatalog = useCase; + this.getCatalogResponsePresenter = presenter; + } + + @GET + @Path("/{catalog}") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse getCatalog(@PathParam("catalog") Slug catalog) { + return useCaseExecutor + .forUseCase(getCatalog) + .withParameter(new ForSlug(catalog)) + .withPresenter(getCatalogResponsePresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/ListCatalogsResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/ListCatalogsResource.java new file mode 100644 index 0000000..58eb6d4 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/ListCatalogsResource.java @@ -0,0 +1,58 @@ +package tech.pegase.z3.application.api.v1.catalog; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.application.presenter.catalog.ListCatalogsPresenter; +import tech.pegase.z3.application.presenter.catalog.ListCatalogsPresenter.CatalogDto; +import tech.pegase.z3.application.request.catalog.PaginatedCatalogListRequest; +import tech.pegase.z3.domain.usecase.catalogs.ListCatalogs; +import tech.pegase.z3.shared.pagination.Pagination; + +@Path("/api/v1/catalogs") +@Authenticated +public class ListCatalogsResource { + private final UseCaseExecutor useCaseExecutor; + private final ListCatalogs listCatalogs; + private final ListCatalogsPresenter listCatalogsPresenter; + + @Inject + public ListCatalogsResource( + UseCaseExecutor executor, ListCatalogs useCase, ListCatalogsPresenter presenter) { + this.useCaseExecutor = executor; + this.listCatalogs = useCase; + this.listCatalogsPresenter = presenter; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> getPaginatedCatalogList( + @BeanParam @Valid PaginatedCatalogListRequest request) { + return useCaseExecutor + .forUseCase(listCatalogs) + .withParameter(getParameterFromRequest(request)) + .withPresenter(listCatalogsPresenter) + .run(); + } + + private ListCatalogs.Input getParameterFromRequest(PaginatedCatalogListRequest request) { + var pagination = + Pagination.fromPageNumberAndPageSize(request.getPage(), request.getPerPage()); + + var brand = request.getBrand(); + + if (brand != null) { + return new ListCatalogs.Input.WithPaginationForBrand(brand, pagination); + } + + return new ListCatalogs.Input.WithPagination(pagination); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/RemoveCatalogResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/RemoveCatalogResource.java new file mode 100644 index 0000000..1051073 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/RemoveCatalogResource.java @@ -0,0 +1,45 @@ +package tech.pegase.z3.application.api.v1.catalog; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.dto.MessageDto; +import tech.pegase.z3.application.presenter.catalog.RemoveCatalogPresenter; +import tech.pegase.z3.domain.usecase.catalogs.RemoveCatalog; +import tech.pegase.z3.domain.usecase.catalogs.RemoveCatalog.Input.ForSlug; +import tech.pegase.z3.domain.valueobject.Slug; + +@Path("/api/v1/catalogs") +@Authenticated +public class RemoveCatalogResource { + private final UseCaseExecutor useCaseExecutor; + private final RemoveCatalog removeCatalogUseCase; + private final RemoveCatalogPresenter removeCatalogPresenter; + + @Inject + RemoveCatalogResource( + UseCaseExecutor executor, + RemoveCatalog useCase, + RemoveCatalogPresenter presenter) { + useCaseExecutor = executor; + removeCatalogUseCase = useCase; + removeCatalogPresenter = presenter; + } + + @DELETE + @Path("/{catalog}") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse removeCatalog(@PathParam("catalog") Slug catalog) { + return useCaseExecutor + .forUseCase(removeCatalogUseCase) + .withParameter(new ForSlug(catalog)) + .withPresenter(removeCatalogPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/UpdateCatalogResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/UpdateCatalogResource.java new file mode 100644 index 0000000..06a11c1 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/UpdateCatalogResource.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.application.api.v1.catalog; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.catalog.UpdateCatalogPresenter; +import tech.pegase.z3.application.presenter.catalog.UpdateCatalogPresenter.CatalogDto; +import tech.pegase.z3.application.request.catalog.UpdateCatalogRequest; +import tech.pegase.z3.domain.usecase.catalogs.UpdateCatalog; +import tech.pegase.z3.domain.usecase.catalogs.UpdateCatalog.Input.ForSlug; +import tech.pegase.z3.domain.valueobject.Slug; + +@Path("/api/v1/catalogs") +@Authenticated +public class UpdateCatalogResource { + private final UseCaseExecutor useCaseExecutor; + private final UpdateCatalog updateCatalogUseCase; + private final UpdateCatalogPresenter updateCatalogPresenter; + + @Inject + UpdateCatalogResource( + UseCaseExecutor executor, + UpdateCatalog useCase, + UpdateCatalogPresenter presenter) { + useCaseExecutor = executor; + updateCatalogUseCase = useCase; + updateCatalogPresenter = presenter; + } + + @PUT + @Path("/{catalog}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse updateCatalog( + @PathParam("catalog") Slug catalog, @Valid UpdateCatalogRequest request) { + return useCaseExecutor + .forUseCase(updateCatalogUseCase) + .withParameter(new ForSlug(catalog, request.getName())) + .withPresenter(updateCatalogPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/package-info.java new file mode 100644 index 0000000..c649862 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/catalog/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.api.v1.catalog; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/CreateLinkResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/CreateLinkResource.java new file mode 100644 index 0000000..243045f --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/CreateLinkResource.java @@ -0,0 +1,62 @@ +package tech.pegase.z3.application.api.v1.link; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.link.CreateLinkPresenter; +import tech.pegase.z3.application.presenter.link.CreateLinkPresenter.LinkDto; +import tech.pegase.z3.application.request.link.CreateLinkRequest; +import tech.pegase.z3.domain.usecase.links.CreateLink; +import tech.pegase.z3.domain.usecase.links.CreateLink.Input.CommonLinkInput; +import tech.pegase.z3.domain.usecase.links.CreateLink.Input.WithSourceAndUrl; +import tech.pegase.z3.domain.usecase.links.CreateLink.Input.WithUrl; + +@Path("/api/v1/links") +@Authenticated +public class CreateLinkResource { + private final UseCaseExecutor useCaseExecutor; + private final CreateLink createLink; + private final CreateLinkPresenter createLinkPresenter; + + @Inject + public CreateLinkResource( + UseCaseExecutor executor, CreateLink useCase, CreateLinkPresenter presenter) { + this.useCaseExecutor = executor; + this.createLink = useCase; + this.createLinkPresenter = presenter; + } + + @POST + @Path("/") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse create(CreateLinkRequest request) { + var commonLinkInput = from(request); + + CreateLink.Input parameter = + request.getSource() == null + ? new WithUrl(commonLinkInput) + : new WithSourceAndUrl(request.getSource(), commonLinkInput); + return useCaseExecutor + .forUseCase(createLink) + .withParameter(parameter) + .withPresenter(createLinkPresenter) + .run(); + } + + CommonLinkInput from(CreateLinkRequest request) { + var target = request.getTarget(); + var isExternal = request.isExternal(); + var brandUuid = request.getBrand(); + var tagUuid = request.getTag(); + var catalogUuid = request.getCatalog(); + + return new CommonLinkInput(target, isExternal, brandUuid, tagUuid, catalogUuid); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ExportLinksResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ExportLinksResource.java new file mode 100644 index 0000000..bb2c27e --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ExportLinksResource.java @@ -0,0 +1,217 @@ +package tech.pegase.z3.application.api.v1.link; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.core.StreamingOutput; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Optional; +import java.util.function.Function; +import org.eclipse.microprofile.config.ConfigProvider; +import org.jboss.resteasy.reactive.RestResponse; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.brand.GetBrandPresenter; +import tech.pegase.z3.application.presenter.catalog.GetCatalogPresenter; +import tech.pegase.z3.application.presenter.link.CsvPartialExportLinkPresenter; +import tech.pegase.z3.application.presenter.link.CsvPartialExportLinkPresenter.PartialExport; +import tech.pegase.z3.domain.usecase.brands.GetBrand; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog; +import tech.pegase.z3.domain.usecase.links.ExportLinks; +import tech.pegase.z3.domain.usecase.links.ExportLinks.Input; +import tech.pegase.z3.domain.valueobject.Slug; + +@Path("/api/v1/links-export") +@Authenticated +public class ExportLinksResource { + private final UseCaseExecutor useCaseExecutor; + private final ExportLinks exportLinks; + private final GetBrand getBrand; + private final GetCatalog getCatalog; + private final CsvPartialExportLinkPresenter csvPartialExportLinkPresenter; + + @Inject + public ExportLinksResource( + UseCaseExecutor executor, + ExportLinks useCase, + GetBrand getBrandUseCase, + GetCatalog getCatalogUseCase, + CsvPartialExportLinkPresenter presenter) { + this.useCaseExecutor = executor; + this.exportLinks = useCase; + this.getBrand = getBrandUseCase; + this.getCatalog = getCatalogUseCase; + this.csvPartialExportLinkPresenter = presenter; + } + + @GET + @Path("/") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public RestResponse exportLink( + @QueryParam("per-page") @Valid @jakarta.annotation.Nullable @Nullable @Min(1) @Max(50) + Integer perPage) { + try { + perPage = Optional.ofNullable(perPage).orElse(50); + csvPartialExportLinkPresenter.setWithHeaders(true); + + var output = genOutput(perPage); + + return buildResponse(output, "external_links"); + } catch (IllegalStateException e) { + return RestResponse.status(Response.Status.BAD_REQUEST); + } + } + + @GET + @Path("/brands/{brand: [a-zA-Z0-9-]+}") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public RestResponse exportBrandLinks( + @PathParam("brand") Slug brand, + @QueryParam("per-page") @Valid @jakarta.annotation.Nullable @Nullable @Min(1) @Max(50) + Integer perPage) { + try { + perPage = Optional.ofNullable(perPage).orElse(50); + var resolvedBrand = resolveBrand(brand); + + if (resolvedBrand == null) { + return RestResponse.status(Status.NOT_FOUND); + } + + csvPartialExportLinkPresenter.setWithHeaders(true); + + var output = genBrandOutput(brand, perPage); + + return buildResponse(output, brand.toString()); + } catch (IllegalStateException e) { + return RestResponse.status(Response.Status.BAD_REQUEST); + } + } + + @GET + @Path("/catalogs/{catalog: [a-zA-Z0-9-]+}") + @Produces(MediaType.APPLICATION_OCTET_STREAM) + public RestResponse exportCatalogLinks( + @PathParam("catalog") Slug catalog, + @QueryParam("per-page") @Valid @jakarta.annotation.Nullable @Nullable @Min(1) @Max(50) + Integer perPage) { + try { + perPage = Optional.ofNullable(perPage).orElse(50); + var resolvedCatalog = resolveCatalog(catalog); + + if (resolvedCatalog == null) { + return RestResponse.status(Status.NOT_FOUND); + } + + csvPartialExportLinkPresenter.setWithHeaders(true); + + var output = genCatalogOutput(catalog, perPage); + + return buildResponse(output, catalog.toString()); + } catch (IllegalStateException e) { + return RestResponse.status(Response.Status.BAD_REQUEST); + } + } + + private GetBrandPresenter.@Nullable BrandDto resolveBrand(Slug brandSlug) { + try (var response = + useCaseExecutor + .forUseCase(getBrand) + .withParameter(new GetBrand.Input.ForSlug(brandSlug)) + .withPresenter( + new GetBrandPresenter() { + @Override + public RestResponse<@Nullable BrandDto> present(GetBrand.Output output) { + if (output instanceof GetBrand.Output.Found) { + return RestResponse.ok(BrandDto.of(((GetBrand.Output.Found) output).brand())); + } + return RestResponse.notFound(); + } + }) + .run()) { + + return response.getStatusInfo().equals(Status.NOT_FOUND) ? null : response.getEntity(); + } + } + + private GetCatalogPresenter.@Nullable CatalogDto resolveCatalog(Slug catalogSlug) { + try (var response = + useCaseExecutor + .forUseCase(getCatalog) + .withParameter(new GetCatalog.Input.ForSlug(catalogSlug)) + .withPresenter( + new GetCatalogPresenter() { + @Override + public RestResponse<@Nullable CatalogDto> present(GetCatalog.Output output) { + if (output instanceof GetCatalog.Output.Found) { + return RestResponse.ok( + CatalogDto.of(((GetCatalog.Output.Found) output).catalog())); + } + return RestResponse.notFound(); + } + }) + .run()) { + + return response.getStatusInfo().equals(Status.NOT_FOUND) ? null : response.getEntity(); + } + } + + private StreamingOutput genBrandOutput(Slug brandSlug, int perPage) { + return genOutputWith( + page -> new Input.PageForBrand(brandSlug, new Input.Pagination(page, perPage))); + } + + private StreamingOutput genCatalogOutput(Slug catalogSlug, int perPage) { + return genOutputWith( + page -> new Input.PageForCatalog(catalogSlug, new Input.Pagination(page, perPage))); + } + + private StreamingOutput genOutput(int perPage) { + return genOutputWith(page -> new Input.PageForNonBranded(new Input.Pagination(page, perPage))); + } + + private StreamingOutput genOutputWith(Function inputFunction) { + return out -> { + var page = 0; + PartialExport exportPart; + do { + exportPart = + useCaseExecutor + .forUseCase(exportLinks) + .withParameter(inputFunction.apply(page++)) + .withPresenter(csvPartialExportLinkPresenter) + .run(); + if (exportPart instanceof PartialExport.Part(var partContent)) { + out.write(partContent.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + } while (exportPart instanceof PartialExport.Part); + }; + } + + private RestResponse buildResponse(StreamingOutput output, String exportName) { + var appName = ConfigProvider.getConfig().getValue("quarkus.application.name", String.class); + + return RestResponse.ResponseBuilder.create(Status.OK, output) + .header( + "Content-Disposition", + "attachment; filename=\"%s-export-%s-%s.csv\";" + .formatted( + appName, exportName, new SimpleDateFormat("dd-MM-yyyy").format(new Date()))) + .header("Content-Type", "application/csv; charset=UTF-8") + .header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate") + .header("Pragma", "no-cache") + .build(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/GetLinkResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/GetLinkResource.java new file mode 100644 index 0000000..b1b8a20 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/GetLinkResource.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.application.api.v1.link; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.link.GetLinkPresenter; +import tech.pegase.z3.application.presenter.link.GetLinkPresenter.LinkDto; +import tech.pegase.z3.domain.usecase.links.GetLink; +import tech.pegase.z3.domain.usecase.links.GetLink.Input.ForSource; +import tech.pegase.z3.shared.valueobject.Source; + +@Path("/api/v1/links") +@Authenticated +public class GetLinkResource { + private final UseCaseExecutor useCaseExecutor; + private final GetLink getLink; + private final GetLinkPresenter getLinkResponsePresenter; + + @Inject + public GetLinkResource(UseCaseExecutor executor, GetLink useCase, GetLinkPresenter presenter) { + this.useCaseExecutor = executor; + this.getLink = useCase; + this.getLinkResponsePresenter = presenter; + } + + @GET + @Path("/{source: [a-zA-Z0-9/._~!$'()*+,;=:@-]+}") + @Produces(MediaType.APPLICATION_JSON) + /* Ici source correspond à la partie path + query + anchor d'une URL sur un QR code. */ + public RestResponse getLink(@PathParam("source") Source source) { + return useCaseExecutor + .forUseCase(getLink) + .withParameter(new ForSource(source)) + .withPresenter(getLinkResponsePresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ImportLinksResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ImportLinksResource.java new file mode 100644 index 0000000..3e915b4 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ImportLinksResource.java @@ -0,0 +1,231 @@ +package tech.pegase.z3.application.api.v1.link; + +import com.opencsv.CSVParser; +import com.opencsv.CSVParserBuilder; +import com.opencsv.CSVReader; +import com.opencsv.CSVReaderBuilder; +import com.opencsv.exceptions.CsvException; +import com.opencsv.exceptions.CsvValidationException; +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.apache.commons.io.input.BOMInputStream; +import org.apache.tika.Tika; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.PartType; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.multipart.FileUpload; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.exception.ValidationException.ValidationError; +import tech.pegase.z3.application.presenter.link.ImportLinksPresenter; +import tech.pegase.z3.application.presenter.link.ImportLinksPresenter.ImportFailuresDto; +import tech.pegase.z3.domain.usecase.links.ImportLinks; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Input.ImportLine; + +@Path("/api/v1/links-import/") +@Authenticated +public class ImportLinksResource { + private static final Logger logger = Logger.getLogger(ImportLinksResource.class.getName()); + private final UseCaseExecutor useCaseExecutor; + private final ImportLinks importLinks; + private final ImportLinksPresenter importLinksPresenter; + + @Inject + public ImportLinksResource( + UseCaseExecutor executor, ImportLinks useCase, ImportLinksPresenter presenter) { + this.useCaseExecutor = executor; + this.importLinks = useCase; + this.importLinksPresenter = presenter; + } + + @POST + @Path("/brands/{brand: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse importForBrand( + @PathParam("brand") UUID brandUuid, + @Nullable @RestForm("tag") UUID tagUuid, + @RestForm("file") @PartType("application/octet-stream") FileUpload file) { + try { + var importLines = uploadedFileToImportLine(file); + + return useCaseExecutor + .forUseCase(importLinks) + .withParameter(new ImportLinks.Input.ImportForBrand(brandUuid, tagUuid, importLines)) + .withPresenter(importLinksPresenter) + .run(); + } catch (IllegalStateException | CsvException e) { + logger.debug(e.getMessage(), e); + return RestResponse.status(Response.Status.BAD_REQUEST); + } catch (IOException e) { + logger.warn(e.getMessage(), e); + throw new RuntimeException(e); + } + } + + @POST + @Path("/catalogs/{catalog: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse importForCatalog( + @PathParam("catalog") UUID catalogUuid, + @Nullable @RestForm("tag") UUID tagUuid, + @RestForm("file") @PartType("application/octet-stream") FileUpload file) { + try { + var importLines = uploadedFileToImportLine(file); + + return useCaseExecutor + .forUseCase(importLinks) + .withParameter(new ImportLinks.Input.ImportForCatalog(catalogUuid, tagUuid, importLines)) + .withPresenter(importLinksPresenter) + .run(); + } catch (IllegalStateException | CsvException e) { + logger.debug(e.getMessage(), e); + return RestResponse.status(Response.Status.BAD_REQUEST); + } catch (IOException e) { + logger.warn(e.getMessage(), e); + throw new RuntimeException(e); + } + } + + private List uploadedFileToImportLine( + FileUpload file) throws IOException, CsvException { + var requiredColumns = List.of("source", "target", "isExternal"); + var delimiterToUse = detectDelimiterWithColumns(file, requiredColumns, ';'); + + assertFileHasHeader(file, requiredColumns, delimiterToUse); + assertUploadedFileIsPlainText(file); + + List output = new ArrayList<>(); + + try (var inputStream = Files.newInputStream(file.uploadedFile())) { + var csvReader = fromInputStream(inputStream, delimiterToUse); + var lines = csvReader.readAll(); + // Skip header line. + lines.removeFirst(); + for (var line : lines) { + var importLine = listToImportLine(List.of(line)); + output.add(importLine); + } + + return output; + } + } + + private Character detectDelimiterWithColumns( + FileUpload file, List columns, Character defaultDelimiter) { + var availableDelimiters = Arrays.asList(';', ','); + + return availableDelimiters.stream() + .filter( + delimiter -> { + try (var inputStream = Files.newInputStream(file.uploadedFile())) { + var csvReader = fromInputStream(inputStream, delimiter); + var firstLine = List.of(csvReader.readNext()); + + return columns.equals(firstLine); + } catch (CsvValidationException e) { + return false; + } catch (IOException e) { + logger.warn(e.getMessage(), e); + throw new RuntimeException(e); + } + }) + .findFirst() + .orElse(defaultDelimiter); + } + + private ImportLine listToImportLine(List values) { + if (values.size() < 3) { + throw new IllegalStateException("Import lines requires at least three values"); + } + var source = values.getFirst(); + var url = values.get(1); + var isExternal = Boolean.parseBoolean(values.get(2)); + return new ImportLine(source, url, isExternal); + } + + private void assertFileHasHeader(FileUpload file, List columns, Character delimiter) + throws IOException { + try (var inputStream = Files.newInputStream(file.uploadedFile())) { + var csvReader = fromInputStream(inputStream, delimiter); + var firstLine = List.of(csvReader.readNext()); + + if (firstLine.size() != 3) { + throw ValidationException.of( + ValidationError.of("file", "La première ligne est invalide, elle à besoin 3 columns")); + } + + if (!columns.equals(firstLine)) { + throw ValidationException.of( + ValidationError.of( + "file", + "La première ligne est invalide, il faut 'source', 'target', 'isExternal'")); + } + } catch (CsvValidationException e) { + logger.debug(e.getMessage()); + throw new ServerErrorException(e); + } + } + + private void assertUploadedFileIsPlainText(FileUpload file) throws IOException { + var guessedMimeType = guessMimeType(file); + + if (!guessedMimeType.equals(MediaType.TEXT_PLAIN)) { + logger.debug("File is not plain text"); + throw ValidationException.of( + ValidationError.of("file", "File le fichier doit contenir du texte/csv uniquement")); + } + } + + private CSVReader fromInputStream(InputStream inputStream, Character delimiter) + throws IOException { + var bomInputStream = + BOMInputStream.builder().setInputStream(inputStream).setInclude(false).get(); + var reader = new InputStreamReader(new BufferedInputStream(bomInputStream)); + return getCsvReader(reader, delimiter); + } + + private CSVReader getCsvReader(Reader reader, Character delimiter) { + var builder = new CSVReaderBuilder(reader); + var parser = getCsvParser(delimiter); + + builder.withCSVParser(parser); + + return builder.build(); + } + + private CSVParser getCsvParser(Character delimiter) { + var csvParserBuilder = new CSVParserBuilder(); + + csvParserBuilder.withSeparator(delimiter); + + return csvParserBuilder.build(); + } + + private String guessMimeType(FileUpload file) throws IOException { + var tika = new Tika(); + return tika.detect(new File(file.uploadedFile().toUri())); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ListLinksResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ListLinksResource.java new file mode 100644 index 0000000..89a33c7 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/ListLinksResource.java @@ -0,0 +1,100 @@ +package tech.pegase.z3.application.api.v1.link; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Objects; +import org.jboss.resteasy.reactive.RestResponse; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.exception.ValidationException.ValidationError; +import tech.pegase.z3.application.presenter.link.ListLinksPresenter; +import tech.pegase.z3.application.presenter.link.ListLinksPresenter.LinkDto; +import tech.pegase.z3.application.request.link.PaginatedLinkListRequest; +import tech.pegase.z3.domain.usecase.links.ListLinks; +import tech.pegase.z3.domain.valueobject.concepts.Ordering; +import tech.pegase.z3.domain.valueobject.concepts.Ordering.Direction; +import tech.pegase.z3.domain.valueobject.concepts.Searching; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering.Field; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; +import tech.pegase.z3.shared.pagination.Pagination; + +@Path("/api/v1/links") +@Authenticated +public class ListLinksResource { + private final UseCaseExecutor useCaseExecutor; + private final ListLinks listLinks; + private final ListLinksPresenter listLinksPresenter; + + @Inject + public ListLinksResource( + UseCaseExecutor executor, ListLinks useCase, ListLinksPresenter presenter) { + this.useCaseExecutor = executor; + this.listLinks = useCase; + this.listLinksPresenter = presenter; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> getPaginatedLinkList( + @BeanParam @Valid PaginatedLinkListRequest request) { + return useCaseExecutor + .forUseCase(listLinks) + .withParameter(getParameterFromRequest(request)) + .withPresenter(listLinksPresenter) + .run(); + } + + private ListLinks.Input getParameterFromRequest(PaginatedLinkListRequest request) { + var pagination = Pagination.fromPageNumberAndPageSize(request.getPage(), request.getPerPage()); + var orderBy = request.getOrderBy(); + var direction = request.getDirection(); + var searchOn = request.getSearchOn(); + var keyword = request.getKeywords(); + var filters = request.getFilters(); + + var defaultOrdering = LinkOrdering.of(Field.SOURCE, Direction.NATURAL); + + assertCanOrder(orderBy, direction); + assertCanSearch(searchOn, keyword); + + var ordering = + (orderBy != null && direction != null) ? LinkOrdering.of(orderBy, direction) : null; + var searching = + (searchOn != null && keyword != null) ? LinkSearching.of(searchOn, keyword) : null; + + if (searching != null) { + return new ListLinks.Input.WithPaginationAndSearchingAllSorted( + pagination, filters, Objects.requireNonNullElse(ordering, defaultOrdering), searching); + } + + return new ListLinks.Input.WithPaginationSorted( + pagination, filters, Objects.requireNonNullElse(ordering, defaultOrdering)); + } + + private void assertCanOrder( + LinkOrdering.@Nullable Field orderBy, Ordering.@Nullable Direction direction) { + if (orderBy != null ^ direction != null) { + throw ValidationException.of( + ValidationError.of("orderBy", "orderBy est requis avec direction"), + ValidationError.of("direction", "direction est requis avec orderBy")); + } + } + + private void assertCanSearch( + LinkSearching.@Nullable Field searchOn, Searching.@Nullable SearchValue keywords) { + if (searchOn != null ^ keywords != null) { + throw ValidationException.of( + ValidationError.of("searchOn", "searchOn est requis avec keywords"), + ValidationError.of("keywords", "keywords est requis avec searchOn")); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/RedirectLinkResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/RedirectLinkResource.java new file mode 100644 index 0000000..37a9cb5 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/RedirectLinkResource.java @@ -0,0 +1,99 @@ +package tech.pegase.z3.application.api.v1.link; + +import io.vertx.core.eventbus.EventBus; +import jakarta.annotation.Nullable; +import jakarta.annotation.security.PermitAll; +import jakarta.validation.Valid; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; +import java.time.ZonedDateTime; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.events.LinkRedirectedEvent; +import tech.pegase.z3.application.presenter.link.ResolveLinkResponsePresenter; +import tech.pegase.z3.application.request.link.RedirectLinkRequest; +import tech.pegase.z3.domain.usecase.links.RedirectLink; +import tech.pegase.z3.domain.usecase.links.RedirectLink.Input; +import tech.pegase.z3.domain.usecase.links.RedirectLink.Output; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.shared.valueobject.Source; + +@Path("/") +public class RedirectLinkResource { + private final Fqdn resolverFqdn; + private final UseCaseExecutor useCaseExecutor; + private final RedirectLink redirectLink; + private final ResolveLinkResponsePresenter redirectLinkResponsePresenter; + private final EventBus eventBus; + + public RedirectLinkResource( + @ConfigProperty(name = "z3.export.resolver-fqdn") String resolverFqdn, + UseCaseExecutor executor, + RedirectLink useCase, + ResolveLinkResponsePresenter presenter, + EventBus eventBus) { + this.useCaseExecutor = executor; + this.redirectLink = useCase; + this.redirectLinkResponsePresenter = presenter; + this.resolverFqdn = new Fqdn(resolverFqdn); + this.eventBus = eventBus; + } + + @GET + @Path("/{source: [a-zA-Z0-9/._~!$'()*+,;=:@-]+}") + @PermitAll + public Response redirect( + @PathParam("source") Source source, + @QueryParam("host") @Valid @Nullable Fqdn host, + @Valid RedirectLinkRequest request) { + + // * Use default Fqdn if none provided + var actualHost = host != null ? host : resolverFqdn; + + return useCaseExecutor + .forUseCase(redirectLink) + .withParameter(new Input.ResolveWith(resolverFqdn, actualHost, source)) + .withPresenter( + new ResolveLinkResponsePresenter() { + @Override + public Response present(Output output) { + var linkIsResolved = output instanceof Output.Found; + + dispatchVisitedLink(source, linkIsResolved, request); + + return redirectLinkResponsePresenter.present(output); + } + }) + .run(); + } + + private void dispatchVisitedLink( + Source source, Boolean linkIsResolved, RedirectLinkRequest request) { + var ipAddress = request.getIpAddress(); + var doNotTrack = request.getDoNotTrack(); + var requestedAt = ZonedDateTime.now(); + var locale = request.getLocale(); + var referrerUri = request.getReferrerUri(); + var userAgent = request.getUserAgent(); + var screenProperties = request.getScreenProperties(); + var fingerprint = request.getFingerprint(); + + var eventPojo = + new LinkRedirectedEvent( + source, + linkIsResolved, + requestedAt, + doNotTrack, + ipAddress, + locale, + referrerUri, + userAgent, + fingerprint, + screenProperties); + + eventBus.publish("LinkRedirectedEvent", eventPojo); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/UpdateLinkResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/UpdateLinkResource.java new file mode 100644 index 0000000..ed58278 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/UpdateLinkResource.java @@ -0,0 +1,53 @@ +package tech.pegase.z3.application.api.v1.link; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.link.UpdateLinkPresenter; +import tech.pegase.z3.application.presenter.link.UpdateLinkPresenter.LinkDto; +import tech.pegase.z3.application.request.link.UpdateLinkRequest; +import tech.pegase.z3.domain.usecase.links.UpdateLink; +import tech.pegase.z3.domain.usecase.links.UpdateLink.Input.ForSource; +import tech.pegase.z3.shared.valueobject.Source; + +@Path("/api/v1/links") +@Authenticated +public class UpdateLinkResource { + private final UseCaseExecutor useCaseExecutor; + private final UpdateLink updateLinkUseCase; + private final UpdateLinkPresenter updateLinkPresenter; + + @Inject + UpdateLinkResource(UseCaseExecutor executor, UpdateLink useCase, UpdateLinkPresenter presenter) { + useCaseExecutor = executor; + updateLinkUseCase = useCase; + updateLinkPresenter = presenter; + } + + @PUT + @Path("/{source: [a-zA-Z0-9/._~!$'()*+,;=:@-]+}") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public RestResponse updateLink( + @PathParam("source") Source source, @Valid UpdateLinkRequest request) { + return useCaseExecutor + .forUseCase(updateLinkUseCase) + .withParameter( + new ForSource( + source, + request.getTarget(), + request.isExternal(), + request.getTag(), + request.getCatalog())) + .withPresenter(updateLinkPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/VerifyLinkResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/VerifyLinkResource.java new file mode 100644 index 0000000..44a86b7 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/VerifyLinkResource.java @@ -0,0 +1,44 @@ +package tech.pegase.z3.application.api.v1.link; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.link.VerifyLinkPresenter; +import tech.pegase.z3.application.presenter.link.VerifyLinkPresenter.LinkValidityDto; +import tech.pegase.z3.domain.usecase.links.VerifyLink; +import tech.pegase.z3.domain.usecase.links.VerifyLink.Input; +import tech.pegase.z3.shared.valueobject.Source; + +@Path("/api/v1/links") +@Authenticated +public class VerifyLinkResource { + private final UseCaseExecutor useCaseExecutor; + private final VerifyLink verifyLink; + private final VerifyLinkPresenter verifyLinkPresenter; + + @Inject + public VerifyLinkResource( + UseCaseExecutor executor, VerifyLink useCase, VerifyLinkPresenter presenter) { + this.useCaseExecutor = executor; + this.verifyLink = useCase; + this.verifyLinkPresenter = presenter; + } + + @PUT + @Path("/{source: [a-zA-Z0-9/._~!$'()*+,;=:@-]+}/verify") + @Produces(MediaType.APPLICATION_JSON) + /* Ici source correspond à la partie path + query + anchor d'une URL sur un QR code. */ + public RestResponse verifyLinkValidity(@PathParam("source") Source source) { + return useCaseExecutor + .forUseCase(verifyLink) + .withParameter(new Input.Verify(source)) + .withPresenter(verifyLinkPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/package-info.java new file mode 100644 index 0000000..7b3cfd9 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/link/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.api.v1.link; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/reporting/GetReportingSubscriptionsResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/reporting/GetReportingSubscriptionsResource.java new file mode 100644 index 0000000..33b00da --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/reporting/GetReportingSubscriptionsResource.java @@ -0,0 +1,47 @@ +package tech.pegase.z3.application.api.v1.reporting; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.Set; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.reporting.GetReportingSubscriptionsPresenter; +import tech.pegase.z3.application.presenter.reporting.GetReportingSubscriptionsPresenter.ReportingSubscriptionDto; +import tech.pegase.z3.domain.usecase.reporting.GetReportingSubscriptions; +import tech.pegase.z3.domain.usecase.reporting.GetReportingSubscriptions.Input; + +@Authenticated +@Path("/api/v1/reporting") +public class GetReportingSubscriptionsResource { + private final UseCaseExecutor useCaseExecutor; + private final GetReportingSubscriptions getReportingSubscriptions; + private final GetReportingSubscriptionsPresenter getReportingSubscriptionsPresenter; + + @Inject + public GetReportingSubscriptionsResource( + UseCaseExecutor executor, + GetReportingSubscriptions useCase, + GetReportingSubscriptionsPresenter presenter) { + this.useCaseExecutor = executor; + this.getReportingSubscriptions = useCase; + this.getReportingSubscriptionsPresenter = presenter; + } + + @GET + @Path("/subscriptions/{user}") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> getReportingSubscriptions( + @PathParam("user") UUID userUuid) { + return useCaseExecutor + .forUseCase(getReportingSubscriptions) + .withParameter(new Input.GetSubscriptionsForUser(userUuid)) + .withPresenter(getReportingSubscriptionsPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/reporting/UpdateUserReportingSubscriptionsResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/reporting/UpdateUserReportingSubscriptionsResource.java new file mode 100644 index 0000000..abb5dbd --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/reporting/UpdateUserReportingSubscriptionsResource.java @@ -0,0 +1,48 @@ +package tech.pegase.z3.application.api.v1.reporting; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.reporting.UpdateUserReportingSubscriptionsPresenter; +import tech.pegase.z3.application.presenter.reporting.UpdateUserReportingSubscriptionsPresenter.MessageDto; +import tech.pegase.z3.application.request.reporting.UpdateUserReportingSubscriptionsRequest; +import tech.pegase.z3.domain.usecase.reporting.UpdateUserReportingSubscriptions; +import tech.pegase.z3.domain.usecase.reporting.UpdateUserReportingSubscriptions.Input; + +@Authenticated +@Path("/api/v1/reporting") +public class UpdateUserReportingSubscriptionsResource { + private final UseCaseExecutor useCaseExecutor; + private final UpdateUserReportingSubscriptions updateUserReportingSubscriptions; + private final UpdateUserReportingSubscriptionsPresenter updateUserReportingSubscriptionsPresenter; + + @Inject + public UpdateUserReportingSubscriptionsResource( + UseCaseExecutor executor, + UpdateUserReportingSubscriptions useCase, + UpdateUserReportingSubscriptionsPresenter presenter) { + this.useCaseExecutor = executor; + this.updateUserReportingSubscriptions = useCase; + this.updateUserReportingSubscriptionsPresenter = presenter; + } + + @POST + @Path("/subscriptions/{user}") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse getReportingSubscriptions( + @PathParam("user") UUID userUuid, @Valid UpdateUserReportingSubscriptionsRequest request) { + return useCaseExecutor + .forUseCase(updateUserReportingSubscriptions) + .withParameter(new Input.UpdateSubscriptionForUser(userUuid, request.getBrands())) + .withPresenter(updateUserReportingSubscriptionsPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/GetLinksStatisticsResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/GetLinksStatisticsResource.java new file mode 100644 index 0000000..4f1477b --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/GetLinksStatisticsResource.java @@ -0,0 +1,109 @@ +package tech.pegase.z3.application.api.v1.statistics; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.constraints.NotNull; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.statistics.GetGlobalStatisticsPresenter; +import tech.pegase.z3.application.presenter.statistics.GetGlobalStatisticsPresenter.GlobalStatistic; +import tech.pegase.z3.application.presenter.statistics.GetLinksStatisticsPresenter; +import tech.pegase.z3.application.presenter.statistics.GetLinksStatisticsPresenter.StatisticLine; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Input.GetStatistics; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Input.GetStatisticsForBrand; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Input.ForBrands; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Input.ForCatalogs; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Input.ForTags; + +@Path("/api/v1/links/statistics") +@Authenticated +public class GetLinksStatisticsResource { + private final UseCaseExecutor useCaseExecutor; + private final GetGlobalStatistics getGlobalStatistics; + private final GetGlobalStatisticsPresenter getGlobalStatisticsPresenter; + private final GetLinksStatistics getLinksStatistics; + private final GetLinksStatisticsPresenter getLinksStatisticsPresenter; + + @Inject + public GetLinksStatisticsResource( + UseCaseExecutor executor, + GetGlobalStatistics getGlobalStatistics, + GetLinksStatistics getLinksStatistics, + GetGlobalStatisticsPresenter getGlobalStatisticsPresenter, + GetLinksStatisticsPresenter getLinksStatisticsPresenter) { + this.useCaseExecutor = executor; + this.getGlobalStatistics = getGlobalStatistics; + this.getLinksStatistics = getLinksStatistics; + this.getGlobalStatisticsPresenter = getGlobalStatisticsPresenter; + this.getLinksStatisticsPresenter = getLinksStatisticsPresenter; + } + + @GET + @Path("/") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> globalStatistic() { + return useCaseExecutor + .forUseCase(getGlobalStatistics) + .withParameter(new GetStatistics()) + .withPresenter(getGlobalStatisticsPresenter) + .run(); + } + + @GET + @Path("/{brand}") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> globalStatisticForBrand( + @PathParam("brand") UUID brandUuid) { + return useCaseExecutor + .forUseCase(getGlobalStatistics) + .withParameter(new GetStatisticsForBrand(brandUuid)) + .withPresenter(getGlobalStatisticsPresenter) + .run(); + } + + @GET + @Path("/brands") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> statisticForBrands() { + return useCaseExecutor + .forUseCase(getLinksStatistics) + .withParameter(new ForBrands()) + .withPresenter(getLinksStatisticsPresenter) + .run(); + } + + @GET + @Path("/catalog") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> statisticForCatalogOf( + @NotNull @QueryParam("brand") UUID brandUuid) { + return useCaseExecutor + .forUseCase(getLinksStatistics) + .withParameter(new ForCatalogs(brandUuid)) + .withPresenter(getLinksStatisticsPresenter) + .run(); + } + + @GET + @Path("/tag") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> statisticForTag( + @NotNull @QueryParam("brand") UUID brandUuid) { + return useCaseExecutor + .forUseCase(getLinksStatistics) + .withParameter(new ForTags(brandUuid)) + .withPresenter(getLinksStatisticsPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/InvalidLinksResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/InvalidLinksResource.java new file mode 100644 index 0000000..e3a08da --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/InvalidLinksResource.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.application.api.v1.statistics; + +import io.quarkus.security.Authenticated; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.Map; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.statistics.InvalidLinksPerWeekPresenter; +import tech.pegase.z3.application.presenter.statistics.InvalidLinksPerWeekPresenter.WeekInvalidLinks; +import tech.pegase.z3.statistics.domain.usecase.statistics.GetInvalidLinksCountsPerWeek; +import tech.pegase.z3.statistics.domain.usecase.statistics.GetInvalidLinksCountsPerWeek.Input.ByBrandForThePastYear; + +@Path("/api/v1/statistics/invalid-links") +@Authenticated +public class InvalidLinksResource { + private final UseCaseExecutor useCaseExecutor; + private final GetInvalidLinksCountsPerWeek invalidLinksPerWeek; + private final InvalidLinksPerWeekPresenter invalidLinksPerWeekPresenter; + + InvalidLinksResource( + UseCaseExecutor useCaseExecutor, + GetInvalidLinksCountsPerWeek getInvalidLinksCountsPerWeek, + InvalidLinksPerWeekPresenter invalidLinksPerWeekPresenter) { + this.useCaseExecutor = useCaseExecutor; + this.invalidLinksPerWeek = getInvalidLinksCountsPerWeek; + this.invalidLinksPerWeekPresenter = invalidLinksPerWeekPresenter; + } + + @GET + @Path("/per-week-per-brand") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse>> invalidLinksPerWeek() { + return useCaseExecutor + .forUseCase(invalidLinksPerWeek) + .withParameter(new ByBrandForThePastYear()) + .withPresenter(invalidLinksPerWeekPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/TopListResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/TopListResource.java new file mode 100644 index 0000000..0e95322 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/TopListResource.java @@ -0,0 +1,61 @@ +package tech.pegase.z3.application.api.v1.statistics; + +import io.quarkus.security.Authenticated; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.statistics.MostFrequentReferrersPresenter; +import tech.pegase.z3.application.presenter.statistics.MostFrequentReferrersPresenter.TopReferrerLine; +import tech.pegase.z3.application.presenter.statistics.MostVisitedSourcePresenter; +import tech.pegase.z3.application.presenter.statistics.MostVisitedSourcePresenter.TopSourceLine; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostFrequentReferrers; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostVisitedSource; + +@Path("/api/v1/statistics/top") +@Authenticated +public class TopListResource { + private final UseCaseExecutor useCaseExecutor; + private final MostFrequentReferrers mostFrequentReferrers; + private final MostFrequentReferrersPresenter mostFrequentReferrersPresenter; + private final MostVisitedSource mostVisitedSource; + private final MostVisitedSourcePresenter mostVisitedSourcePresenter; + + TopListResource( + UseCaseExecutor useCaseExecutor, + MostFrequentReferrers mostFrequentReferrers, + MostFrequentReferrersPresenter mostFrequentReferrersPresenter, + MostVisitedSource mostVisitedSource, + MostVisitedSourcePresenter mostVisitedSourcePresenter) { + this.useCaseExecutor = useCaseExecutor; + this.mostFrequentReferrers = mostFrequentReferrers; + this.mostFrequentReferrersPresenter = mostFrequentReferrersPresenter; + this.mostVisitedSource = mostVisitedSource; + this.mostVisitedSourcePresenter = mostVisitedSourcePresenter; + } + + @GET + @Path("/referrers") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> topReferrers() { + return useCaseExecutor + .forUseCase(mostFrequentReferrers) + .withParameter(new MostFrequentReferrers.Input.GetTop20()) + .withPresenter(mostFrequentReferrersPresenter) + .run(); + } + + @GET + @Path("/sources") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> topSources() { + return useCaseExecutor + .forUseCase(mostVisitedSource) + .withParameter(new MostVisitedSource.Input.GetTop30()) + .withPresenter(mostVisitedSourcePresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/UniqueVisitorsResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/UniqueVisitorsResource.java new file mode 100644 index 0000000..ff53215 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/UniqueVisitorsResource.java @@ -0,0 +1,62 @@ +package tech.pegase.z3.application.api.v1.statistics; + +import io.quarkus.security.Authenticated; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.List; +import java.util.Map; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.statistics.UniqueVisitorsByBrandPresenter; +import tech.pegase.z3.application.presenter.statistics.UniqueVisitorsByBrandPresenter.BrandUniqueVisit; +import tech.pegase.z3.application.presenter.statistics.UniqueVisitorsPresenterByCatalog; +import tech.pegase.z3.application.presenter.statistics.UniqueVisitorsPresenterByCatalog.CatalogUniqueVisit; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByBrand; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByCatalog; + +@Path("/api/v1/statistics/unique-visitors") +@Authenticated +public class UniqueVisitorsResource { + private final UseCaseExecutor useCaseExecutor; + private final UniqueVisitorsByBrand brandsUniqueVisitors; + private final UniqueVisitorsByCatalog catalogsUniqueVisitors; + private final UniqueVisitorsByBrandPresenter brandUniqueVisitorsPresenter; + private final UniqueVisitorsPresenterByCatalog catalogUniqueVisitorsPresenter; + + UniqueVisitorsResource( + UseCaseExecutor useCaseExecutor, + UniqueVisitorsByBrand uniqueVisitorsByBrand, + UniqueVisitorsByCatalog uniqueVisitorsByCatalog, + UniqueVisitorsByBrandPresenter uniqueVisitorsByBrandPresenter, + UniqueVisitorsPresenterByCatalog uniqueVisitorsPresenterByCatalog) { + this.useCaseExecutor = useCaseExecutor; + this.brandsUniqueVisitors = uniqueVisitorsByBrand; + this.catalogsUniqueVisitors = uniqueVisitorsByCatalog; + this.brandUniqueVisitorsPresenter = uniqueVisitorsByBrandPresenter; + this.catalogUniqueVisitorsPresenter = uniqueVisitorsPresenterByCatalog; + } + + @GET + @Path("/brands") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse>> brandUniqueVisitors() { + return useCaseExecutor + .forUseCase(brandsUniqueVisitors) + .withParameter(new UniqueVisitorsByBrand.Input.GetCountsForThePastYear()) + .withPresenter(brandUniqueVisitorsPresenter) + .run(); + } + + @GET + @Path("/catalogs") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse>> catalogUniqueVisitors() { + return useCaseExecutor + .forUseCase(catalogsUniqueVisitors) + .withParameter(new UniqueVisitorsByCatalog.Input.GetCountsForThePastYear()) + .withPresenter(catalogUniqueVisitorsPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/package-info.java new file mode 100644 index 0000000..6f15119 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/statistics/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.api.v1.statistics; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/CreateTagResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/CreateTagResource.java new file mode 100644 index 0000000..60a6df3 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/CreateTagResource.java @@ -0,0 +1,46 @@ +package tech.pegase.z3.application.api.v1.tag; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.tag.CreateTagPresenter; +import tech.pegase.z3.application.presenter.tag.CreateTagPresenter.TagDto; +import tech.pegase.z3.application.request.tag.CreateTagRequest; +import tech.pegase.z3.domain.usecase.tags.CreateTag; +import tech.pegase.z3.domain.usecase.tags.CreateTag.Input; + +@Authenticated +@Path("/api/v1/tags") +public class CreateTagResource { + private final UseCaseExecutor useCaseExecutor; + private final CreateTag createTag; + private final CreateTagPresenter createTagPresenter; + + @Inject + public CreateTagResource( + UseCaseExecutor executor, CreateTag useCase, CreateTagPresenter presenter) { + this.useCaseExecutor = executor; + this.createTag = useCase; + this.createTagPresenter = presenter; + } + + @POST + @Path("/") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse create(@Valid CreateTagRequest request) { + Input parameter = new Input.With(request.getName(), request.getColor()); + return useCaseExecutor + .forUseCase(createTag) + .withParameter(parameter) + .withPresenter(createTagPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/GetTagResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/GetTagResource.java new file mode 100644 index 0000000..dc6fee0 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/GetTagResource.java @@ -0,0 +1,42 @@ +package tech.pegase.z3.application.api.v1.tag; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.tag.GetTagPresenter; +import tech.pegase.z3.application.presenter.tag.GetTagPresenter.TagDto; +import tech.pegase.z3.domain.usecase.tags.GetTag; +import tech.pegase.z3.domain.usecase.tags.GetTag.Input.ForId; + +@Path("/api/v1/tags") +@Authenticated +public class GetTagResource { + private final UseCaseExecutor useCaseExecutor; + private final GetTag getTag; + private final GetTagPresenter getTagResponsePresenter; + + @Inject + public GetTagResource(UseCaseExecutor executor, GetTag useCase, GetTagPresenter presenter) { + this.useCaseExecutor = executor; + this.getTag = useCase; + this.getTagResponsePresenter = presenter; + } + + @GET + @Path("/{tag}") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse getTag(@PathParam("tag") UUID tagUuid) { + return useCaseExecutor + .forUseCase(getTag) + .withParameter(new ForId(tagUuid)) + .withPresenter(getTagResponsePresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/ListTagsResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/ListTagsResource.java new file mode 100644 index 0000000..758a5a8 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/ListTagsResource.java @@ -0,0 +1,52 @@ +package tech.pegase.z3.application.api.v1.tag; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.application.presenter.tag.ListTagsPresenter; +import tech.pegase.z3.application.presenter.tag.ListTagsPresenter.TagDto; +import tech.pegase.z3.application.request.tag.PaginatedTagListRequest; +import tech.pegase.z3.domain.usecase.tags.ListTags; +import tech.pegase.z3.shared.pagination.Pagination; + +@Path("/api/v1/tags") +@Authenticated +public class ListTagsResource { + private final UseCaseExecutor useCaseExecutor; + private final ListTags listTags; + private final ListTagsPresenter listTagsPresenter; + + @Inject + public ListTagsResource( + UseCaseExecutor executor, ListTags useCase, ListTagsPresenter presenter) { + this.useCaseExecutor = executor; + this.listTags = useCase; + this.listTagsPresenter = presenter; + } + + @GET + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> getPaginatedTagList( + @BeanParam @Valid PaginatedTagListRequest request) { + return useCaseExecutor + .forUseCase(listTags) + .withParameter(getParameterFromRequest(request)) + .withPresenter(listTagsPresenter) + .run(); + } + + private ListTags.Input getParameterFromRequest(PaginatedTagListRequest request) { + var pagination = + Pagination.fromPageNumberAndPageSize(request.getPage(), request.getPerPage()); + + return new ListTags.Input.WithPagination(pagination); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/RemoveTagResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/RemoveTagResource.java new file mode 100644 index 0000000..a7ccc1e --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/RemoveTagResource.java @@ -0,0 +1,42 @@ +package tech.pegase.z3.application.api.v1.tag; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.dto.MessageDto; +import tech.pegase.z3.application.presenter.tag.RemoveTagPresenter; +import tech.pegase.z3.domain.usecase.tags.RemoveTag; +import tech.pegase.z3.domain.usecase.tags.RemoveTag.Input.WhereUuid; + +@Path("/api/v1/tags") +@Authenticated +public class RemoveTagResource { + private final UseCaseExecutor useCaseExecutor; + private final RemoveTag removeTagUseCase; + private final RemoveTagPresenter removeTagPresenter; + + @Inject + RemoveTagResource(UseCaseExecutor executor, RemoveTag useCase, RemoveTagPresenter presenter) { + useCaseExecutor = executor; + removeTagUseCase = useCase; + removeTagPresenter = presenter; + } + + @DELETE + @Path("/{tag}") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse removeTag(@PathParam("tag") UUID tagUuid) { + return useCaseExecutor + .forUseCase(removeTagUseCase) + .withParameter(new WhereUuid(tagUuid)) + .withPresenter(removeTagPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/UpdateTagResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/UpdateTagResource.java new file mode 100644 index 0000000..9a28156 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/UpdateTagResource.java @@ -0,0 +1,47 @@ +package tech.pegase.z3.application.api.v1.tag; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.tag.UpdateTagPresenter; +import tech.pegase.z3.application.presenter.tag.UpdateTagPresenter.TagDto; +import tech.pegase.z3.application.request.tag.UpdateTagRequest; +import tech.pegase.z3.domain.usecase.tags.UpdateTag; +import tech.pegase.z3.domain.usecase.tags.UpdateTag.Input.WhereUuid; + +@Path("/api/v1/tags") +@Authenticated +public class UpdateTagResource { + private final UseCaseExecutor useCaseExecutor; + private final UpdateTag updateTagUseCase; + private final UpdateTagPresenter updateTagPresenter; + + @Inject + UpdateTagResource(UseCaseExecutor executor, UpdateTag useCase, UpdateTagPresenter presenter) { + useCaseExecutor = executor; + updateTagUseCase = useCase; + updateTagPresenter = presenter; + } + + @PUT + @Path("/{tag}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse updateTag( + @PathParam("tag") UUID tagUuid, @Valid UpdateTagRequest request) { + return useCaseExecutor + .forUseCase(updateTagUseCase) + .withParameter(new WhereUuid(tagUuid, request.getName(), request.getColor())) + .withPresenter(updateTagPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/package-info.java new file mode 100644 index 0000000..f684110 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/tag/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.api.v1.tag; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/CreateUserResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/CreateUserResource.java new file mode 100644 index 0000000..0c7815a --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/CreateUserResource.java @@ -0,0 +1,70 @@ +package tech.pegase.z3.application.api.v1.user; + +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.application.presenter.user.CreateUserPresenter; +import tech.pegase.z3.application.presenter.user.CreateUserPresenter.UserDto; +import tech.pegase.z3.application.request.user.CreateUserRequest; +import tech.pegase.z3.domain.usecase.users.CreateUser; +import tech.pegase.z3.domain.usecase.users.CreateUser.Input; +import tech.pegase.z3.domain.usecase.users.CreateUser.Input.NewUserBy; + +@Authenticated +@Path("/api/v1/users") +public class CreateUserResource { + private final UseCaseExecutor useCaseExecutor; + private final CreateUser createUser; + private final CreateUserPresenter createUserPresenter; + private final UserContext userContext; + + @Inject + public CreateUserResource( + UseCaseExecutor executor, + CreateUser useCase, + CreateUserPresenter presenter, + UserContext userContext) { + this.useCaseExecutor = executor; + this.createUser = useCase; + this.createUserPresenter = presenter; + this.userContext = userContext; + } + + @POST + @Path("/") + @RolesAllowed("ADMINISTRATOR") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse create(@Valid CreateUserRequest request) { + var userContextId = userContext.getId(); + + if (userContextId == null) { + throw new ServerErrorException("Cannot get user context"); + } + + Input parameter = + new NewUserBy( + userContextId, + request.getFirstName(), + request.getLastName(), + request.getEmail(), + request.getRole(), + request.getLogin(), + request.getBrands()); + return useCaseExecutor + .forUseCase(createUser) + .withParameter(parameter) + .withPresenter(createUserPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/GetUserResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/GetUserResource.java new file mode 100644 index 0000000..b36d8bc --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/GetUserResource.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.application.api.v1.user; + +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.user.GetUserPresenter; +import tech.pegase.z3.application.presenter.user.GetUserPresenter.UserDto; +import tech.pegase.z3.domain.usecase.users.GetUser; + +@Path("/api/v1/users") +@Authenticated +public class GetUserResource { + private final UseCaseExecutor useCaseExecutor; + private final GetUser getUser; + private final GetUserPresenter getUserResponsePresenter; + + @Inject + public GetUserResource(UseCaseExecutor executor, GetUser useCase, GetUserPresenter presenter) { + this.useCaseExecutor = executor; + this.getUser = useCase; + this.getUserResponsePresenter = presenter; + } + + @GET + @Path("/{user}") + @RolesAllowed("ADMINISTRATOR") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse getUser(@PathParam("user") UUID userUuid) { + return useCaseExecutor + .forUseCase(getUser) + .withParameter(new GetUser.Input.ForId(userUuid)) + .withPresenter(getUserResponsePresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/ListUsersResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/ListUsersResource.java new file mode 100644 index 0000000..68f285b --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/ListUsersResource.java @@ -0,0 +1,54 @@ +package tech.pegase.z3.application.api.v1.user; + +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.BeanParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.application.presenter.user.ListUsersPresenter; +import tech.pegase.z3.application.presenter.user.ListUsersPresenter.UserDto; +import tech.pegase.z3.application.request.user.PaginatedUserListRequest; +import tech.pegase.z3.domain.usecase.users.ListUsers; +import tech.pegase.z3.shared.pagination.Pagination; + +@Path("/api/v1/users") +@Authenticated +public class ListUsersResource { + private final UseCaseExecutor useCaseExecutor; + private final ListUsers listUsers; + private final ListUsersPresenter listUsersPresenter; + + @Inject + public ListUsersResource( + UseCaseExecutor executor, ListUsers useCase, ListUsersPresenter presenter) { + this.useCaseExecutor = executor; + this.listUsers = useCase; + this.listUsersPresenter = presenter; + } + + @GET + @RolesAllowed("ADMINISTRATOR") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse> getPaginatedUserList( + @BeanParam @Valid PaginatedUserListRequest request) { + return useCaseExecutor + .forUseCase(listUsers) + .withParameter(getParameterFromRequest(request)) + .withPresenter(listUsersPresenter) + .run(); + } + + private ListUsers.Input getParameterFromRequest(PaginatedUserListRequest request) { + var pagination = + Pagination.fromPageNumberAndPageSize(request.getPage(), request.getPerPage()); + + return new ListUsers.Input.WithPagination(pagination); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/RemoveUserResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/RemoveUserResource.java new file mode 100644 index 0000000..0e5edf1 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/RemoveUserResource.java @@ -0,0 +1,58 @@ +package tech.pegase.z3.application.api.v1.user; + +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.dto.MessageDto; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.application.presenter.user.RemoveUserPresenter; +import tech.pegase.z3.domain.usecase.users.RemoveUser; +import tech.pegase.z3.domain.usecase.users.RemoveUser.Input.WhereUuid; + +@Path("/api/v1/users") +@Authenticated +public class RemoveUserResource { + private final UseCaseExecutor useCaseExecutor; + private final RemoveUser removeUserUseCase; + private final RemoveUserPresenter removeUserPresenter; + private final UserContext userContext; + + @Inject + RemoveUserResource( + UseCaseExecutor executor, + RemoveUser useCase, + RemoveUserPresenter presenter, + UserContext userContext) { + useCaseExecutor = executor; + removeUserUseCase = useCase; + removeUserPresenter = presenter; + this.userContext = userContext; + } + + @DELETE + @Path("/{user}") + @RolesAllowed("ADMINISTRATOR") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse removeUser(@PathParam("user") UUID userUuid) { + var userContextId = userContext.getId(); + + if (userContextId == null) { + throw new ServerErrorException("Cannot get user context"); + } + + return useCaseExecutor + .forUseCase(removeUserUseCase) + .withParameter(new WhereUuid(userContextId, userUuid)) + .withPresenter(removeUserPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/ResetUserPasswordResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/ResetUserPasswordResource.java new file mode 100644 index 0000000..f9fde1c --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/ResetUserPasswordResource.java @@ -0,0 +1,63 @@ +package tech.pegase.z3.application.api.v1.user; + +import io.quarkus.security.Authenticated; +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.exception.AuthorizationException; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.application.presenter.user.ResetUserPasswordPresenter; +import tech.pegase.z3.application.presenter.user.ResetUserPasswordPresenter.MessageDto; +import tech.pegase.z3.domain.usecase.users.ResetUserPassword; +import tech.pegase.z3.domain.usecase.users.ResetUserPassword.Input.ForUser; + +@Path("/api/v1/users") +@Authenticated +public class ResetUserPasswordResource { + private final UseCaseExecutor useCaseExecutor; + private final ResetUserPassword resetUserPassword; + private final ResetUserPasswordPresenter resetUserPasswordPresenter; + private final UserContext userContext; + + @Inject + public ResetUserPasswordResource( + UseCaseExecutor executor, + ResetUserPassword useCase, + ResetUserPasswordPresenter presenter, + UserContext userContext) { + this.useCaseExecutor = executor; + this.resetUserPassword = useCase; + this.resetUserPasswordPresenter = presenter; + this.userContext = userContext; + } + + @POST + @Path("/{user}/reset") + @RolesAllowed("ADMINISTRATOR") + @Produces(MediaType.APPLICATION_JSON) + public RestResponse getUser(@PathParam("user") UUID userUuid) { + var userContextId = userContext.getId(); + + if (userContextId == null) { + throw new ServerErrorException("Cannot get user context"); + } + + if (userUuid.equals(userContextId)) { + throw new AuthorizationException(); + } + + return useCaseExecutor + .forUseCase(resetUserPassword) + .withParameter(new ForUser(userUuid)) + .withPresenter(resetUserPasswordPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/UpdateUserResource.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/UpdateUserResource.java new file mode 100644 index 0000000..2bb78bc --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/UpdateUserResource.java @@ -0,0 +1,75 @@ +package tech.pegase.z3.application.api.v1.user; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.validation.Valid; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.util.UUID; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.exception.AuthorizationException; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.application.presenter.user.UpdateUserPresenter; +import tech.pegase.z3.application.presenter.user.UpdateUserPresenter.UserDto; +import tech.pegase.z3.application.request.user.UpdateUserRequest; +import tech.pegase.z3.domain.usecase.users.UpdateUser; +import tech.pegase.z3.domain.valueobject.Role.AccessLevel; + +@Path("/api/v1/users") +@Authenticated +public class UpdateUserResource { + private final UseCaseExecutor useCaseExecutor; + private final UpdateUser updateUserUseCase; + private final UpdateUserPresenter updateUserPresenter; + private final UserContext userContext; + + @Inject + UpdateUserResource( + UseCaseExecutor executor, + UpdateUser useCase, + UpdateUserPresenter presenter, + UserContext userContext) { + useCaseExecutor = executor; + updateUserUseCase = useCase; + updateUserPresenter = presenter; + this.userContext = userContext; + } + + @PUT + @Path("/{user}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public RestResponse updateUser( + @PathParam("user") UUID userUuid, @Valid UpdateUserRequest request) { + var userContextId = userContext.getId(); + var userContextRole = userContext.getRole(); + + if (userContextId == null || userContextRole == null) { + throw new ServerErrorException("Cannot get user context"); + } + + // Only Admins can update other profiles + if (!userContextId.equals(userUuid) + && !userContextRole.value().equals(AccessLevel.ADMINISTRATOR)) { + throw new AuthorizationException(); + } + + return useCaseExecutor + .forUseCase(updateUserUseCase) + .withParameter( + new UpdateUser.Input.WhereId( + userUuid, + request.getFirstName(), + request.getLastName(), + request.getEmail(), + request.getBrands())) + .withPresenter(updateUserPresenter) + .run(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/package-info.java new file mode 100644 index 0000000..5d40d30 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/api/v1/user/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.api.v1.user; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/context/UserContext.java b/z3-application/src/main/java/tech/pegase/z3/application/context/UserContext.java new file mode 100644 index 0000000..d8aa561 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/context/UserContext.java @@ -0,0 +1,24 @@ +package tech.pegase.z3.application.context; + +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; + +public interface UserContext { + @Nullable UUID getId(); + + @Nullable String getLastName(); + + @Nullable String getFirstName(); + + @Nullable Email getEmail(); + + @Nullable Role getRole(); + + int hashCode(); + + boolean equals(Object obj); + + String toString(); +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/context/UserContextImpl.java b/z3-application/src/main/java/tech/pegase/z3/application/context/UserContextImpl.java new file mode 100644 index 0000000..90b9867 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/context/UserContextImpl.java @@ -0,0 +1,90 @@ +package tech.pegase.z3.application.context; + +import java.util.Objects; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; + +@SuppressWarnings("ClassCanBeRecord") +public final class UserContextImpl implements UserContext { + private final UUID id; + private final String firstName; + private final String lastName; + private final Email email; + private final Role role; + + public UserContextImpl(UUID id, String firstName, String lastName, Email email, Role role) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.email = email; + this.role = role; + } + + @Override + public @Nullable UUID getId() { + return id; + } + + @Override + public @Nullable String getLastName() { + return lastName; + } + + @Override + public @Nullable String getFirstName() { + return firstName; + } + + @Override + public @Nullable Email getEmail() { + return email; + } + + @Override + public @Nullable Role getRole() { + return role; + } + + @Override + public int hashCode() { + return Objects.hash(id, firstName, lastName, email, role); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + var that = (UserContextImpl) obj; + return Objects.equals(this.id, that.id) + && Objects.equals(this.firstName, that.firstName) + && Objects.equals(this.lastName, that.lastName) + && Objects.equals(this.email, that.email) + && Objects.equals(this.role, that.role); + } + + @Override + public String toString() { + return "UserContext[" + + "uuid=" + + id + + ", " + + "firstName=" + + firstName + + ", " + + "lastName=" + + lastName + + ", " + + "email=" + + email + + ", " + + "role=" + + role.value() + + ']'; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/context/UserContextNull.java b/z3-application/src/main/java/tech/pegase/z3/application/context/UserContextNull.java new file mode 100644 index 0000000..ed9af6c --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/context/UserContextNull.java @@ -0,0 +1,76 @@ +package tech.pegase.z3.application.context; + +import java.util.Objects; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; + +public final class UserContextNull implements UserContext { + + @Override + public @Nullable UUID getId() { + return null; + } + + @Override + public @Nullable String getLastName() { + return null; + } + + @Override + public @Nullable String getFirstName() { + return null; + } + + @Override + public @Nullable Email getEmail() { + return null; + } + + @Override + public @Nullable Role getRole() { + return null; + } + + @Override + public int hashCode() { + return Objects.hash(null, null, null, null, null); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + var that = (UserContext) obj; + return Objects.equals(null, that.getId()) + && Objects.equals(null, that.getFirstName()) + && Objects.equals(null, that.getLastName()) + && Objects.equals(null, that.getEmail()) + && Objects.equals(null, that.getRole()); + } + + @Override + public String toString() { + return "UserContext[" + + "uuid=" + + null + + ", " + + "firstName=" + + null + + ", " + + "lastName=" + + null + + ", " + + "email=" + + null + + ", " + + "role=" + + null + + ']'; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/di/UseCaseExecutorProducer.java b/z3-application/src/main/java/tech/pegase/z3/application/di/UseCaseExecutorProducer.java new file mode 100644 index 0000000..1de2338 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/di/UseCaseExecutorProducer.java @@ -0,0 +1,66 @@ +package tech.pegase.z3.application.di; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import org.jboss.logging.Logger; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.happy.core.usecase.log.UseCaseLogger; +import tech.pegase.happy.quarkus.QuarkusUseCaseExecutor; +import tech.pegase.z3.application.context.UserContext; + +@NullMarked +@ApplicationScoped +public class UseCaseExecutorProducer { + + @Produces + @ApplicationScoped + public UseCaseExecutor useCaseExecutor(@Nullable UseCaseLogger logger) { + return new QuarkusUseCaseExecutor(logger); + } + + @Produces + @ApplicationScoped + public UseCaseLogger useCaseLogger(UserContext userContext) { + return new UseCaseLogger() { + static final Logger logger = Logger.getLogger(UseCaseLogger.class); + static final String SUCCESS = + """ + %s: SUCCESS + \tAuthor: %s + \tInput: %s + """; + static final String FAILURE = + """ + %s: FAILURE + \tAuthor: %s + \tInput: %s + \tFailure: %s + """; + + @Override + public , I, O> void log( + Class useCaseClass, I input, O output) { + var contextualUser = + userContext.getId() != null ? "User '%s'".formatted(userContext.getId()) : "Anonymous"; + + if (output instanceof Failure) { + logger.warn( + String.format( + FAILURE, + useCaseClass.getSimpleName(), + contextualUser, + input.getClass(), + output.getClass())); + } else { + logger.info( + String.format( + SUCCESS, useCaseClass.getSimpleName(), contextualUser, input.getClass())); + } + } + }; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/dto/MessageDto.java b/z3-application/src/main/java/tech/pegase/z3/application/dto/MessageDto.java new file mode 100644 index 0000000..d42ae3e --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/dto/MessageDto.java @@ -0,0 +1,7 @@ +package tech.pegase.z3.application.dto; + +public record MessageDto(String message) { + public static MessageDto of(String message) { + return new MessageDto(message); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/dto/PaginatedDto.java b/z3-application/src/main/java/tech/pegase/z3/application/dto/PaginatedDto.java new file mode 100644 index 0000000..c55c79e --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/dto/PaginatedDto.java @@ -0,0 +1,26 @@ +package tech.pegase.z3.application.dto; + +import java.util.List; +import java.util.function.Function; +import tech.pegase.z3.shared.pagination.PaginatedData; + +public record PaginatedDto( + List list, long page, long perPage, long totalPages, long totalItems) { + public static PaginatedDto of( + PaginatedData paginatedData, Function mapper) { + + var totalItems = paginatedData.total(); + var perPage = paginatedData.perPage(); + var totalPages = + totalItems > 0 ? Math.ceilDiv(paginatedData.total(), paginatedData.perPage()) : 1; + var currentPage = + totalItems > 0 ? Math.floorDiv(paginatedData.offset(), paginatedData.perPage()) + 1 : 1; + + return new PaginatedDto<>( + paginatedData.items().stream().map(mapper).toList(), + currentPage, + perPage, + totalPages, + totalItems); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/dto/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/dto/package-info.java new file mode 100644 index 0000000..68e8b0d --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/dto/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.dto; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/events/LinkRedirectedEvent.java b/z3-application/src/main/java/tech/pegase/z3/application/events/LinkRedirectedEvent.java new file mode 100644 index 0000000..2acfc75 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/events/LinkRedirectedEvent.java @@ -0,0 +1,22 @@ +package tech.pegase.z3.application.events; + +import java.net.InetAddress; +import java.net.URI; +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; + +public record LinkRedirectedEvent( + Source source, + Boolean linkIsResolved, + ZonedDateTime visitedAt, + @Nullable Boolean doNotTrack, + @Nullable InetAddress ipAddress, + @Nullable Locale locale, + @Nullable URI referrer, + @Nullable String userAgent, + @Nullable Fingerprint fingerprint, + @Nullable ScreenProperties screenProperties) {} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/events/LinkRedirectionEventListener.java b/z3-application/src/main/java/tech/pegase/z3/application/events/LinkRedirectionEventListener.java new file mode 100644 index 0000000..06e8a69 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/events/LinkRedirectionEventListener.java @@ -0,0 +1,183 @@ +package tech.pegase.z3.application.events; + +import io.quarkus.vertx.ConsumeEvent; +import io.smallrye.common.annotation.RunOnVirtualThread; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.control.ActivateRequestContext; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.Presenter; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.domain.usecase.brands.GetBrand; +import tech.pegase.z3.domain.usecase.links.GetLink; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.usecase.links.VisitLink; +import tech.pegase.z3.statistics.domain.usecase.links.VisitLink.Input.AddVisitFor; +import tech.pegase.z3.statistics.domain.usecase.tracking.CreateFingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.RawUserAgent; + +@ActivateRequestContext +@ApplicationScoped +public class LinkRedirectionEventListener { + private final UseCaseExecutor useCaseExecutor; + private final GetLink getLink; + private final GetBrand getBrand; + private final VisitLink visitLink; + private final CreateFingerprint createFingerprint; + + LinkRedirectionEventListener( + UseCaseExecutor useCaseExecutor, + GetLink getLink, + GetBrand getBrand, + VisitLink visitLink, + CreateFingerprint createFingerprint) { + this.useCaseExecutor = useCaseExecutor; + this.getLink = getLink; + this.getBrand = getBrand; + this.visitLink = visitLink; + this.createFingerprint = createFingerprint; + } + + @RunOnVirtualThread + @ConsumeEvent("LinkRedirectedEvent") + public void consumeRedirect(LinkRedirectedEvent event) { + var source = event.source(); + String linkCatalogName = null; + String linkBrandName = null; + var linkInformation = getLinkInformationName(source).orElse(null); + + if (linkInformation != null) { + linkCatalogName = linkInformation.catalogName(); + linkBrandName = + Optional.ofNullable(linkInformation.brandUuid()).flatMap(this::getBrandName).orElse(null); + } + + var visitLinkInput = toInput(event, linkBrandName, linkCatalogName); + + storeLinkVisit(visitLinkInput); + } + + private Optional getBrandName(UUID brandUuid) { + var input = new GetBrand.Input.ForUuid(brandUuid); + + return useCaseExecutor + .forUseCase(getBrand) + .withParameter(input) + .withPresenter( + (Presenter>) this::presentBrandName) + .run(); + } + + private Optional presentBrandName(GetBrand.Output output) { + + if (output instanceof GetBrand.Output.Found) { + var outputBrand = ((GetBrand.Output.Found) output).brand(); + + return Optional.of(outputBrand.name()); + } + + return Optional.empty(); + } + + private Optional getLinkInformationName(Source source) { + var input = new GetLink.Input.ForSource(source); + + return useCaseExecutor + .forUseCase(getLink) + .withParameter(input) + .withPresenter( + (Presenter>) + this::presentLinkInformation) + .run(); + } + + private Optional presentLinkInformation(GetLink.Output output) { + if (output instanceof GetLink.Output.Found) { + var outputLink = ((GetLink.Output.Found) output).link(); + var linkCatalog = outputLink.catalog(); + var catalogName = linkCatalog != null ? linkCatalog.name() : null; + var brandUuid = outputLink.brandUuid(); + + return Optional.of(new LinkInformation(brandUuid, catalogName)); + } + + return Optional.empty(); + } + + private void storeLinkVisit(VisitLink.Input input) { + useCaseExecutor + .forUseCase(visitLink) + .withParameter(input) + .withPresenter(output -> output) + .run(); + } + + private VisitLink.Input toInput( + LinkRedirectedEvent event, @Nullable String brand, @Nullable String catalog) { + var source = event.source(); + var linkIsResolved = event.linkIsResolved(); + var visitedAt = event.visitedAt(); + var doNotTrack = event.doNotTrack(); + var inetAddress = event.ipAddress(); + var locale = event.locale(); + var referrer = event.referrer(); + var screenProperties = event.screenProperties(); + var userAgent = event.userAgent(); + var fingerprint = event.fingerprint(); + + if (fingerprint == null) { + fingerprint = createFingerprintFrom(event).orElse(null); + } + + var rawUserAgent = Optional.ofNullable(userAgent).map(RawUserAgent::new).orElse(null); + var additionalInputs = + new AddVisitFor.AdditionalInputs( + brand, + catalog, + doNotTrack, + fingerprint, + inetAddress, + locale, + referrer, + rawUserAgent, + screenProperties); + + return new AddVisitFor(source, linkIsResolved, visitedAt, additionalInputs); + } + + private Optional createFingerprintFrom(LinkRedirectedEvent event) { + var ipAddress = event.ipAddress(); + var locale = event.locale(); + var screenProperties = event.screenProperties(); + var userAgent = event.userAgent(); + + if (ipAddress == null || userAgent == null || screenProperties == null || locale == null) { + return Optional.empty(); + } + + var input = + new CreateFingerprint.Input.MakeFrom(ipAddress, userAgent, screenProperties, locale); + + return useCaseExecutor + .forUseCase(createFingerprint) + .withParameter(input) + .withPresenter( + (Presenter>) + this::presentCreatedFingerprint) + .run(); + } + + Optional presentCreatedFingerprint(CreateFingerprint.Output output) { + if (output instanceof CreateFingerprint.Output.GeneratedFingerprint) { + var outputLink = ((CreateFingerprint.Output.GeneratedFingerprint) output).fingerprint(); + + return Optional.of(outputLink); + } + + return Optional.empty(); + } + + private record LinkInformation(@Nullable UUID brandUuid, @Nullable String catalogName) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/events/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/events/package-info.java new file mode 100644 index 0000000..5c64f76 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/events/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.events; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/exception/AuthorizationException.java b/z3-application/src/main/java/tech/pegase/z3/application/exception/AuthorizationException.java new file mode 100644 index 0000000..a98e94f --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/exception/AuthorizationException.java @@ -0,0 +1,26 @@ +package tech.pegase.z3.application.exception; + +import jakarta.ws.rs.WebApplicationException; + +public class AuthorizationException extends WebApplicationException { + + public AuthorizationException(String message, Exception cause) { + super(message, cause, 403); + } + + public AuthorizationException(String message) { + super(message, 403); + } + + public AuthorizationException(Exception cause) { + super("Unauthorized", cause, 403); + } + + public AuthorizationException() { + super("Unauthorized", 403); + } + + public static AuthorizationException of(String message) { + return new AuthorizationException(message); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/exception/ServerErrorException.java b/z3-application/src/main/java/tech/pegase/z3/application/exception/ServerErrorException.java new file mode 100644 index 0000000..806dbea --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/exception/ServerErrorException.java @@ -0,0 +1,26 @@ +package tech.pegase.z3.application.exception; + +import jakarta.ws.rs.WebApplicationException; + +public class ServerErrorException extends WebApplicationException { + + public ServerErrorException(String message, Exception cause) { + super(message, cause, 500); + } + + public ServerErrorException(String message, int status) { + super(message, status); + } + + public ServerErrorException(String message) { + super(message, 500); + } + + public ServerErrorException(Exception cause) { + super("Server error", cause, 500); + } + + public ServerErrorException() { + super("Server error", 500); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/exception/ValidationException.java b/z3-application/src/main/java/tech/pegase/z3/application/exception/ValidationException.java new file mode 100644 index 0000000..a7d3123 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/exception/ValidationException.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.application.exception; + +import jakarta.ws.rs.WebApplicationException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; + +public class ValidationException extends WebApplicationException { + private final Set errors; + + public ValidationException(Set errors) { + super("Validation error", 422); + this.errors = errors; + } + + public static ValidationException of(Set invalidFields) { + return new ValidationException(invalidFields); + } + + public static ValidationException of(ValidationError... errors) { + return of(Arrays.stream(errors).collect(Collectors.toSet())); + } + + public static ValidationException of(Collection invalidFields) { + return of(invalidFields.stream().map(ValidationError::of).collect(Collectors.toSet())); + } + + public Set getErrors() { + return errors; + } + + public record ValidationError(String field, String message) { + public static ValidationError of(String field, String message) { + return new ValidationError(field, message); + } + + public static ValidationError of(InvalidField invalidField) { + return new ValidationError(invalidField.field(), invalidField.reason()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/lifecycle/AppLifecycleBean.java b/z3-application/src/main/java/tech/pegase/z3/application/lifecycle/AppLifecycleBean.java new file mode 100644 index 0000000..7b78ab8 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/lifecycle/AppLifecycleBean.java @@ -0,0 +1,97 @@ +package tech.pegase.z3.application.lifecycle; + +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.Set; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestResponse; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.application.presenter.user.CreateUserPresenter; +import tech.pegase.z3.domain.usecase.users.CreateUser; +import tech.pegase.z3.domain.usecase.users.CreateUser.Input; +import tech.pegase.z3.domain.usecase.users.CreateUser.Input.NewUser; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.Role.AccessLevel; + +@NullMarked +@ApplicationScoped +public class AppLifecycleBean { + private static final Logger logger = Logger.getLogger(AppLifecycleBean.class); + + private final UseCaseExecutor useCaseExecutor; + private final CreateUser createUser; + private @Nullable Email userEmailToCreate; + + @Inject + public AppLifecycleBean( + UseCaseExecutor executor, + CreateUser useCase, + @ConfigProperty(name = "z3.app.create-user-on-start") Optional createUserOnStart) { + this.useCaseExecutor = executor; + this.createUser = useCase; + + try { + createUserOnStart.ifPresent(s -> this.userEmailToCreate = new Email(s)); + } catch (Exception e) { + logger.info("Failed to validate create user on start email", e); + this.userEmailToCreate = null; + } + } + + void onStart(@Observes StartupEvent ev) { + logger.info("The application is starting..."); + + if (userEmailToCreate == null) { + return; + } + + createUser(userEmailToCreate); + } + + void onStop(@Observes ShutdownEvent ev) { + logger.info("The application is stopping..."); + } + + private void createUser(Email email) { + logger.info("Creating user..."); + + Input parameter = + new NewUser( + "Admin", + "Link", + email, + Role.of(AccessLevel.ADMINISTRATOR), + Login.of("z3.admin"), + Set.of()); + try { + useCaseExecutor + .forUseCase(createUser) + .withParameter(parameter) + .withPresenter( + new CreateUserPresenter() { + @Override + public RestResponse<@Nullable UserDto> present(CreateUser.Output output) { + if (output instanceof CreateUser.Output.Created) { + return RestResponse.ok( + UserDto.of(((CreateUser.Output.Created) output).value())); + } else { + logger.error("Failed to create user"); + } + return RestResponse.notFound(); + } + }) + .run(); + } catch (Exception e) { + logger.error("Failed to create user", e); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/AuthenticateUserPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/AuthenticateUserPresenter.java new file mode 100644 index 0000000..d34cf16 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/AuthenticateUserPresenter.java @@ -0,0 +1,49 @@ +package tech.pegase.z3.application.presenter.auth; + +import io.quarkus.security.AuthenticationFailedException; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.RestResponse.Status; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.auth.AuthenticateUserPresenter.JwtTokenJsonDto; +import tech.pegase.z3.domain.usecase.auth.AuthenticateUser; +import tech.pegase.z3.domain.valueobject.JwtToken; + +@ApplicationScoped +public class AuthenticateUserPresenter + implements Presenter< + AuthenticateUser, + AuthenticateUser.Output, + RestResponse> { + @Override + public RestResponse present( + AuthenticateUser.Output output) { + return switch (output) { + case AuthenticateUser.Output.Valid outputCase -> present(outputCase); + case AuthenticateUser.Output.Invalid outputCase -> present(outputCase); + }; + } + + public RestResponse present( + AuthenticateUser.Output.Valid outputCase) { + return RestResponse.status( + Status.OK, AuthenticateUserPresenter.JwtTokenJsonDto.of(outputCase.token())); + } + + public RestResponse present( + AuthenticateUser.Output.Invalid ignoredOutputCase) { + throw new AuthenticationFailedException("Authentication failed"); + } + + public record JwtTokenJsonDto(String token) { + public JwtTokenJsonDto { + if (token.isBlank()) { + throw new IllegalArgumentException("token is blank"); + } + } + + public static JwtTokenJsonDto of(JwtToken token) { + return new JwtTokenJsonDto(token.value()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/ChangePasswordPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/ChangePasswordPresenter.java new file mode 100644 index 0000000..d158662 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/ChangePasswordPresenter.java @@ -0,0 +1,42 @@ +package tech.pegase.z3.application.presenter.auth; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.users.ChangeUserPassword; + +@ApplicationScoped +public class ChangePasswordPresenter + implements Presenter< + ChangeUserPassword, + ChangeUserPassword.Output, + RestResponse> { + @Override + public RestResponse present(ChangeUserPassword.Output output) { + return switch (output) { + case ChangeUserPassword.Output.Changed outputCase -> present(outputCase); + case ChangeUserPassword.Output.NotChanged outputCase -> present(outputCase); + case ChangeUserPassword.Output.NoSuchUser outputCase -> present(outputCase); + }; + } + + public RestResponse present(ChangeUserPassword.Output.Changed ignoredOutputCase) { + return RestResponse.ok(MessageDto.of("Le mot de passe à été changé")); + } + + public RestResponse present(ChangeUserPassword.Output.NotChanged outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + public RestResponse present(ChangeUserPassword.Output.NoSuchUser outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } + + public record MessageDto(String message) { + public static MessageDto of(String value) { + return new MessageDto(value); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/UserContextPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/UserContextPresenter.java new file mode 100644 index 0000000..1f64253 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/UserContextPresenter.java @@ -0,0 +1,35 @@ +package tech.pegase.z3.application.presenter.auth; + +import jakarta.enterprise.context.ApplicationScoped; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.context.UserContextImpl; +import tech.pegase.z3.application.context.UserContextNull; +import tech.pegase.z3.domain.usecase.users.GetUser; +import tech.pegase.z3.domain.usecase.users.GetUser.Output; + +@ApplicationScoped +public class UserContextPresenter implements Presenter { + @Override + public @Nullable UserContext present(Output output) { + return switch (output) { + case Output.Found outputCase -> present(outputCase); + case Output.NoSuchUser outputCase -> present(outputCase); + }; + } + + public UserContext present(Output.Found outputCase) { + var userOutput = outputCase.user(); + return new UserContextImpl( + userOutput.uuid(), + userOutput.firstName(), + userOutput.lastName(), + userOutput.email(), + userOutput.role()); + } + + public @Nullable UserContext present(Output.NoSuchUser ignoredOutputCase) { + return new UserContextNull(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/package-info.java new file mode 100644 index 0000000..ed47e77 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/auth/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.presenter.auth; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/CreateBrandPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/CreateBrandPresenter.java new file mode 100644 index 0000000..565bce9 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/CreateBrandPresenter.java @@ -0,0 +1,47 @@ +package tech.pegase.z3.application.presenter.brand; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Set; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.brands.CreateBrand; + +@ApplicationScoped +public class CreateBrandPresenter + implements Presenter< + CreateBrand, CreateBrand.Output, RestResponse> { + @Override + public RestResponse present(CreateBrand.Output output) { + return switch (output) { + case CreateBrand.Output.Created outputCase -> present(outputCase); + case CreateBrand.Output.NotCreated outputCase -> present(outputCase); + }; + } + + private RestResponse present( + CreateBrand.Output.Created outputCase) { + return RestResponse.ok(CreateBrandPresenter.BrandDto.of(outputCase.brand())); + } + + private RestResponse present( + CreateBrand.Output.NotCreated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + public record BrandDto(String uuid, String slug, String name, Set fqdns) { + public BrandDto { + if (slug.isBlank()) { + throw new IllegalStateException("slug cannot be empty"); + } + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static BrandDto of(CreateBrand.Output.BrandOutput brand) { + return new BrandDto( + brand.uuid().toString(), brand.slug().value(), brand.name(), brand.fqdnSet()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/GetBrandPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/GetBrandPresenter.java new file mode 100644 index 0000000..0cb11a0 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/GetBrandPresenter.java @@ -0,0 +1,45 @@ +package tech.pegase.z3.application.presenter.brand; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.util.Set; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.domain.usecase.brands.GetBrand; + +@ApplicationScoped +public class GetBrandPresenter + implements Presenter> { + @Override + public RestResponse present(GetBrand.Output output) { + return switch (output) { + case GetBrand.Output.Found found -> present(found); + case GetBrand.Output.NoSuchBrand noSuchKey -> present(noSuchKey); + }; + } + + private RestResponse present(GetBrand.Output.Found found) { + return RestResponse.ok(GetBrandPresenter.BrandDto.of(found.brand())); + } + + private RestResponse present( + GetBrand.Output.NoSuchBrand noSuchBrand) { + throw new NotFoundException(noSuchBrand.getClass().getSimpleName()); + } + + public record BrandDto(String uuid, String slug, String name, Set fqdns) { + public BrandDto { + if (slug.isBlank()) { + throw new IllegalStateException("slug cannot be empty"); + } + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static GetBrandPresenter.BrandDto of(GetBrand.Output.BrandOutput brand) { + return new GetBrandPresenter.BrandDto( + brand.uuid().toString(), brand.slug().value(), brand.name(), brand.fqdnSet()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/ListBrandsPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/ListBrandsPresenter.java new file mode 100644 index 0000000..d708289 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/ListBrandsPresenter.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.application.presenter.brand; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Set; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.domain.usecase.brands.ListBrands; + +@ApplicationScoped +public class ListBrandsPresenter + implements Presenter< + ListBrands, ListBrands.Output, RestResponse>> { + @Override + public RestResponse> present( + ListBrands.Output output) { + return switch (output) { + case ListBrands.Output.PaginatedBrands outputCase -> present(outputCase); + }; + } + + private RestResponse> present( + ListBrands.Output.PaginatedBrands outputCase) { + return RestResponse.ok( + PaginatedDto.of(outputCase.brandList(), ListBrandsPresenter.BrandDto::of)); + } + + public record BrandDto(String uuid, String slug, String name, Set fqdns) { + public BrandDto { + if (slug.isBlank()) { + throw new IllegalStateException("slug cannot be empty"); + } + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static ListBrandsPresenter.BrandDto of(ListBrands.Output.BrandOutput brand) { + return new ListBrandsPresenter.BrandDto( + brand.uuid().toString(), brand.slug().value(), brand.name(), brand.fqdnSet()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/UpdateBrandPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/UpdateBrandPresenter.java new file mode 100644 index 0000000..f92cc36 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/UpdateBrandPresenter.java @@ -0,0 +1,54 @@ +package tech.pegase.z3.application.presenter.brand; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.util.Set; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.brands.UpdateBrand; + +@ApplicationScoped +public class UpdateBrandPresenter + implements Presenter< + UpdateBrand, UpdateBrand.Output, RestResponse> { + @Override + public RestResponse present(UpdateBrand.Output output) { + return switch (output) { + case UpdateBrand.Output.Updated outputCase -> present(outputCase); + case UpdateBrand.Output.NotUpdated outputCase -> present(outputCase); + case UpdateBrand.Output.NoSuchBrand outputCase -> present(outputCase); + }; + } + + private RestResponse present( + UpdateBrand.Output.Updated outputCase) { + return RestResponse.ok(UpdateBrandPresenter.BrandDto.of(outputCase.brand())); + } + + private RestResponse present( + UpdateBrand.Output.NotUpdated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + private RestResponse present( + UpdateBrand.Output.NoSuchBrand ignoredOutputCase) { + throw new NotFoundException(); + } + + public record BrandDto(String uuid, String slug, String name, Set fqdns) { + public BrandDto { + if (slug.isBlank()) { + throw new IllegalStateException("slug cannot be empty"); + } + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static UpdateBrandPresenter.BrandDto of(UpdateBrand.Output.BrandOutput brand) { + return new UpdateBrandPresenter.BrandDto( + brand.uuid().toString(), brand.slug().value(), brand.name(), brand.fqdns()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/package-info.java new file mode 100644 index 0000000..9ab262b --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/brand/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.presenter.brand; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/CreateCatalogPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/CreateCatalogPresenter.java new file mode 100644 index 0000000..800c65f --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/CreateCatalogPresenter.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.application.presenter.catalog; + +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.presenter.catalog.CreateCatalogPresenter.CatalogDto; +import tech.pegase.z3.domain.usecase.catalogs.CreateCatalog; + +@ApplicationScoped +public class CreateCatalogPresenter + implements Presenter< + CreateCatalog, CreateCatalog.Output, RestResponse> { + @Override + public RestResponse present(CreateCatalog.Output output) { + return switch (output) { + case CreateCatalog.Output.Created outputCase -> present(outputCase); + case CreateCatalog.Output.NotCreated outputCase -> present(outputCase); + }; + } + + private RestResponse present( + CreateCatalog.Output.Created outputCase) { + return RestResponse.ok(CreateCatalogPresenter.CatalogDto.of(outputCase.value())); + } + + private RestResponse present( + CreateCatalog.Output.NotCreated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + public record CatalogDto(String uuid, String slug, String name, String brand) { + public CatalogDto { + if (slug.isBlank()) { + throw new IllegalStateException("slug cannot be empty"); + } + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static CatalogDto of(CreateCatalog.Output.CatalogOutput catalog) { + return new CatalogDto( + catalog.uuid().toString(), + catalog.slug().value(), + catalog.name(), + catalog.brandUuid().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/GetCatalogPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/GetCatalogPresenter.java new file mode 100644 index 0000000..0b6ca50 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/GetCatalogPresenter.java @@ -0,0 +1,48 @@ +package tech.pegase.z3.application.presenter.catalog; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog; + +@ApplicationScoped +public class GetCatalogPresenter + implements Presenter< + GetCatalog, GetCatalog.Output, RestResponse> { + @Override + public RestResponse present(GetCatalog.Output output) { + return switch (output) { + case GetCatalog.Output.Found outputCase -> present(outputCase); + case GetCatalog.Output.NoSuchCatalog outputCase -> present(outputCase); + }; + } + + private RestResponse present(GetCatalog.Output.Found outputCase) { + return RestResponse.ok(GetCatalogPresenter.CatalogDto.of(outputCase.catalog())); + } + + private RestResponse present( + GetCatalog.Output.NoSuchCatalog outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } + + public record CatalogDto(String uuid, String slug, String name, String brand) { + public CatalogDto { + if (slug.isBlank()) { + throw new IllegalStateException("slug cannot be empty"); + } + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static GetCatalogPresenter.CatalogDto of(GetCatalog.Output.CatalogOutput catalog) { + return new GetCatalogPresenter.CatalogDto( + catalog.uuid().toString(), + catalog.slug().value(), + catalog.name(), + catalog.brandUuid().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/ListCatalogsPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/ListCatalogsPresenter.java new file mode 100644 index 0000000..e3b2314 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/ListCatalogsPresenter.java @@ -0,0 +1,47 @@ +package tech.pegase.z3.application.presenter.catalog; + +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.domain.usecase.catalogs.ListCatalogs; + +@ApplicationScoped +public class ListCatalogsPresenter + implements Presenter< + ListCatalogs, + ListCatalogs.Output, + RestResponse>> { + @Override + public RestResponse> present( + ListCatalogs.Output output) { + return switch (output) { + case ListCatalogs.Output.PaginatedCatalogs outputCase -> present(outputCase); + }; + } + + private RestResponse> present( + ListCatalogs.Output.PaginatedCatalogs outputCase) { + return RestResponse.ok( + PaginatedDto.of(outputCase.catalogList(), ListCatalogsPresenter.CatalogDto::of)); + } + + public record CatalogDto(String uuid, String slug, String name, String brand) { + public CatalogDto { + if (slug.isBlank()) { + throw new IllegalStateException("slug cannot be empty"); + } + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static ListCatalogsPresenter.CatalogDto of(ListCatalogs.Output.CatalogOutput catalog) { + return new ListCatalogsPresenter.CatalogDto( + catalog.uuid().toString(), + catalog.slug().value(), + catalog.name(), + catalog.brandUuid().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/RemoveCatalogPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/RemoveCatalogPresenter.java new file mode 100644 index 0000000..f0cb86b --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/RemoveCatalogPresenter.java @@ -0,0 +1,29 @@ +package tech.pegase.z3.application.presenter.catalog; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.dto.MessageDto; +import tech.pegase.z3.domain.usecase.catalogs.RemoveCatalog; +import tech.pegase.z3.domain.usecase.catalogs.RemoveCatalog.Output.NoSuchCatalog; + +@ApplicationScoped +public class RemoveCatalogPresenter + implements Presenter> { + @Override + public RestResponse present(RemoveCatalog.Output output) { + return switch (output) { + case RemoveCatalog.Output.Removed outputCase -> present(outputCase); + case NoSuchCatalog outputCase -> present(outputCase); + }; + } + + private RestResponse present(RemoveCatalog.Output.Removed ignoredOutputCase) { + return RestResponse.ok(MessageDto.of("A été supprimé")); + } + + private RestResponse present(NoSuchCatalog outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/UpdateCatalogPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/UpdateCatalogPresenter.java new file mode 100644 index 0000000..b94b028 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/UpdateCatalogPresenter.java @@ -0,0 +1,56 @@ +package tech.pegase.z3.application.presenter.catalog; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.catalogs.UpdateCatalog; + +@ApplicationScoped +public class UpdateCatalogPresenter + implements Presenter< + UpdateCatalog, UpdateCatalog.Output, RestResponse> { + @Override + public RestResponse present(UpdateCatalog.Output output) { + return switch (output) { + case UpdateCatalog.Output.Updated outputCase -> present(outputCase); + case UpdateCatalog.Output.NotUpdated outputCase -> present(outputCase); + case UpdateCatalog.Output.NoSuchCatalog outputCase -> present(outputCase); + }; + } + + private RestResponse present( + UpdateCatalog.Output.Updated outputCase) { + return RestResponse.ok(UpdateCatalogPresenter.CatalogDto.of(outputCase.value())); + } + + private RestResponse present( + UpdateCatalog.Output.NotUpdated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + private RestResponse present( + UpdateCatalog.Output.NoSuchCatalog ignoredOutputCase) { + throw new NotFoundException(); + } + + public record CatalogDto(String uuid, String slug, String name, String brand) { + public CatalogDto { + if (slug.isBlank()) { + throw new IllegalStateException("slug cannot be empty"); + } + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static UpdateCatalogPresenter.CatalogDto of(UpdateCatalog.Output.CatalogOutput catalog) { + return new UpdateCatalogPresenter.CatalogDto( + catalog.uuid().toString(), + catalog.slug().value(), + catalog.name(), + catalog.brandUuid().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/package-info.java new file mode 100644 index 0000000..426c303 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/catalog/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.presenter.catalog; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/CreateLinkPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/CreateLinkPresenter.java new file mode 100644 index 0000000..82e7b19 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/CreateLinkPresenter.java @@ -0,0 +1,107 @@ +package tech.pegase.z3.application.presenter.link; + +import jakarta.enterprise.context.ApplicationScoped; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.ZonedDateTime; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.resteasy.reactive.RestResponse; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.links.CreateLink; + +@ApplicationScoped +public class CreateLinkPresenter + implements Presenter> { + private final URI resolverFqdn; + + CreateLinkPresenter(@ConfigProperty(name = "z3.export.resolver-fqdn") String resolverFqdn) + throws URISyntaxException { + this.resolverFqdn = new URI("https://" + resolverFqdn); + } + + @Override + public RestResponse present(CreateLink.Output output) { + return switch (output) { + case CreateLink.Output.Created outputCase -> present(outputCase); + case CreateLink.Output.NotCreated outputCase -> present(outputCase); + }; + } + + private RestResponse present(CreateLink.Output.Created outputCase) { + return RestResponse.ok(linkOutputToLinkDto(outputCase.link())); + } + + private RestResponse present( + CreateLink.Output.NotCreated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + private CreateLinkPresenter.LinkDto linkOutputToLinkDto(CreateLink.Output.LinkOutput linkOutput) { + var source = linkOutput.source(); + var sourceAbsolute = resolverFqdn.resolve(source.value()); + var target = linkOutput.target(); + var validity = linkOutput.validity(); + var brandUuid = linkOutput.brandUuid(); + + return new CreateLinkPresenter.LinkDto( + sourceAbsolute.toString(), + source.value(), + target.value(), + linkOutput.isExternal(), + validity.ordinal(), + linkOutput.visits(), + brandUuid != null ? brandUuid.toString() : null, + CreateLinkPresenter.LinkDto.LinkCatalogDto.of(linkOutput), + CreateLinkPresenter.LinkDto.LinkTagDto.of(linkOutput), + linkOutput.createdAt(), + linkOutput.updatedAt()); + } + + public record LinkDto( + String sourceAbsolute, + String source, + String target, + boolean isExternal, + int validity, + int visits, + @Nullable String brand, + CreateLinkPresenter.LinkDto.@Nullable LinkCatalogDto catalog, + CreateLinkPresenter.LinkDto.@Nullable LinkTagDto tag, + ZonedDateTime createdAt, + @Nullable ZonedDateTime updatedAt) { + public LinkDto { + if (source.isBlank()) { + throw new IllegalStateException("Source cannot be empty"); + } + if (target.isBlank()) { + throw new IllegalStateException("Target cannot be empty"); + } + if (brand != null && brand.isBlank()) { + throw new IllegalStateException("BrandSlug cannot be empty"); + } + } + + public record LinkTagDto(String uuid, String name, String color) { + public static CreateLinkPresenter.LinkDto.@Nullable LinkTagDto of( + CreateLink.Output.LinkOutput link) { + return link.tag() != null + ? new CreateLinkPresenter.LinkDto.LinkTagDto( + link.tag().uuid().toString(), link.tag().name(), link.tag().color()) + : null; + } + } + + public record LinkCatalogDto(String slug, String name) { + public static CreateLinkPresenter.LinkDto.@Nullable LinkCatalogDto of( + CreateLink.Output.LinkOutput link) { + var catalog = link.catalog(); + + return link.catalog() != null + ? new CreateLinkPresenter.LinkDto.LinkCatalogDto(catalog.slug().value(), catalog.name()) + : null; + } + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/CsvPartialExportLinkPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/CsvPartialExportLinkPresenter.java new file mode 100644 index 0000000..9efbec2 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/CsvPartialExportLinkPresenter.java @@ -0,0 +1,132 @@ +package tech.pegase.z3.application.presenter.link; + +import com.opencsv.CSVWriterBuilder; +import com.opencsv.ICSVWriter; +import jakarta.enterprise.context.RequestScoped; +import java.io.IOException; +import java.io.StringWriter; +import java.io.Writer; +import java.net.URI; +import java.util.Optional; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.link.CsvPartialExportLinkPresenter.PartialExport; +import tech.pegase.z3.domain.usecase.links.ExportLinks; +import tech.pegase.z3.domain.usecase.links.ExportLinks.Output; +import tech.pegase.z3.domain.valueobject.Fqdn; + +@RequestScoped +public class CsvPartialExportLinkPresenter + implements Presenter { + static final Logger logger = Logger.getLogger(CsvPartialExportLinkPresenter.class); + private static final boolean applyQuotesToFields = true; + private static final char defaultSeparator = ';'; + private final URI defaultBaseUri; + private boolean withHeaders = false; + private boolean hasWrittenHeaders = false; + + CsvPartialExportLinkPresenter( + @ConfigProperty(name = "z3.export.resolver-fqdn") String resolverFqdn) { + this.defaultBaseUri = Fqdn.of(resolverFqdn).getFqdnUri(); + } + + public void setWithHeaders(boolean addHeaders) { + withHeaders = addHeaders; + } + + @Override + public PartialExport present(Output output) { + return switch (output) { + case Output.Page outputCase -> present(outputCase); + case Output.NoMoreToExport outputCase -> present(outputCase); + case Output.Failed outputCase -> present(outputCase); + }; + } + + private PartialExport present(Output.Page outputCase) { + try { + var stringWriter = new StringWriter(); + var csvWriter = getCsvWriter(stringWriter); + String output; + + writeHeadersRowIfWanted(csvWriter); + writeOutputCaseRows(outputCase, csvWriter); + + output = stringWriter.toString(); + + csvWriter.flush(); + csvWriter.close(); + + return new PartialExport.Part(output); + } catch (IOException e) { + logger.error(e.getMessage(), e); + return new PartialExport.Error("Failed to write CSV line"); + } + } + + private PartialExport present(Output.NoMoreToExport ignoredOutputCase) { + return new PartialExport.Eof(); + } + + private PartialExport present(Output.Failed outputCase) { + return new PartialExport.Error(outputCase.reason()); + } + + private void writeHeadersRowIfWanted(ICSVWriter writer) { + if (!withHeaders || hasWrittenHeaders) { + return; + } + + writer.writeNext(getCsvHeaderRow(), applyQuotesToFields); + + hasWrittenHeaders = true; + } + + private void writeOutputCaseRows(Output.Page outputCase, ICSVWriter writer) { + for (var row : outputCase.rows()) { + writer.writeNext(toCsvRow(row), applyQuotesToFields); + } + } + + private String[] getCsvHeaderRow() { + return new String[] { "source", "target", "isExternal", "validity" }; + } + + private String[] toCsvRow(Output.LinkOutput row) { + var fqdn = row.host(); + var source = row.source(); + var target = row.target(); + var isExternal = row.isExternal(); + var validity = row.validity(); + var absoluteUri = Optional.ofNullable(fqdn) + .map(Fqdn::getFqdnUri) + .map(fqdnUri -> fqdnUri.resolve(source.value())) + .orElse(defaultBaseUri.resolve(source.value())); + + return new String[] { + absoluteUri.toString(), target.value(), String.valueOf(isExternal), validity.label() + }; + } + + private ICSVWriter getCsvWriter(Writer writer) { + var builder = new CSVWriterBuilder(writer); + builder.withSeparator(defaultSeparator); + + return builder.build(); + } + + public sealed interface PartialExport { + interface ExportFailure { + } + + record Part(String partContent) implements PartialExport { + } + + record Eof() implements PartialExport { + } + + record Error(String reason) implements PartialExport, ExportFailure { + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/GetLinkPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/GetLinkPresenter.java new file mode 100644 index 0000000..a1fe393 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/GetLinkPresenter.java @@ -0,0 +1,107 @@ +package tech.pegase.z3.application.presenter.link; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.ZonedDateTime; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.resteasy.reactive.RestResponse; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.domain.usecase.links.GetLink; +import tech.pegase.z3.domain.usecase.links.GetLink.Output.NoSuchSource; + +@ApplicationScoped +public class GetLinkPresenter + implements Presenter> { + private final URI resolverFqdn; + + GetLinkPresenter(@ConfigProperty(name = "z3.export.resolver-fqdn") String resolverFqdn) + throws URISyntaxException { + this.resolverFqdn = new URI("https://" + resolverFqdn); + } + + @Override + public RestResponse present(GetLink.Output output) { + return switch (output) { + case GetLink.Output.Found found -> present(found); + case NoSuchSource noSuchSource -> present(noSuchSource); + }; + } + + private RestResponse present(GetLink.Output.Found found) { + return RestResponse.ok(linkOutputToLinkDto(found.link())); + } + + private RestResponse present(NoSuchSource noSuchSource) { + throw new NotFoundException(noSuchSource.getClass().getSimpleName()); + } + + private GetLinkPresenter.LinkDto linkOutputToLinkDto(GetLink.Output.LinkOutput linkOutput) { + var source = linkOutput.source(); + var sourceAbsolute = resolverFqdn.resolve(source.value()); + var target = linkOutput.target(); + var validity = linkOutput.validity(); + var brandUuid = linkOutput.brandUuid(); + + return new GetLinkPresenter.LinkDto( + sourceAbsolute.toString(), + source.value(), + target.value(), + linkOutput.isExternal(), + validity.ordinal(), + linkOutput.visits(), + brandUuid != null ? brandUuid.toString() : null, + GetLinkPresenter.LinkDto.LinkCatalogDto.of(linkOutput), + GetLinkPresenter.LinkDto.LinkTagDto.of(linkOutput), + linkOutput.createdAt(), + linkOutput.updatedAt()); + } + + public record LinkDto( + String sourceAbsolute, + String source, + String target, + boolean isExternal, + int validity, + int visits, + @Nullable String brand, + GetLinkPresenter.LinkDto.@Nullable LinkCatalogDto catalog, + GetLinkPresenter.LinkDto.@Nullable LinkTagDto tag, + ZonedDateTime createdAt, + @Nullable ZonedDateTime updatedAt) { + public LinkDto { + if (source.isBlank()) { + throw new IllegalStateException("Source cannot be empty"); + } + if (target.isBlank()) { + throw new IllegalStateException("Target cannot be empty"); + } + if (brand != null && brand.isBlank()) { + throw new IllegalStateException("BrandSlug cannot be empty"); + } + } + + public record LinkTagDto(String uuid, String name, String color) { + public static GetLinkPresenter.LinkDto.@Nullable LinkTagDto of( + GetLink.Output.LinkOutput link) { + return link.tag() != null + ? new GetLinkPresenter.LinkDto.LinkTagDto( + link.tag().uuid().toString(), link.tag().name(), link.tag().color()) + : null; + } + } + + public record LinkCatalogDto(String slug, String name) { + public static GetLinkPresenter.LinkDto.@Nullable LinkCatalogDto of( + GetLink.Output.LinkOutput link) { + var catalog = link.catalog(); + + return link.catalog() != null + ? new GetLinkPresenter.LinkDto.LinkCatalogDto(catalog.slug().value(), catalog.name()) + : null; + } + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ImportLinksPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ImportLinksPresenter.java new file mode 100644 index 0000000..5b9c8c3 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ImportLinksPresenter.java @@ -0,0 +1,58 @@ +package tech.pegase.z3.application.presenter.link; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.util.List; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.domain.usecase.links.ImportLinks; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Output.NoSuchBrand; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Output.NoSuchCatalog; + +@ApplicationScoped +public class ImportLinksPresenter + implements Presenter< + ImportLinks, ImportLinks.Output, RestResponse> { + @Override + public RestResponse present(ImportLinks.Output output) { + return switch (output) { + case ImportLinks.Output.ImportResults outputCase -> present(outputCase); + case ImportLinks.Output.NoSuchBrand outputCase -> present(outputCase); + case ImportLinks.Output.NoSuchCatalog outputCase -> present(outputCase); + }; + } + + private RestResponse present( + NoSuchBrand ignoredOutputCase) { + throw new NotFoundException(); + } + + private RestResponse present( + NoSuchCatalog ignoredOutputCase) { + throw new NotFoundException(); + } + + private RestResponse present( + ImportLinks.Output.ImportResults output) { + return RestResponse.ok( + ImportFailuresDto.of( + output.failedImports().stream() + .map( + failedImport -> + "line %d -> \n \"%s\"\n source: %s, target: %s, ext: %b\n" + .formatted( + failedImport.line(), + failedImport.reason(), + failedImport.source(), + failedImport.target(), + failedImport.isExternal())) + .toList(), + output.importedLinks())); + } + + public record ImportFailuresDto(List failedImports, Integer importedLinks) { + public static ImportFailuresDto of(List failedImports, Integer importedLinks) { + return new ImportFailuresDto(failedImports, importedLinks); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ListLinksPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ListLinksPresenter.java new file mode 100644 index 0000000..869759e --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ListLinksPresenter.java @@ -0,0 +1,103 @@ +package tech.pegase.z3.application.presenter.link; + +import jakarta.enterprise.context.ApplicationScoped; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.ZonedDateTime; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.resteasy.reactive.RestResponse; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.domain.usecase.links.ListLinks; + +@ApplicationScoped +public class ListLinksPresenter + implements Presenter< + ListLinks, ListLinks.Output, RestResponse>> { + private final URI resolverFqdn; + + ListLinksPresenter(@ConfigProperty(name = "z3.export.resolver-fqdn") String resolverFqdn) + throws URISyntaxException { + this.resolverFqdn = new URI("https://" + resolverFqdn); + } + + @Override + public RestResponse> present(ListLinks.Output output) { + return switch (output) { + case ListLinks.Output.PaginatedLinks outputCase -> present(outputCase); + }; + } + + private RestResponse> present( + ListLinks.Output.PaginatedLinks outputCase) { + return RestResponse.ok(PaginatedDto.of(outputCase.linkList(), this::linkOutputToLinkDto)); + } + + private LinkDto linkOutputToLinkDto(ListLinks.Output.LinkOutput linkOutput) { + var source = linkOutput.source(); + var sourceAbsolute = resolverFqdn.resolve(source.value()); + var target = linkOutput.target(); + var validity = linkOutput.validity(); + var brandUuid = linkOutput.brandUuid(); + + return new ListLinksPresenter.LinkDto( + sourceAbsolute.toString(), + source.value(), + target.value(), + linkOutput.isExternal(), + validity.ordinal(), + linkOutput.visits(), + brandUuid != null ? brandUuid.toString() : null, + ListLinksPresenter.LinkDto.LinkCatalogDto.of(linkOutput), + ListLinksPresenter.LinkDto.LinkTagDto.of(linkOutput), + linkOutput.createdAt(), + linkOutput.updatedAt()); + } + + public record LinkDto( + String sourceAbsolute, + String source, + String target, + boolean isExternal, + int validity, + int visits, + @Nullable String brand, + ListLinksPresenter.LinkDto.@Nullable LinkCatalogDto catalog, + ListLinksPresenter.LinkDto.@Nullable LinkTagDto tag, + ZonedDateTime createdAt, + @Nullable ZonedDateTime updatedAt) { + public LinkDto { + if (source.isBlank()) { + throw new IllegalStateException("Source cannot be empty"); + } + if (target.isBlank()) { + throw new IllegalStateException("Target cannot be empty"); + } + if (brand != null && brand.isBlank()) { + throw new IllegalStateException("BrandSlug cannot be empty"); + } + } + + public record LinkTagDto(String uuid, String name, String color) { + public static ListLinksPresenter.LinkDto.@Nullable LinkTagDto of( + ListLinks.Output.LinkOutput link) { + return link.tag() != null + ? new ListLinksPresenter.LinkDto.LinkTagDto( + link.tag().uuid().toString(), link.tag().name(), link.tag().color()) + : null; + } + } + + public record LinkCatalogDto(String slug, String name) { + public static ListLinksPresenter.LinkDto.@Nullable LinkCatalogDto of( + ListLinks.Output.LinkOutput link) { + var catalog = link.catalog(); + + return link.catalog() != null + ? new ListLinksPresenter.LinkDto.LinkCatalogDto(catalog.slug().value(), catalog.name()) + : null; + } + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ResolveLinkResponsePresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ResolveLinkResponsePresenter.java new file mode 100644 index 0000000..996953c --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/ResolveLinkResponsePresenter.java @@ -0,0 +1,46 @@ +package tech.pegase.z3.application.presenter.link; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import java.net.URI; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.domain.usecase.links.RedirectLink; +import tech.pegase.z3.domain.usecase.links.RedirectLink.Output; + +@ApplicationScoped +public class ResolveLinkResponsePresenter + implements Presenter { + @Override + public Response present(RedirectLink.Output output) { + return switch (output) { + case Output.Found useCase -> present(useCase); + case Output.WrongFqdn useCase -> present(useCase); + case Output.NoSuchSource useCase -> present(useCase); + }; + } + + private Response present(Output.Found found) { + var target = found.link().target().value(); + var uri = URI.create(target); + // Return 301 while using GET or HEAD instead of 308 + return Response.status(Status.MOVED_PERMANENTLY) + .location(uri) + .header("Cache-Control", "no-store, no-cache, must-revalidate") + .build(); + } + + private Response present(Output.WrongFqdn ignored) { + return Response.status(Status.BAD_REQUEST) + .header("Cache-Control", "no-store, no-cache, must-revalidate") + .entity("Nom de domain invalide pour ce lien") + .build(); + } + + private Response present(Output.NoSuchSource ignored) { + return Response.status(Response.Status.NOT_FOUND) + .header("Cache-Control", "no-store, no-cache, must-revalidate") + .entity("Aucun lien trouvé") + .build(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/UpdateLinkPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/UpdateLinkPresenter.java new file mode 100644 index 0000000..d856643 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/UpdateLinkPresenter.java @@ -0,0 +1,114 @@ +package tech.pegase.z3.application.presenter.link; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.ZonedDateTime; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.resteasy.reactive.RestResponse; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.links.UpdateLink; + +@ApplicationScoped +public class UpdateLinkPresenter + implements Presenter> { + private final URI resolverFqdn; + + UpdateLinkPresenter(@ConfigProperty(name = "z3.export.resolver-fqdn") String resolverFqdn) + throws URISyntaxException { + this.resolverFqdn = new URI("https://" + resolverFqdn); + } + + @Override + public RestResponse present(UpdateLink.Output output) { + return switch (output) { + case UpdateLink.Output.Updated outputCase -> present(outputCase); + case UpdateLink.Output.NotUpdated outputCase -> present(outputCase); + case UpdateLink.Output.NoSuchSource outputCase -> present(outputCase); + }; + } + + private RestResponse present(UpdateLink.Output.Updated outputCase) { + return RestResponse.ok(linkOutputToLinkDto(outputCase.link())); + } + + private RestResponse present( + UpdateLink.Output.NotUpdated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + private RestResponse present( + UpdateLink.Output.NoSuchSource ignored) { + throw new NotFoundException(); + } + + private UpdateLinkPresenter.LinkDto linkOutputToLinkDto(UpdateLink.Output.LinkOutput linkOutput) { + var source = linkOutput.source(); + var sourceAbsolute = resolverFqdn.resolve(source.value()); + var target = linkOutput.target(); + var validity = linkOutput.validity(); + var brandUuid = linkOutput.brandUuid(); + + return new UpdateLinkPresenter.LinkDto( + sourceAbsolute.toString(), + source.value(), + target.value(), + linkOutput.isExternal(), + validity.ordinal(), + linkOutput.visits(), + brandUuid != null ? brandUuid.toString() : null, + UpdateLinkPresenter.LinkDto.LinkCatalogDto.of(linkOutput), + UpdateLinkPresenter.LinkDto.LinkTagDto.of(linkOutput), + linkOutput.createdAt(), + linkOutput.updatedAt()); + } + + public record LinkDto( + String sourceAbsolute, + String source, + String target, + boolean isExternal, + int validity, + int visits, + @Nullable String brand, + UpdateLinkPresenter.LinkDto.@Nullable LinkCatalogDto catalog, + UpdateLinkPresenter.LinkDto.@Nullable LinkTagDto tag, + ZonedDateTime createdAt, + @Nullable ZonedDateTime updatedAt) { + public LinkDto { + if (source.isBlank()) { + throw new IllegalStateException("Source cannot be empty"); + } + if (target.isBlank()) { + throw new IllegalStateException("Target cannot be empty"); + } + if (brand != null && brand.isBlank()) { + throw new IllegalStateException("BrandSlug cannot be empty"); + } + } + + public record LinkTagDto(String uuid, String name, String color) { + public static UpdateLinkPresenter.LinkDto.@Nullable LinkTagDto of( + UpdateLink.Output.LinkOutput link) { + return link.tag() != null + ? new UpdateLinkPresenter.LinkDto.LinkTagDto( + link.tag().uuid().toString(), link.tag().name(), link.tag().color()) + : null; + } + } + + public record LinkCatalogDto(String slug, String name) { + public static UpdateLinkPresenter.LinkDto.@Nullable LinkCatalogDto of( + UpdateLink.Output.LinkOutput link) { + var catalog = link.catalog(); + + return link.catalog() != null + ? new UpdateLinkPresenter.LinkDto.LinkCatalogDto(catalog.slug().value(), catalog.name()) + : null; + } + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/VerifyLinkPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/VerifyLinkPresenter.java new file mode 100644 index 0000000..893bbca --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/VerifyLinkPresenter.java @@ -0,0 +1,39 @@ +package tech.pegase.z3.application.presenter.link; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.link.VerifyLinkPresenter.LinkValidityDto; +import tech.pegase.z3.domain.usecase.links.VerifyLink; +import tech.pegase.z3.domain.usecase.links.VerifyLink.Output; + +@ApplicationScoped +public class VerifyLinkPresenter + implements Presenter> { + + @Override + public RestResponse present(Output output) { + return switch (output) { + case Output.TargetIsNotValid outputCase -> present(outputCase); + case Output.TargetIsValid outputCase -> present(outputCase); + case Output.NoSuchLink outputCase -> present(outputCase); + }; + } + + private RestResponse present(Output.TargetIsNotValid output) { + return RestResponse.ok( + new VerifyLinkPresenter.LinkValidityDto(output.linkTarget().value(), false)); + } + + private RestResponse present(Output.TargetIsValid output) { + return RestResponse.ok( + new VerifyLinkPresenter.LinkValidityDto(output.linkTarget().value(), true)); + } + + private RestResponse present(Output.NoSuchLink output) { + throw new NotFoundException(output.getClass().getSimpleName()); + } + + public record LinkValidityDto(String target, Boolean isValid) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/package-info.java new file mode 100644 index 0000000..b37fd3d --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/link/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.presenter.link; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/reporting/GetReportingSubscriptionsPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/reporting/GetReportingSubscriptionsPresenter.java new file mode 100644 index 0000000..c5f4794 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/reporting/GetReportingSubscriptionsPresenter.java @@ -0,0 +1,47 @@ +package tech.pegase.z3.application.presenter.reporting; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.util.Set; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.reporting.GetReportingSubscriptionsPresenter.ReportingSubscriptionDto; +import tech.pegase.z3.domain.entity.ReportingSubscription; +import tech.pegase.z3.domain.usecase.reporting.GetReportingSubscriptions; +import tech.pegase.z3.domain.usecase.reporting.GetReportingSubscriptions.Output; + +@ApplicationScoped +public class GetReportingSubscriptionsPresenter + implements Presenter< + GetReportingSubscriptions, + GetReportingSubscriptions.Output, + RestResponse>> { + + @Override + public RestResponse> present(Output output) { + return switch (output) { + case Output.ReportSubscriptionsForUser outputCase -> present(outputCase); + case Output.NoSuchUser outputCase -> present(outputCase); + }; + } + + private RestResponse> present( + Output.ReportSubscriptionsForUser outputCase) { + return RestResponse.ok( + outputCase.subscriptions().stream() + .map(ReportingSubscriptionDto::of) + .collect(Collectors.toSet())); + } + + private RestResponse> present(Output.NoSuchUser ignored) { + throw new NotFoundException(); + } + + public record ReportingSubscriptionDto(String user, String brand) { + public static ReportingSubscriptionDto of(ReportingSubscription subscription) { + return new ReportingSubscriptionDto( + subscription.getUser().toString(), subscription.getBrand().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/reporting/UpdateUserReportingSubscriptionsPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/reporting/UpdateUserReportingSubscriptionsPresenter.java new file mode 100644 index 0000000..e10b376 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/reporting/UpdateUserReportingSubscriptionsPresenter.java @@ -0,0 +1,68 @@ +package tech.pegase.z3.application.presenter.reporting; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.exception.ValidationException.ValidationError; +import tech.pegase.z3.domain.usecase.reporting.UpdateUserReportingSubscriptions; +import tech.pegase.z3.domain.usecase.reporting.UpdateUserReportingSubscriptions.Output; + +@ApplicationScoped +public class UpdateUserReportingSubscriptionsPresenter + implements Presenter< + UpdateUserReportingSubscriptions, + UpdateUserReportingSubscriptions.Output, + RestResponse> { + + @Override + public RestResponse present(Output output) { + return switch (output) { + case Output.SubscriptionsUpdated outputCase -> present(outputCase); + case Output.NoSuchUser outputCase -> present(outputCase); + case Output.TheseBrandsDoNotExist outputCase -> present(outputCase); + case Output.UserDoNotBelongToTheseBrands outputCase -> present(outputCase); + }; + } + + private RestResponse present( + Output.SubscriptionsUpdated ignored) { + return RestResponse.ok(MessageDto.of("Souscriptions mises à jour")); + } + + private RestResponse present( + Output.UserDoNotBelongToTheseBrands ignored) { + throw new ValidationException( + Set.of( + ValidationError.of( + "brands", + "L'utilisateur n'appartient pas à ces enseignes : " + + ignored.brandsUuids().stream() + .map(UUID::toString) + .collect(Collectors.joining())))); + } + + private RestResponse present( + Output.NoSuchUser ignored) { + throw new ValidationException( + Set.of(ValidationError.of("user", "Cet utilisateur n'existe pas"))); + } + + private RestResponse present( + Output.TheseBrandsDoNotExist output) { + throw new ValidationException( + Set.of( + ValidationError.of( + "brands", + output.brandsUuids().stream().map(UUID::toString).collect(Collectors.joining())))); + } + + public record MessageDto(String message) { + public static UpdateUserReportingSubscriptionsPresenter.MessageDto of(String value) { + return new UpdateUserReportingSubscriptionsPresenter.MessageDto(value); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/GetGlobalStatisticsPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/GetGlobalStatisticsPresenter.java new file mode 100644 index 0000000..8b7d791 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/GetGlobalStatisticsPresenter.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.application.presenter.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.util.List; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.statistics.GetGlobalStatisticsPresenter.GlobalStatistic; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Output; + +@ApplicationScoped +public class GetGlobalStatisticsPresenter + implements Presenter>> { + + @Override + public RestResponse> present(Output output) { + return switch (output) { + case Output.GeneralStatistics outputCase -> present(outputCase); + case Output.NoSuchBrand outputCase -> present(outputCase); + }; + } + + private RestResponse> present(Output.GeneralStatistics outputCase) { + + return RestResponse.ok( + outputCase.statistics().stream().map(GlobalStatistic::of).collect(Collectors.toList())); + } + + private RestResponse> present(Output.NoSuchBrand ignoredOutputCase) { + throw new NotFoundException("Aucune enseigne trouvée"); + } + + public record GlobalStatistic(String key, String statistic, Long value) { + public static GlobalStatistic of(Output.GlobalStatistic statistic) { + return new GlobalStatistic(statistic.type(), statistic.statistic(), statistic.value()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/GetLinksStatisticsPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/GetLinksStatisticsPresenter.java new file mode 100644 index 0000000..9429835 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/GetLinksStatisticsPresenter.java @@ -0,0 +1,44 @@ +package tech.pegase.z3.application.presenter.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.util.Map; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.statistics.GetLinksStatisticsPresenter.StatisticLine; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Output; + +@ApplicationScoped +public class GetLinksStatisticsPresenter + implements Presenter>> { + + @Override + public RestResponse> present(Output output) { + return switch (output) { + case Output.Statistics outputCase -> present(outputCase); + case Output.NoSuchBrand outputCase -> present(outputCase); + }; + } + + private RestResponse> present(Output.Statistics outputCase) { + + return RestResponse.ok( + outputCase.visitsPerEntity().entrySet().stream() + .collect( + Collectors.toMap( + key -> key.getKey().toString(), value -> StatisticLine.of(value.getValue())))); + } + + private RestResponse> present(Output.NoSuchBrand ignored) { + throw new NotFoundException("Aucune enseigne trouvée"); + } + + public record StatisticLine(String uuid, String name, Integer totalVisits) { + public static StatisticLine of(Output.StatisticLine statisticLine) { + return new StatisticLine( + statisticLine.uuid().toString(), statisticLine.name(), statisticLine.totalVisits()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/InvalidLinksPerWeekPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/InvalidLinksPerWeekPresenter.java new file mode 100644 index 0000000..c1afd8e --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/InvalidLinksPerWeekPresenter.java @@ -0,0 +1,48 @@ +package tech.pegase.z3.application.presenter.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.RestResponse.Status; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.statistics.InvalidLinksPerWeekPresenter.WeekInvalidLinks; +import tech.pegase.z3.statistics.domain.usecase.statistics.GetInvalidLinksCountsPerWeek; +import tech.pegase.z3.statistics.domain.usecase.statistics.GetInvalidLinksCountsPerWeek.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +@ApplicationScoped +public class InvalidLinksPerWeekPresenter + implements Presenter< + GetInvalidLinksCountsPerWeek, + GetInvalidLinksCountsPerWeek.Output, + RestResponse>>> { + + @Override + public RestResponse>> present(Output output) { + return switch (output) { + case Output.GetCounts outputCase -> present(outputCase); + }; + } + + RestResponse>> present(Output.GetCounts outputCase) { + var statisticLines = outputCase.counts(); + var groupedStatistics = groupStatistics(statisticLines); + + return RestResponse.status(Status.OK, groupedStatistics); + } + + private Map> groupStatistics( + List brandInvalidLinksPerWeek) { + return brandInvalidLinksPerWeek.stream() + .collect( + Collectors.groupingBy( + StatisticLinePerInterval::value, + Collectors.mapping( + s -> new WeekInvalidLinks(s.interval(), s.count()), Collectors.toList()))); + } + + public record WeekInvalidLinks(ZonedDateTime weekOfYear, Integer count) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/MostFrequentReferrersPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/MostFrequentReferrersPresenter.java new file mode 100644 index 0000000..5b34096 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/MostFrequentReferrersPresenter.java @@ -0,0 +1,41 @@ +package tech.pegase.z3.application.presenter.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.statistics.MostFrequentReferrersPresenter.TopReferrerLine; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostFrequentReferrers; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostFrequentReferrers.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLineWithEntity; + +@ApplicationScoped +public class MostFrequentReferrersPresenter + implements Presenter< + MostFrequentReferrers, MostFrequentReferrers.Output, RestResponse>> { + + @Override + public RestResponse> present(Output output) { + return switch (output) { + case Output.TopList outputCase -> present(outputCase); + }; + } + + private RestResponse> present(Output.TopList output) { + var outputList = + output.count().stream().map(this::outputLineMapper).collect(Collectors.toList()); + + return RestResponse.ok(outputList); + } + + private TopReferrerLine outputLineMapper(StatisticLineWithEntity statisticLine) { + var referrer = statisticLine.value(); + var brand = statisticLine.entity(); + var count = statisticLine.count(); + + return new TopReferrerLine(referrer, brand, count); + } + + public record TopReferrerLine(String referrer, String brand, Integer count) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/MostVisitedSourcePresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/MostVisitedSourcePresenter.java new file mode 100644 index 0000000..cfed9ff --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/MostVisitedSourcePresenter.java @@ -0,0 +1,41 @@ +package tech.pegase.z3.application.presenter.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.statistics.MostVisitedSourcePresenter.TopSourceLine; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostVisitedSource; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostVisitedSource.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLineWithEntity; + +@ApplicationScoped +public class MostVisitedSourcePresenter + implements Presenter< + MostVisitedSource, MostVisitedSource.Output, RestResponse>> { + + @Override + public RestResponse> present(Output output) { + return switch (output) { + case Output.TopList outputCase -> present(outputCase); + }; + } + + RestResponse> present(Output.TopList output) { + var outputList = + output.count().stream().map(this::outputLineMapper).collect(Collectors.toList()); + + return RestResponse.ok(outputList); + } + + private TopSourceLine outputLineMapper(StatisticLineWithEntity statisticLine) { + var source = statisticLine.value(); + var brand = statisticLine.entity(); + var count = statisticLine.count(); + + return new TopSourceLine(source, brand, count); + } + + public record TopSourceLine(String source, String brand, Integer count) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/UniqueVisitorsByBrandPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/UniqueVisitorsByBrandPresenter.java new file mode 100644 index 0000000..2225e78 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/UniqueVisitorsByBrandPresenter.java @@ -0,0 +1,64 @@ +package tech.pegase.z3.application.presenter.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.RestResponse.Status; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.statistics.UniqueVisitorsByBrandPresenter.BrandUniqueVisit; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByBrand; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByBrand.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +@ApplicationScoped +public class UniqueVisitorsByBrandPresenter + implements Presenter< + UniqueVisitorsByBrand, Output, RestResponse>>> { + + @Override + public RestResponse>> present(Output output) { + return switch (output) { + case Output.CountsForAll outputCase -> present(outputCase); + }; + } + + private RestResponse>> present( + Output.CountsForAll outputCase) { + var statisticLines = outputCase.counts(); + var groupedStatistics = groupStatistics(statisticLines); + + return RestResponse.status(Status.OK, groupedStatistics); + } + + private Map> groupStatistics( + List brandVisitsPerDay) { + Map> brandStatistics = new HashMap<>(); + + brandVisitsPerDay.forEach( + statistic -> { + var brandName = statistic.value(); + var interval = statistic.interval(); + var count = statistic.count(); + var brandUniqueVisit = new BrandUniqueVisit(interval, count); + List visitsPerDayOfYear; + + // Get or create a new list foreach brands + if (brandStatistics.containsKey(brandName)) { + visitsPerDayOfYear = brandStatistics.get(brandName); + } else { + visitsPerDayOfYear = new LinkedList<>(); + brandStatistics.put(brandName, visitsPerDayOfYear); + } + + visitsPerDayOfYear.add(brandUniqueVisit); + }); + + return brandStatistics; + } + + public record BrandUniqueVisit(ZonedDateTime dayOfYear, Integer count) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/UniqueVisitorsPresenterByCatalog.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/UniqueVisitorsPresenterByCatalog.java new file mode 100644 index 0000000..6eaaaec --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/statistics/UniqueVisitorsPresenterByCatalog.java @@ -0,0 +1,64 @@ +package tech.pegase.z3.application.presenter.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import org.jboss.resteasy.reactive.RestResponse; +import org.jboss.resteasy.reactive.RestResponse.Status; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.presenter.statistics.UniqueVisitorsPresenterByCatalog.CatalogUniqueVisit; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByCatalog; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByCatalog.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +@ApplicationScoped +public class UniqueVisitorsPresenterByCatalog + implements Presenter< + UniqueVisitorsByCatalog, Output, RestResponse>>> { + + @Override + public RestResponse>> present(Output output) { + return switch (output) { + case Output.CountsForAll outputCase -> present(outputCase); + }; + } + + private RestResponse>> present( + Output.CountsForAll outputCase) { + var statisticLines = outputCase.counts(); + var groupedStatistics = groupStatistics(statisticLines); + + return RestResponse.status(Status.OK, groupedStatistics); + } + + private Map> groupStatistics( + List catalogVisitsPerDay) { + Map> catalogStatistics = new HashMap<>(); + + catalogVisitsPerDay.forEach( + statistic -> { + var catalogName = statistic.value(); + var interval = statistic.interval(); + var count = statistic.count(); + var catalogUniqueVisit = new CatalogUniqueVisit(interval, count); + List visitsPerDayOfYear; + + // Get or create a new list foreach catalogs + if (catalogStatistics.containsKey(catalogName)) { + visitsPerDayOfYear = catalogStatistics.get(catalogName); + } else { + visitsPerDayOfYear = new LinkedList<>(); + catalogStatistics.put(catalogName, visitsPerDayOfYear); + } + + visitsPerDayOfYear.add(catalogUniqueVisit); + }); + + return catalogStatistics; + } + + public record CatalogUniqueVisit(ZonedDateTime dayOfYear, Integer count) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/CreateTagPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/CreateTagPresenter.java new file mode 100644 index 0000000..fbabe2f --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/CreateTagPresenter.java @@ -0,0 +1,39 @@ +package tech.pegase.z3.application.presenter.tag; + +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.tags.CreateTag; + +@ApplicationScoped +public class CreateTagPresenter + implements Presenter> { + @Override + public RestResponse present(CreateTag.Output output) { + return switch (output) { + case CreateTag.Output.Created outputCase -> present(outputCase); + case CreateTag.Output.NotCreated outputCase -> present(outputCase); + }; + } + + private RestResponse present(CreateTag.Output.Created outputCase) { + return RestResponse.ok(CreateTagPresenter.TagDto.of(outputCase.value())); + } + + private RestResponse present(CreateTag.Output.NotCreated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + public record TagDto(String uuid, String name, String color) { + public TagDto { + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static TagDto of(CreateTag.Output.TagOutput tag) { + return new TagDto(tag.uuid().toString(), tag.name(), tag.color().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/GetTagPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/GetTagPresenter.java new file mode 100644 index 0000000..e97e479 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/GetTagPresenter.java @@ -0,0 +1,39 @@ +package tech.pegase.z3.application.presenter.tag; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.domain.usecase.tags.GetTag; + +@ApplicationScoped +public class GetTagPresenter + implements Presenter> { + @Override + public RestResponse present(GetTag.Output output) { + return switch (output) { + case GetTag.Output.Found outputCase -> present(outputCase); + case GetTag.Output.NoSuchTag outputCase -> present(outputCase); + }; + } + + private RestResponse present(GetTag.Output.Found outputCase) { + return RestResponse.ok(GetTagPresenter.TagDto.of(outputCase.tag())); + } + + private RestResponse present(GetTag.Output.NoSuchTag outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } + + public record TagDto(String uuid, String name, String color) { + public TagDto { + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static GetTagPresenter.TagDto of(GetTag.Output.TagOutput tag) { + return new GetTagPresenter.TagDto(tag.uuid().toString(), tag.name(), tag.color().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/ListTagsPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/ListTagsPresenter.java new file mode 100644 index 0000000..d9a23c3 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/ListTagsPresenter.java @@ -0,0 +1,37 @@ +package tech.pegase.z3.application.presenter.tag; + +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.domain.usecase.tags.ListTags; + +@ApplicationScoped +public class ListTagsPresenter + implements Presenter< + ListTags, ListTags.Output, RestResponse>> { + @Override + public RestResponse> present(ListTags.Output output) { + return switch (output) { + case ListTags.Output.PaginatedTags outputCase -> present(outputCase); + }; + } + + private RestResponse> present( + ListTags.Output.PaginatedTags outputCase) { + return RestResponse.ok(PaginatedDto.of(outputCase.tagList(), ListTagsPresenter.TagDto::of)); + } + + public record TagDto(String uuid, String name, String color) { + public TagDto { + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static ListTagsPresenter.TagDto of(ListTags.Output.TagOutput tag) { + return new ListTagsPresenter.TagDto( + tag.uuid().toString(), tag.name(), tag.color().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/RemoveTagPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/RemoveTagPresenter.java new file mode 100644 index 0000000..2ff0fc0 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/RemoveTagPresenter.java @@ -0,0 +1,29 @@ +package tech.pegase.z3.application.presenter.tag; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.dto.MessageDto; +import tech.pegase.z3.domain.usecase.tags.RemoveTag; +import tech.pegase.z3.domain.usecase.tags.RemoveTag.Output.NoSuchTag; + +@ApplicationScoped +public class RemoveTagPresenter + implements Presenter> { + @Override + public RestResponse present(RemoveTag.Output output) { + return switch (output) { + case RemoveTag.Output.Removed outputCase -> present(outputCase); + case NoSuchTag outputCase -> present(outputCase); + }; + } + + private RestResponse present(RemoveTag.Output.Removed ignoredOutputCase) { + return RestResponse.ok(MessageDto.of("A été supprimé")); + } + + private RestResponse present(NoSuchTag outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/UpdateTagPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/UpdateTagPresenter.java new file mode 100644 index 0000000..24a071d --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/UpdateTagPresenter.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.application.presenter.tag; + +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.tags.UpdateTag; + +@ApplicationScoped +public class UpdateTagPresenter + implements Presenter> { + @Override + public RestResponse present(UpdateTag.Output output) { + return switch (output) { + case UpdateTag.Output.Updated outputCase -> present(outputCase); + case UpdateTag.Output.NotUpdated outputCase -> present(outputCase); + }; + } + + private RestResponse present(UpdateTag.Output.Updated outputCase) { + return RestResponse.ok(TagDto.of(outputCase.value())); + } + + private RestResponse present(UpdateTag.Output.NotUpdated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + public record TagDto(String uuid, String name, String color) { + public TagDto { + if (name.isBlank()) { + throw new IllegalStateException("name cannot be empty"); + } + } + + public static UpdateTagPresenter.TagDto of(UpdateTag.Output.TagOutput tag) { + return new UpdateTagPresenter.TagDto( + tag.uuid().toString(), tag.name(), tag.color().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/package-info.java new file mode 100644 index 0000000..72e7da1 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/tag/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.presenter.tag; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/CreateUserPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/CreateUserPresenter.java new file mode 100644 index 0000000..77cba47 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/CreateUserPresenter.java @@ -0,0 +1,79 @@ +package tech.pegase.z3.application.presenter.user; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.AuthorizationException; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.presenter.user.CreateUserPresenter.UserDto; +import tech.pegase.z3.domain.usecase.users.CreateUser; +import tech.pegase.z3.domain.usecase.users.CreateUser.Output; + +@ApplicationScoped +public class CreateUserPresenter implements Presenter> { + @Override + public RestResponse present(Output output) { + return switch (output) { + case Output.Created outputCase -> present(outputCase); + case Output.Forbidden outputCase -> present(outputCase); + case Output.NotCreated outputCase -> present(outputCase); + case Output.MessageCannotBeSent outputCase -> present(outputCase); + }; + } + + private RestResponse present(Output.Created outputCase) { + return RestResponse.ok(UserDto.of(outputCase.value())); + } + + private RestResponse present(Output.Forbidden outputCase) { + throw AuthorizationException.of(outputCase.reason()); + } + + private RestResponse present(Output.NotCreated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + private RestResponse present(Output.MessageCannotBeSent outputCase) { + throw new ServerErrorException(outputCase.getClass().getSimpleName(), 502); + } + + public record UserDto( + String uuid, + String firstName, + String lastName, + String email, + String role, + Set brands) { + public UserDto { + if (uuid.isBlank()) { + throw new IllegalStateException("uuid cannot be empty"); + } + if (firstName.isBlank()) { + throw new IllegalStateException("firstName cannot be empty"); + } + if (lastName.isBlank()) { + throw new IllegalStateException("lastName cannot be empty"); + } + if (email.isBlank()) { + throw new IllegalStateException("email cannot be empty"); + } + if (role.isBlank()) { + throw new IllegalStateException("role cannot be empty"); + } + } + + public static UserDto of(Output.UserOutput user) { + return new UserDto( + user.uuid().toString(), + user.firstName(), + user.lastName(), + user.email().value(), + user.role().value().toString(), + user.brandsUuids().stream().map(UUID::toString).collect(Collectors.toSet())); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/GetUserPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/GetUserPresenter.java new file mode 100644 index 0000000..da9ea3e --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/GetUserPresenter.java @@ -0,0 +1,66 @@ +package tech.pegase.z3.application.presenter.user; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.domain.usecase.users.GetUser; + +@ApplicationScoped +public class GetUserPresenter + implements Presenter> { + @Override + public RestResponse present(GetUser.Output output) { + return switch (output) { + case GetUser.Output.Found outputCase -> present(outputCase); + case GetUser.Output.NoSuchUser outputCase -> present(outputCase); + }; + } + + private RestResponse present(GetUser.Output.Found outputCase) { + return RestResponse.ok(UserDto.of(outputCase.user())); + } + + private RestResponse present(GetUser.Output.NoSuchUser outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } + + public record UserDto( + String uuid, + String firstName, + String lastName, + String email, + String role, + Set brands) { + public UserDto { + if (uuid.isBlank()) { + throw new IllegalStateException("uuid cannot be empty"); + } + if (firstName.isBlank()) { + throw new IllegalStateException("firstName cannot be empty"); + } + if (lastName.isBlank()) { + throw new IllegalStateException("lastName cannot be empty"); + } + if (email.isBlank()) { + throw new IllegalStateException("email cannot be empty"); + } + if (role.isBlank()) { + throw new IllegalStateException("role cannot be empty"); + } + } + + public static UserDto of(GetUser.Output.UserOutput user) { + return new UserDto( + user.uuid().toString(), + user.firstName(), + user.lastName(), + user.email().value(), + user.role().value().toString(), + user.brandsUuids().stream().map(UUID::toString).collect(Collectors.toSet())); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/ListUsersPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/ListUsersPresenter.java new file mode 100644 index 0000000..d5585e4 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/ListUsersPresenter.java @@ -0,0 +1,52 @@ +package tech.pegase.z3.application.presenter.user; + +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.dto.PaginatedDto; +import tech.pegase.z3.domain.usecase.users.ListUsers; + +@ApplicationScoped +public class ListUsersPresenter + implements Presenter< + ListUsers, ListUsers.Output, RestResponse>> { + @Override + public RestResponse> present(ListUsers.Output output) { + return switch (output) { + case ListUsers.Output.PaginatedUsers outputCase -> present(outputCase); + }; + } + + private RestResponse> present(ListUsers.Output.PaginatedUsers outputCase) { + return RestResponse.ok(PaginatedDto.of(outputCase.userList(), UserDto::of)); + } + + public record UserDto(String uuid, String firstName, String lastName, String email, String role) { + public UserDto { + if (uuid.isBlank()) { + throw new IllegalStateException("uuid cannot be empty"); + } + if (firstName.isBlank()) { + throw new IllegalStateException("firstName cannot be empty"); + } + if (lastName.isBlank()) { + throw new IllegalStateException("lastName cannot be empty"); + } + if (email.isBlank()) { + throw new IllegalStateException("email cannot be empty"); + } + if (role.isBlank()) { + throw new IllegalStateException("role cannot be empty"); + } + } + + public static UserDto of(ListUsers.Output.UserOutput user) { + return new UserDto( + user.uuid().toString(), + user.firstName(), + user.lastName(), + user.email().value(), + user.role().value().toString()); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/RemoveUserPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/RemoveUserPresenter.java new file mode 100644 index 0000000..7dc21a3 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/RemoveUserPresenter.java @@ -0,0 +1,35 @@ +package tech.pegase.z3.application.presenter.user; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.ForbiddenException; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.dto.MessageDto; +import tech.pegase.z3.domain.usecase.users.RemoveUser; +import tech.pegase.z3.domain.usecase.users.RemoveUser.Output.ForbiddenAction; + +@ApplicationScoped +public class RemoveUserPresenter + implements Presenter> { + @Override + public RestResponse present(RemoveUser.Output output) { + return switch (output) { + case RemoveUser.Output.Removed outputCase -> present(outputCase); + case RemoveUser.Output.NotSuchUser outputCase -> present(outputCase); + case ForbiddenAction outputCase -> present(outputCase); + }; + } + + private RestResponse present(RemoveUser.Output.Removed ignored) { + return RestResponse.ok(MessageDto.of("A été supprimé")); + } + + private RestResponse present(RemoveUser.Output.NotSuchUser outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } + + private RestResponse present(ForbiddenAction outputCase) { + throw new ForbiddenException(outputCase.getClass().getSimpleName()); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/ResetUserPasswordPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/ResetUserPasswordPresenter.java new file mode 100644 index 0000000..916bae8 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/ResetUserPasswordPresenter.java @@ -0,0 +1,44 @@ +package tech.pegase.z3.application.presenter.user; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ServerErrorException; +import tech.pegase.z3.domain.usecase.users.ResetUserPassword; +import tech.pegase.z3.domain.usecase.users.ResetUserPassword.Output; +import tech.pegase.z3.domain.usecase.users.ResetUserPassword.Output.MessageSent; + +@ApplicationScoped +public class ResetUserPasswordPresenter + implements Presenter< + ResetUserPassword, + ResetUserPassword.Output, + RestResponse> { + @Override + public RestResponse present(ResetUserPassword.Output output) { + return switch (output) { + case MessageSent outputCase -> present(outputCase); + case ResetUserPassword.Output.NotSuchUser outputCase -> present(outputCase); + case ResetUserPassword.Output.MessageCannotBeSent outputCase -> present(outputCase); + }; + } + + private RestResponse present(Output.MessageSent ignoredOutputCase) { + return RestResponse.ok(MessageDto.of("Message envoyé")); + } + + private RestResponse present(Output.NotSuchUser outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } + + private RestResponse present(Output.MessageCannotBeSent outputCase) { + throw new ServerErrorException(outputCase.getClass().getSimpleName(), 502); + } + + public record MessageDto(String value) { + public static MessageDto of(String message) { + return new MessageDto(message); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/UpdateUserPresenter.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/UpdateUserPresenter.java new file mode 100644 index 0000000..7849923 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/UpdateUserPresenter.java @@ -0,0 +1,72 @@ +package tech.pegase.z3.application.presenter.user; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.NotFoundException; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.happy.core.Presenter; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.domain.usecase.users.UpdateUser; + +@ApplicationScoped +public class UpdateUserPresenter + implements Presenter> { + @Override + public RestResponse present(UpdateUser.Output output) { + return switch (output) { + case UpdateUser.Output.Updated outputCase -> present(outputCase); + case UpdateUser.Output.NoSuchUser outputCase -> present(outputCase); + case UpdateUser.Output.NotUpdated outputCase -> present(outputCase); + }; + } + + private RestResponse present(UpdateUser.Output.Updated outputCase) { + return RestResponse.ok(UserDto.of(outputCase.value())); + } + + private RestResponse present(UpdateUser.Output.NoSuchUser outputCase) { + throw new NotFoundException(outputCase.getClass().getSimpleName()); + } + + private RestResponse present(UpdateUser.Output.NotUpdated outputCase) { + throw ValidationException.of(outputCase.reasons().invalidFields()); + } + + public record UserDto( + String uuid, + String firstName, + String lastName, + String email, + String role, + Set brands) { + public UserDto { + if (uuid.isBlank()) { + throw new IllegalStateException("uuid cannot be empty"); + } + if (firstName.isBlank()) { + throw new IllegalStateException("firstName cannot be empty"); + } + if (lastName.isBlank()) { + throw new IllegalStateException("lastName cannot be empty"); + } + if (email.isBlank()) { + throw new IllegalStateException("email cannot be empty"); + } + if (role.isBlank()) { + throw new IllegalStateException("role cannot be empty"); + } + } + + public static UpdateUserPresenter.UserDto of(UpdateUser.Output.UserOutput user) { + return new UpdateUserPresenter.UserDto( + user.uuid().toString(), + user.firstName(), + user.lastName(), + user.email().value(), + user.role().value().toString(), + user.brandsUuids().stream().map(UUID::toString).collect(Collectors.toSet())); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/package-info.java new file mode 100644 index 0000000..a4d3593 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/presenter/user/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.presenter.user; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/producer/UserContextProducer.java b/z3-application/src/main/java/tech/pegase/z3/application/producer/UserContextProducer.java new file mode 100644 index 0000000..9cf65cc --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/producer/UserContextProducer.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.application.producer; + +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.narayana.jta.TransactionRunnerOptions; +import io.quarkus.narayana.jta.TransactionSemantics; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.Produces; +import java.util.UUID; +import org.eclipse.microprofile.jwt.Claim; +import org.eclipse.microprofile.jwt.Claims; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.context.UserContextNull; +import tech.pegase.z3.application.presenter.auth.UserContextPresenter; +import tech.pegase.z3.domain.usecase.users.GetUser; + +/** + * Quarkus transaction is needed only here because classic use case executor need UserContext to be + * injected. + */ +@NullMarked +@ApplicationScoped +public class UserContextProducer { + private final TransactionRunnerOptions useCaseExecutor; + private final GetUser getUserUseCase; + private final UserContextPresenter userContextPresenter; + + @Inject + public UserContextProducer(GetUser getUserUseCase, UserContextPresenter userContextPresenter) { + this.getUserUseCase = getUserUseCase; + this.useCaseExecutor = QuarkusTransaction.runner(TransactionSemantics.REQUIRE_NEW); + this.userContextPresenter = userContextPresenter; + } + + @RequestScoped + @Produces + public @Nullable UserContext currentUser(@Nullable @Claim(standard = Claims.sub) String userId) { + if (userId == null) { + return new UserContextNull(); + } + + var input = new GetUser.Input.ForId(UUID.fromString(userId)); + var output = useCaseExecutor.call(() -> getUserUseCase.run(input)); + + return userContextPresenter.present(output); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/JwtContextFilter.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/JwtContextFilter.java new file mode 100644 index 0000000..07ae600 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/JwtContextFilter.java @@ -0,0 +1,51 @@ +package tech.pegase.z3.application.provider; + +import io.quarkus.security.AuthenticationFailedException; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.ext.Provider; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.application.context.UserContext; +import tech.pegase.z3.application.provider.exception.ErrorJsonExceptionMapper; + +/** This is used. */ +@Provider +public class JwtContextFilter implements ContainerRequestFilter { + private final UserContext userContext; + private final ErrorJsonExceptionMapper errorJsonExceptionMapper; + + public JwtContextFilter( + UserContext userContext, ErrorJsonExceptionMapper errorJsonExceptionMapper) { + this.userContext = userContext; + this.errorJsonExceptionMapper = errorJsonExceptionMapper; + } + + @Override + public void filter(ContainerRequestContext ctx) { + var jwtUserId = jwtUserId(ctx); + var contextUserId = userContextId(userContext); + + // ? JWT TOKEN GIVEN ? + if (jwtUserId == null) { + // ! No, then just continue + return; + } + + // ? Does the JWT identified user matches our user context ? + if (!jwtUserId.equals(contextUserId)) { + ctx.abortWith( + errorJsonExceptionMapper.toResponse( + new AuthenticationFailedException("Jwt token does not match any user"))); + } + } + + private @Nullable String jwtUserId(ContainerRequestContext requestContext) { + var securityContext = requestContext.getSecurityContext(); + var principal = securityContext.getUserPrincipal(); + return principal != null ? principal.getName() : null; + } + + private @Nullable String userContextId(UserContext userContext) { + return userContext.getId() != null ? userContext.getId().toString() : null; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/AuthenticationExceptionMapper.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/AuthenticationExceptionMapper.java new file mode 100644 index 0000000..0d4943d --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/AuthenticationExceptionMapper.java @@ -0,0 +1,35 @@ +package tech.pegase.z3.application.provider.exception; + +import io.quarkus.security.AuthenticationFailedException; +import jakarta.annotation.Priority; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.z3.application.dto.MessageDto; + +/** + * Catch authentication Exceptions see GH issue. + */ +@Provider +@Priority(1) +public class AuthenticationExceptionMapper + implements ExceptionMapper { + private static final Logger logger = Logger.getLogger(AuthenticationExceptionMapper.class); + + @Override + public Response toResponse(AuthenticationFailedException e) { + final var message = "Unauthenticated"; + logger.debug(message); + try (var response = RestResponse.status(Status.UNAUTHORIZED, MessageDto.of(message))) { + return response.toResponse(); + } catch (Error err) { + logger.fatalf(err.getMessage(), err); + return Response.status(Status.INTERNAL_SERVER_ERROR.getStatusCode(), "Unhandled Server error") + .build(); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/AutorisationExceptionMapper.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/AutorisationExceptionMapper.java new file mode 100644 index 0000000..ea94a8d --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/AutorisationExceptionMapper.java @@ -0,0 +1,28 @@ +package tech.pegase.z3.application.provider.exception; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.z3.application.dto.MessageDto; +import tech.pegase.z3.application.exception.AuthorizationException; + +@Provider +public class AutorisationExceptionMapper implements ExceptionMapper { + private static final Logger logger = Logger.getLogger(AutorisationExceptionMapper.class); + + @Override + public Response toResponse(AuthorizationException e) { + var message = e.getMessage(); + logger.debug(message); + try (var response = RestResponse.status(Status.FORBIDDEN, MessageDto.of(message))) { + return response.toResponse(); + } catch (Error err) { + logger.fatalf(err.getMessage(), err); + return Response.status(Status.INTERNAL_SERVER_ERROR.getStatusCode(), "Unhandled Server error") + .build(); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ErrorJsonExceptionMapper.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ErrorJsonExceptionMapper.java new file mode 100644 index 0000000..18ba58e --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ErrorJsonExceptionMapper.java @@ -0,0 +1,73 @@ +package tech.pegase.z3.application.provider.exception; + +import io.quarkus.security.AuthenticationFailedException; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import java.util.Map; +import java.util.stream.Collectors; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.z3.application.dto.MessageDto; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.exception.ValidationException.ValidationError; + +@Provider +public class ErrorJsonExceptionMapper implements ExceptionMapper { + private static final Logger logger = Logger.getLogger(ErrorJsonExceptionMapper.class); + + @Override + public Response toResponse(Exception e) { + logger.debug(e.getMessage()); + + if (e instanceof ValidationException) { + return mapValidationExceptionToResponse((ValidationException) e); + } + + try (var mappedResponse = mapExceptionToResponse(e)) { + return mappedResponse.toResponse(); + } + } + + private Response mapValidationExceptionToResponse(ValidationException e) { + try (var restResponse = + RestResponse.status( + Status.BAD_REQUEST, + Map.of( + "errors", + e.getErrors().stream() + .collect( + Collectors.toMap(ValidationError::field, ValidationError::message))))) { + var customResponse = Response.fromResponse(restResponse.toResponse()); + customResponse.status(422); + return customResponse.build(); + } + } + + private RestResponse mapExceptionToResponse(Exception exception) { + var message = MessageDto.of(exception.getMessage()); + + return switch (exception) { + // * 401 + case AuthenticationFailedException ignored -> + RestResponse.status(Status.UNAUTHORIZED, message); + + // * 400 + case IllegalStateException ignored -> RestResponse.status(Status.BAD_REQUEST, message); + case IllegalArgumentException ignored -> RestResponse.status(Status.BAD_REQUEST, message); + + // ? Any ? + case WebApplicationException e -> + RestResponse.status(Status.fromStatusCode(e.getResponse().getStatus()), message); + + // ! 500 + default -> { + /* Use 500 (Internal Server Error) for all other. */ + logger.fatalf(exception, "Unhandled Server error"); + yield RestResponse.status(Status.INTERNAL_SERVER_ERROR, MessageDto.of("Erreur serveur")); + } + }; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ForbiddenExceptionMapper.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ForbiddenExceptionMapper.java new file mode 100644 index 0000000..3188e72 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ForbiddenExceptionMapper.java @@ -0,0 +1,34 @@ +package tech.pegase.z3.application.provider.exception; + +import io.quarkus.security.ForbiddenException; +import jakarta.annotation.Nullable; +import jakarta.annotation.Priority; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.z3.application.dto.MessageDto; + +@Provider +// override the build-int mapper(which has 5001 prio) +@Priority(1) +public class ForbiddenExceptionMapper implements ExceptionMapper { + private static final Logger logger = Logger.getLogger(AutorisationExceptionMapper.class); + + @Override + public Response toResponse(ForbiddenException e) { + @Nullable var message = e.getMessage(); + logger.debug(message); + try (var response = + RestResponse.status( + Status.FORBIDDEN, MessageDto.of(message != null ? message : "Unauthorized"))) { + return response.toResponse(); + } catch (Error err) { + logger.fatalf(err.getMessage(), err); + return Response.status(Status.INTERNAL_SERVER_ERROR.getStatusCode(), "Unhandled Server error") + .build(); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/InvalidFormatExceptionMapper.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/InvalidFormatExceptionMapper.java new file mode 100644 index 0000000..68ebf64 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/InvalidFormatExceptionMapper.java @@ -0,0 +1,54 @@ +package tech.pegase.z3.application.provider.exception; + +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import java.util.Map; +import org.apache.commons.collections4.map.HashedMap; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestResponse; + +/// Handle Json to Request Mapper Exceptions +@Provider +@SuppressWarnings("unused") +public class InvalidFormatExceptionMapper implements ExceptionMapper { + private static final Logger logger = Logger.getLogger(InvalidFormatExceptionMapper.class); + private static final Integer statusCode = 422; + + @Override + public Response toResponse(InvalidFormatException e) { + var message = e.getOriginalMessage(); + + var targetType = e.getTargetType(); + var fieldName = e.getPath().getLast().getFieldName(); + var providedValue = e.getValue(); + + try (var restResponse = + RestResponse.status(Status.FORBIDDEN, mapExceptionToValidationErrors(e))) { + var customResponse = Response.fromResponse(restResponse.toResponse()); + customResponse.status(statusCode); + return customResponse.build(); + } catch (Error err) { + logger.fatalf(err.getMessage(), err); + return Response.status(Status.INTERNAL_SERVER_ERROR.getStatusCode(), "Unhandled Server error") + .build(); + } + } + + private ValidationErrors mapExceptionToValidationErrors(InvalidFormatException e) { + Map errors = new HashedMap<>(); + var targetType = e.getTargetType().getSimpleName().toLowerCase(); + var fieldName = e.getPath().getLast().getFieldName(); + var providedValue = e.getValue().toString(); + + errors.put( + fieldName, + "The value has to be a %s value given is '%s'".formatted(targetType, providedValue)); + + return new ValidationErrors(errors); + } + + private record ValidationErrors(Map errors) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/JsonProcessingExceptionMapper.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/JsonProcessingExceptionMapper.java new file mode 100644 index 0000000..55515fe --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/JsonProcessingExceptionMapper.java @@ -0,0 +1,29 @@ +package tech.pegase.z3.application.provider.exception; + +import com.fasterxml.jackson.core.JsonProcessingException; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.RestResponse; +import tech.pegase.z3.application.dto.MessageDto; + +/// Handle Invalid Json format on request +@Provider +public class JsonProcessingExceptionMapper implements ExceptionMapper { + private static final Logger logger = Logger.getLogger(JsonProcessingExceptionMapper.class); + + @Override + public Response toResponse(JsonProcessingException e) { + var message = e.getMessage(); + logger.debug(message); + try (var response = RestResponse.status(Status.BAD_REQUEST, MessageDto.of(message))) { + return response.toResponse(); + } catch (Error err) { + logger.fatalf(err.getMessage(), err); + return Response.status(Status.INTERNAL_SERVER_ERROR.getStatusCode(), "Unhandled Server error") + .build(); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ValidationExceptionMapper.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ValidationExceptionMapper.java new file mode 100644 index 0000000..e02d4fe --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/ValidationExceptionMapper.java @@ -0,0 +1,109 @@ +package tech.pegase.z3.application.provider.exception; + +import io.quarkus.hibernate.validator.runtime.jaxrs.ResteasyReactiveViolationException; +import io.quarkus.hibernate.validator.runtime.jaxrs.ResteasyReactiveViolationExceptionMapper; +import io.quarkus.hibernate.validator.runtime.jaxrs.ValidatorMediaTypeUtil; +import io.quarkus.hibernate.validator.runtime.jaxrs.ViolationReport; +import io.quarkus.hibernate.validator.runtime.jaxrs.ViolationReport.Violation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.ValidationException; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; +import jakarta.ws.rs.ext.ExceptionMapper; +import jakarta.ws.rs.ext.Provider; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.apache.commons.collections4.map.HashedMap; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.common.util.ServerMediaType; +import org.jboss.resteasy.reactive.server.core.CurrentRequestManager; +import org.jboss.resteasy.reactive.server.core.ResteasyReactiveRequestContext; + +/// Handle Jakarta Validation errors +@Provider +public class ValidationExceptionMapper extends ResteasyReactiveViolationExceptionMapper + implements ExceptionMapper { + private static final Logger logger = Logger.getLogger(ValidationExceptionMapper.class); + private static final String VALIDATION_HEADER = "validation-exception"; + private static final List SUPPORTED_MEDIA_TYPES = + List.of(MediaType.valueOf(MediaType.APPLICATION_JSON)); + private static final Integer statusCode = 422; + + @Override + public Response toResponse(ValidationException e) { + logger.debug("Could not validate request"); + + // Allow parent to make verifications. + try (var ignored = super.toResponse(e)) { + + // Customize response with validation errors + var resteasyViolationException = (ResteasyReactiveViolationException) e; + return buildViolationReportResponse(resteasyViolationException); + } + } + + private Response buildViolationReportResponse(ConstraintViolationException cve) { + var builder = Response.status(statusCode); + var rrContext = CurrentRequestManager.get(); + + // Check standard media types. + var mediaType = + ValidatorMediaTypeUtil.getAcceptMediaType( + rrContext.getHttpHeaders().getAcceptableMediaTypes(), serverMediaTypes(rrContext)); + + if (mediaType == null) { + mediaType = MediaType.APPLICATION_JSON_TYPE; + } + + builder.header(VALIDATION_HEADER, "true"); + + List violationsInReport = new ArrayList<>(cve.getConstraintViolations().size()); + + for (var cv : cve.getConstraintViolations()) { + violationsInReport.add( + new ViolationReport.Violation(cv.getPropertyPath().toString(), cv.getMessage())); + } + + var violationReport = + new ViolationReport(cve.getMessage(), Status.BAD_REQUEST, violationsInReport); + + violationReport.setStatus(statusCode); + builder.entity(violationReportToValidationErrors(violationReport)); + builder.type(mediaType); + + return builder.build(); + } + + private ValidationErrors violationReportToValidationErrors(ViolationReport violationReport) { + Map violations = new HashedMap<>(); + + for (var violation : violationReport.getViolations()) { + var field = violation.getField().substring(violation.getField().lastIndexOf(".") + 1); + violations.compute( + field, + (k, previousError) -> + previousError != null + ? previousError + ", " + violation.getMessage() + : violation.getMessage()); + } + + return new ValidationErrors(violations); + } + + private List serverMediaTypes(ResteasyReactiveRequestContext context) { + ServerMediaType serverMediaType; + if (context.getTarget() == null) { + return SUPPORTED_MEDIA_TYPES; + } + serverMediaType = context.getTarget().getProduces(); + if (serverMediaType == null) { + return SUPPORTED_MEDIA_TYPES; + } + return Arrays.asList(serverMediaType.getSortedMediaTypes()); + } + + private record ValidationErrors(Map errors) {} +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/package-info.java new file mode 100644 index 0000000..341e6a1 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/exception/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.provider.exception; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/provider/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/provider/package-info.java new file mode 100644 index 0000000..7627f28 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/provider/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.provider; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/Request.java b/z3-application/src/main/java/tech/pegase/z3/application/request/Request.java new file mode 100644 index 0000000..4d03383 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/Request.java @@ -0,0 +1,49 @@ +package tech.pegase.z3.application.request; + +import java.util.function.Function; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.exception.ValidationException.ValidationError; + +@NullMarked +public interface Request { + /** Validate a field or throw ValidationException. */ + static @Nullable ToT validate( + String fieldName, + @Nullable FromT from, + Function<@Nullable FromT, @Nullable ToT> validator) + throws ValidationException { + try { + return validator.apply(from); + } catch (IllegalStateException e) { + throw ValidationException.of(ValidationError.of(fieldName, e.getMessage())); + } + } + + /** Validate a field or throw ValidationException. */ + static ToT validateNonNullable( + String fieldName, + FromT from, + Function validator) + throws ValidationException { + try { + return validator.apply(from); + } catch (IllegalStateException e) { + throw ValidationException.of(ValidationError.of(fieldName, e.getMessage())); + } + } + + /** Validate a field or throw ValidationException. */ + static , ToT extends Enum, FromT> @Nullable ToT validateEnum( + String fieldName, + @Nullable FromT from, + Function<@Nullable FromT, @Nullable ToT> validator) + throws ValidationException { + try { + return validator.apply(from); + } catch (IllegalStateException e) { + throw ValidationException.of(ValidationError.of(fieldName, e.getMessage())); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/auth/AuthRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/auth/AuthRequest.java new file mode 100644 index 0000000..57cf81d --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/auth/AuthRequest.java @@ -0,0 +1,23 @@ +package tech.pegase.z3.application.request.auth; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import tech.pegase.z3.application.request.Request; + +public class AuthRequest implements Request { + @NotNull @NotEmpty private final String login; + @NotNull @NotEmpty private final String password; + + private AuthRequest(String login, String password) { + this.login = login; + this.password = password; + } + + public String getLogin() { + return login.trim(); + } + + public String getPassword() { + return password.trim(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/auth/ChangePasswordRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/auth/ChangePasswordRequest.java new file mode 100644 index 0000000..caef504 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/auth/ChangePasswordRequest.java @@ -0,0 +1,31 @@ +package tech.pegase.z3.application.request.auth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.Objects; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Password; + +public record ChangePasswordRequest( + @NotNull + @NotEmpty + String password, + + @NotNull + @NotEmpty + @JsonProperty("new-password") + String newPassword + +) implements Request { + + public Password getPassword() { + return Objects.requireNonNull(Request.validate("password", password, + password -> password != null ? Password.of(password) : null)); + } + + public Password getNewPassword() { + return Objects.requireNonNull(Request.validate("newPassword", newPassword, + newPassword -> newPassword != null ? Password.of(newPassword) : null)); + } +} \ No newline at end of file diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/brand/CreateBrandRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/brand/CreateBrandRequest.java new file mode 100644 index 0000000..4d84218 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/brand/CreateBrandRequest.java @@ -0,0 +1,37 @@ +package tech.pegase.z3.application.request.brand; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Set; +import java.util.stream.Collectors; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.exception.ValidationException.ValidationError; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Fqdn; + + +public record CreateBrandRequest( + @NotEmpty + @Size(min = 1, max = 255) + String name, + + @NotNull + Set fqdns + +) implements Request { + + public String getName() { + return name; + } + + public Set getFqdns() { + try { + return fqdns.stream() + .map(Fqdn::new) + .collect(Collectors.toSet()); + } catch (IllegalStateException e) { + throw ValidationException.of(ValidationError.of("fqdns", e.getMessage())); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/brand/PaginatedBrandListRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/brand/PaginatedBrandListRequest.java new file mode 100644 index 0000000..051bfdc --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/brand/PaginatedBrandListRequest.java @@ -0,0 +1,33 @@ +package tech.pegase.z3.application.request.brand; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.QueryParam; +import tech.pegase.z3.application.request.Request; + +public class PaginatedBrandListRequest implements Request { + + @QueryParam("page") + @jakarta.annotation.Nullable + @Min(1) + @DefaultValue("1") + Integer page; + + @QueryParam("perPage") + @jakarta.annotation.Nullable + @Min(1) + @Max(50) + @DefaultValue("1") + Integer perPage; + + protected PaginatedBrandListRequest() {} + + public Integer getPage() { + return page == null ? 1 : page; + } + + public Integer getPerPage() { + return perPage == null ? 15 : perPage; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/brand/UpdateBrandRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/brand/UpdateBrandRequest.java new file mode 100644 index 0000000..f96dcce --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/brand/UpdateBrandRequest.java @@ -0,0 +1,38 @@ +package tech.pegase.z3.application.request.brand; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Set; +import java.util.stream.Collectors; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.exception.ValidationException.ValidationError; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Fqdn; + +public record UpdateBrandRequest( + + @NotEmpty + @Size(min = 1, max = 255) + String name, + + @NotNull + Set fqdns + +) implements Request { + + public String getName() { + return name; + } + + public Set getFqdns() { + try { + return fqdns.stream() + .map(Fqdn::new) + .collect(Collectors.toSet()); + } catch (IllegalStateException e) { + throw ValidationException.of(ValidationError.of("fqdns", e.getMessage())); + } + } + +} \ No newline at end of file diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/brand/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/request/brand/package-info.java new file mode 100644 index 0000000..da6ed71 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/brand/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.request.brand; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/CreateCatalogRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/CreateCatalogRequest.java new file mode 100644 index 0000000..dc3bf63 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/CreateCatalogRequest.java @@ -0,0 +1,28 @@ +package tech.pegase.z3.application.request.catalog; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.UUID; +import tech.pegase.z3.application.request.Request; + +public record CreateCatalogRequest( + + @NotEmpty + @Size(min = 1, max = 255) + String name, + + @NotNull + UUID brand + +) implements Request { + + public String getName() { + return name.trim(); + } + + + public UUID getBrand() { + return brand; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/PaginatedCatalogListRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/PaginatedCatalogListRequest.java new file mode 100644 index 0000000..f6db1f5 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/PaginatedCatalogListRequest.java @@ -0,0 +1,44 @@ +package tech.pegase.z3.application.request.catalog; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.QueryParam; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.application.request.Request; + +public class PaginatedCatalogListRequest implements Request { + + @QueryParam("page") + @jakarta.annotation.Nullable + @Min(1) + @DefaultValue("1") + Integer page; + + @QueryParam("perPage") + @jakarta.annotation.Nullable + @Min(1) + @Max(50) + @DefaultValue("1") + Integer perPage; + + @QueryParam("brand") + @jakarta.annotation.Nullable + @Nullable + UUID brand; + + protected PaginatedCatalogListRequest() {} + + public Integer getPage() { + return page == null ? 1 : page; + } + + public Integer getPerPage() { + return perPage == null ? 15 : perPage; + } + + public @Nullable UUID getBrand() { + return brand; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/UpdateCatalogRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/UpdateCatalogRequest.java new file mode 100644 index 0000000..6597dad --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/UpdateCatalogRequest.java @@ -0,0 +1,24 @@ +package tech.pegase.z3.application.request.catalog; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.usecase.catalogs.UpdateCatalog; + +public record UpdateCatalogRequest( + + @NotEmpty + @Size(min = 1, max = 255) + String name + +) implements Request { + + public static UpdateCatalogRequest of(UpdateCatalog.Output.CatalogOutput catalog) { + return new UpdateCatalogRequest( + catalog.name()); + } + + public String getName() { + return name.trim(); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/package-info.java new file mode 100644 index 0000000..3e6a1de --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/catalog/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.request.catalog; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/link/CreateLinkRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/link/CreateLinkRequest.java new file mode 100644 index 0000000..1a591a1 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/link/CreateLinkRequest.java @@ -0,0 +1,65 @@ +package tech.pegase.z3.application.request.link; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Objects; +import java.util.UUID; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public record CreateLinkRequest( + + @jakarta.annotation.Nullable + @Nullable + @Size(min = 1, max = 255) + String source, + + @NotNull + @Size(min = 1, max = 350) + String target, + + @jakarta.annotation.Nullable + @Nullable + UUID tag, + + @jakarta.annotation.Nullable + @Nullable + UUID catalog, + + @jakarta.annotation.Nullable + @Nullable + UUID brand, + + @NotNull + @JsonProperty(value = "is_external") + Boolean isExternal + +) implements Request { + + public @Nullable Source getSource() { + return Request.validate("source", source, value -> + value != null ? Source.of(value.trim()) : null); + } + + public @NonNull Target getTarget() { + return Objects.requireNonNull( + Request.validate("target", target, value -> + value != null ? Target.of(value.trim()) : null)); + } + + public @Nullable UUID getBrand() { + return brand; + } + + public @Nullable UUID getTag() { + return tag; + } + + public @Nullable UUID getCatalog() { + return catalog; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/link/PaginatedLinkListRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/link/PaginatedLinkListRequest.java new file mode 100644 index 0000000..d69a520 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/link/PaginatedLinkListRequest.java @@ -0,0 +1,111 @@ +package tech.pegase.z3.application.request.link; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.QueryParam; +import java.util.HashMap; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.concepts.Ordering; +import tech.pegase.z3.domain.valueobject.concepts.Searching; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; + +public class PaginatedLinkListRequest implements Request { + + @QueryParam("page") + @jakarta.annotation.Nullable + @Min(1) + @DefaultValue("1") + Integer page; + + @QueryParam("perPage") + @jakarta.annotation.Nullable + @Nullable + @Min(1) + @Max(50) + @DefaultValue("1") + Integer perPage; + + @QueryParam("orderBy") + @jakarta.annotation.Nullable + @Nullable + String orderBy; + + @QueryParam("direction") + @jakarta.annotation.Nullable + @Nullable + String direction; + + @QueryParam("searchOn") + @jakarta.annotation.Nullable + @Nullable + String searchOn; + + @QueryParam("keywords") + @jakarta.annotation.Nullable + @Nullable + String keywords; + + @QueryParam("brand") + @jakarta.annotation.Nullable + @Nullable + String brand; + + @QueryParam("tag") + @jakarta.annotation.Nullable + @Nullable + String tag; + + @QueryParam("catalog") + @jakarta.annotation.Nullable + @Nullable + String catalog; + + protected PaginatedLinkListRequest() {} + + public Integer getPage() { + return page == null ? 1 : page; + } + + public Integer getPerPage() { + return perPage == null ? 15 : perPage; + } + + public LinkOrdering.@Nullable Field getOrderBy() { + return Request.validateEnum("orderBy", orderBy, + value -> value != null ? LinkOrdering.Field.of(value.toUpperCase()) : null + ); + } + + public LinkOrdering.@Nullable Direction getDirection() { + return Request.validateEnum("direction", direction, + value -> value != null ? Ordering.Direction.of(value.toUpperCase()) : null); + } + + public LinkSearching.@Nullable Field getSearchOn() { + return Request.validateEnum("searchOn", searchOn, + value -> value != null ? LinkSearching.Field.of(value.toUpperCase()) : null); + } + + public LinkSearching.@Nullable SearchValue getKeywords() { + return Request.validate("keywords", keywords, + value -> value != null ? Searching.SearchValue.of(value) : null); + } + + public LinkFiltering getFilters() { + var filters = new HashMap(); + + filters.put("BRAND", brand); + if (tag != null && !tag.isEmpty()) { + filters.put("TAG", tag); + } + if (catalog != null && !catalog.isEmpty()) { + filters.put("CATALOG", catalog); + } + + return LinkFiltering.of(filters); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/link/RedirectLinkRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/link/RedirectLinkRequest.java new file mode 100644 index 0000000..5e3dd98 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/link/RedirectLinkRequest.java @@ -0,0 +1,168 @@ +package tech.pegase.z3.application.request.link; + +import inet.ipaddr.AddressStringException; +import inet.ipaddr.IPAddressString; +import jakarta.ws.rs.QueryParam; +import java.net.InetAddress; +import java.net.URI; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.application.exception.ValidationException; +import tech.pegase.z3.application.exception.ValidationException.ValidationError; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; + +public record RedirectLinkRequest( + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("ip-address") + String ipAddress, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("dnt") + Boolean doNotTrack, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("fingerprint") + String fingerprint, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("lang") + String lang, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("region") + String region, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("referrer") + String referrer, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("user-agent") + String userAgent, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("screen-width") + Integer screenWidth, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("screen-height") + Integer screenHeight, + + @jakarta.annotation.Nullable + @Nullable + @QueryParam("screen-dpi") + Integer screenDpi + +) implements Request { + + public @Nullable InetAddress getIpAddress() { + return Request.validate("ip-address", ipAddress, this::toInetAddress); + } + + public @Nullable Boolean getDoNotTrack() { + return doNotTrack; + } + + public @Nullable Locale getLocale() { + var langIsNull = lang == null; + var regionIsNull = region == null; + var allAreNull = Stream.of(lang, region).allMatch(Objects::isNull); + + if (!allAreNull & (langIsNull | regionIsNull)) { + throw new ValidationException(Set.of( + ValidationError.of("lang", "is required with region"), + ValidationError.of("region", "is required with lang") + )); + } + + if (allAreNull) { + return null; + } + + return new Locale(lang, region); + } + + public @Nullable URI getReferrerUri() { + try { + if (referrer == null) { + return null; + } + + return URI.create(referrer); + } catch (Exception e) { + throw new ValidationException(Set.of( + ValidationError.of("referrer", "is not a valid referrer") + )); + } + } + + public @Nullable String getUserAgent() { + return userAgent; + } + + public @Nullable ScreenProperties getScreenProperties() { + var screenHeightIsNull = screenHeight == null; + var screenWidthIsNull = screenWidth == null; + var screenDpiIsNull = screenDpi == null; + var allAreNull = Stream.of(screenHeight, screenWidth, screenDpi) + .allMatch(Objects::isNull); + + if (!allAreNull & (screenHeightIsNull | screenWidthIsNull | screenDpiIsNull)) { + throw new ValidationException(Set.of( + ValidationError.of("screen-height", "is required with screen width and screen dpi"), + ValidationError.of("screen-width", "is required with screen height and screen dpi"), + ValidationError.of("screen-dpi", "is required with screen height and screen width") + )); + } + + if (allAreNull) { + return null; + } + + return new ScreenProperties(screenHeight, screenWidth, screenDpi); + } + + public @Nullable Fingerprint getFingerprint() { + try { + if (fingerprint == null) { + return null; + } + + return new Fingerprint(fingerprint); + } catch (IllegalStateException e) { + throw new ValidationException(Set.of( + ValidationError.of("fingerprint", "is not a valid fingerprint") + )); + } + } + + private @Nullable InetAddress toInetAddress(@Nullable String ipLiteral) { + try { + if (ipLiteral == null) { + return null; + } + + var ipAddressString = new IPAddressString(ipLiteral); + var ipAddress = ipAddressString.toAddress(); + + return ipAddress.toInetAddress(); + } catch (AddressStringException e) { + throw new IllegalStateException("Not a valid IP address: " + ipLiteral); + } + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/link/UpdateLinkRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/link/UpdateLinkRequest.java new file mode 100644 index 0000000..f3ba0af --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/link/UpdateLinkRequest.java @@ -0,0 +1,51 @@ +package tech.pegase.z3.application.request.link; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Objects; +import java.util.UUID; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.Target; + +public record UpdateLinkRequest( + @NotNull + @Size(min = 1, max = 350) + String target, + + @NotNull + @JsonProperty(value = "is_external") + Boolean isExternal, + + @jakarta.annotation.Nullable + @Nullable + UUID tag, + + @jakarta.annotation.Nullable + @Nullable + String catalog + +) implements Request { + + public Boolean isExternal() { + return isExternal; + } + + public @NonNull Target getTarget() { + return Objects.requireNonNull( + Request.validate("target", target, value -> + value != null ? Target.of(value.trim()) : null)); + } + + public @Nullable UUID getTag() { + return tag; + } + + public @Nullable Slug getCatalog() { + return Request.validate("catalog", catalog, + value -> value != null ? Slug.of(value.trim()) : null); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/link/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/request/link/package-info.java new file mode 100644 index 0000000..bd7e4f2 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/link/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.request.link; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/reporting/UpdateUserReportingSubscriptionsRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/reporting/UpdateUserReportingSubscriptionsRequest.java new file mode 100644 index 0000000..0e99053 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/reporting/UpdateUserReportingSubscriptionsRequest.java @@ -0,0 +1,19 @@ +package tech.pegase.z3.application.request.reporting; + +import jakarta.validation.constraints.NotNull; +import java.util.Set; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.application.request.Request; + +@NullMarked +public record UpdateUserReportingSubscriptionsRequest( + + @NotNull + Set brands + +) implements Request { + public Set getBrands() { + return brands; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/tag/CreateTagRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/tag/CreateTagRequest.java new file mode 100644 index 0000000..015bf86 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/tag/CreateTagRequest.java @@ -0,0 +1,28 @@ +package tech.pegase.z3.application.request.tag; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.Objects; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Color; + +public record CreateTagRequest( + + @NotEmpty + @Size(min = 1, max = 255) + String name, + + @NotEmpty + String color + +) implements Request { + + public String getName() { + return name.trim(); + } + + public Color getColor() { + return Objects.requireNonNull(Request.validate("color", color, + hexColor -> hexColor != null ? Color.of(hexColor.trim()) : null)); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/tag/PaginatedTagListRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/tag/PaginatedTagListRequest.java new file mode 100644 index 0000000..b0467dc --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/tag/PaginatedTagListRequest.java @@ -0,0 +1,33 @@ +package tech.pegase.z3.application.request.tag; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.QueryParam; +import tech.pegase.z3.application.request.Request; + +public class PaginatedTagListRequest implements Request { + + @QueryParam("page") + @jakarta.annotation.Nullable + @Min(1) + @DefaultValue("1") + Integer page; + + @QueryParam("perPage") + @jakarta.annotation.Nullable + @Min(1) + @Max(50) + @DefaultValue("1") + Integer perPage; + + protected PaginatedTagListRequest() {} + + public Integer getPage() { + return page == null ? 1 : page; + } + + public Integer getPerPage() { + return perPage == null ? 1 : perPage; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/tag/UpdateTagRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/tag/UpdateTagRequest.java new file mode 100644 index 0000000..ce81e25 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/tag/UpdateTagRequest.java @@ -0,0 +1,28 @@ +package tech.pegase.z3.application.request.tag; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; +import java.util.Objects; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Color; + +public record UpdateTagRequest( + + @NotEmpty + @Size(min = 1, max = 255) + String name, + + @NotEmpty + String color + +) implements Request { + + public String getName() { + return name.trim(); + } + + public Color getColor() { + return Objects.requireNonNull(Request.validate("color", color, + hexColor -> hexColor != null ? Color.of(hexColor.trim()) : null)); + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/tag/package-info.java b/z3-application/src/main/java/tech/pegase/z3/application/request/tag/package-info.java new file mode 100644 index 0000000..2ccf959 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/tag/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.application.request.tag; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/user/CreateUserRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/user/CreateUserRequest.java new file mode 100644 index 0000000..562b181 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/user/CreateUserRequest.java @@ -0,0 +1,67 @@ +package tech.pegase.z3.application.request.user; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.Role.AccessLevel; + +public record CreateUserRequest( + + @NotEmpty + @Size(min = 1, max = 255) + String firstName, + + @NotEmpty + @Size(min = 1, max = 255) + String lastName, + + @NotEmpty + @Size(min = 1, max = 255) + String email, + + @NotEmpty + String role, + + @NotEmpty + @Size(min = 1, max = 255) + String login, + + @NotNull + Set brands + +) implements Request { + + public String getFirstName() { + return firstName.trim(); + } + + public String getLastName() { + return lastName.trim(); + } + + public Email getEmail() { + return Objects.requireNonNull(Request.validate("email", email, + email -> email != null ? Email.of(email.trim()) : null)); + } + + public Role getRole() { + return Objects.requireNonNull(Request.validate("role", role, + role -> role != null ? Role.of(AccessLevel.valueOf(role)) : null)); + } + + public Login getLogin() { + return Objects.requireNonNull(Request.validate("login", login, + login -> login != null ? Login.of(login.trim()) : null)); + } + + public Set getBrands() { + return brands; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/user/PaginatedUserListRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/user/PaginatedUserListRequest.java new file mode 100644 index 0000000..f0027da --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/user/PaginatedUserListRequest.java @@ -0,0 +1,33 @@ +package tech.pegase.z3.application.request.user; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.QueryParam; +import tech.pegase.z3.application.request.Request; + +public class PaginatedUserListRequest implements Request { + + @QueryParam("page") + @jakarta.annotation.Nullable + @Min(1) + @DefaultValue("1") + Integer page; + + @QueryParam("perPage") + @jakarta.annotation.Nullable + @Min(1) + @Max(50) + @DefaultValue("1") + Integer perPage; + + protected PaginatedUserListRequest() {} + + public Integer getPage() { + return page == null ? 1 : page; + } + + public Integer getPerPage() { + return perPage == null ? 15 : perPage; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/request/user/UpdateUserRequest.java b/z3-application/src/main/java/tech/pegase/z3/application/request/user/UpdateUserRequest.java new file mode 100644 index 0000000..8003ed6 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/request/user/UpdateUserRequest.java @@ -0,0 +1,47 @@ +package tech.pegase.z3.application.request.user; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import tech.pegase.z3.application.request.Request; +import tech.pegase.z3.domain.valueobject.Email; + +public record UpdateUserRequest( + + @NotEmpty + @Size(min = 1, max = 255) + String firstName, + + @NotEmpty + @Size(min = 1, max = 255) + String lastName, + + @NotEmpty + @Size(min = 1, max = 255) + String email, + + @NotNull + Set brands + +) implements Request { + + public String getFirstName() { + return firstName.trim(); + } + + public String getLastName() { + return lastName.trim(); + } + + public Email getEmail() { + return Objects.requireNonNull(Request.validate("email", email, + email -> email != null ? Email.of(email) : null)); + } + + public Set getBrands() { + return brands; + } +} diff --git a/z3-application/src/main/java/tech/pegase/z3/application/scheduler/ReportingSubscriptionScheduler.java b/z3-application/src/main/java/tech/pegase/z3/application/scheduler/ReportingSubscriptionScheduler.java new file mode 100644 index 0000000..19f8331 --- /dev/null +++ b/z3-application/src/main/java/tech/pegase/z3/application/scheduler/ReportingSubscriptionScheduler.java @@ -0,0 +1,53 @@ +package tech.pegase.z3.application.scheduler; + +import io.quarkus.scheduler.Scheduled; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import tech.pegase.happy.core.Presenter; +import tech.pegase.happy.core.usecase.UseCaseExecutor; +import tech.pegase.z3.domain.usecase.reporting.SendReports; +import tech.pegase.z3.domain.usecase.reporting.SendReports.Input; +import tech.pegase.z3.domain.usecase.reporting.SendReports.Output; +import tech.pegase.z3.domain.usecase.reporting.SendReports.Output.ReportsFailedFor; + +@ApplicationScoped +public class ReportingSubscriptionScheduler { + private static final Logger logger = Logger.getLogger(ReportingSubscriptionScheduler.class); + private final UseCaseExecutor useCaseExecutor; + private final SendReports sendReports; + private final String from; + + @Inject + public ReportingSubscriptionScheduler( + UseCaseExecutor executor, + SendReports useCase, + @ConfigProperty(name = "quarkus.mailer.from") String from) { + this.useCaseExecutor = executor; + this.sendReports = useCase; + this.from = from; + } + + @Scheduled(cron = "{z3.cron.sendInvalidLinksReports}") + void sendInvalidLinksReports() { + logger.info("Sending InvalidLinksReports"); + + var output = + useCaseExecutor + .forUseCase(sendReports) + .withParameter(new Input.SendReportsForInvalidLinks(from)) + .withPresenter((Presenter) output1 -> output1) + .run(); + + if (output instanceof Output.ReportsFailedFor) { + logger.info("Some reports failed :"); + ((ReportsFailedFor) output) + .reasonsPerUser() + .forEach((user, reason) -> logger.info("User '%s' -> %s".formatted(user, reason))); + return; + } + + logger.info("Ended sending reports with success"); + } +} diff --git a/z3-application/src/main/resources/application.properties b/z3-application/src/main/resources/application.properties new file mode 100644 index 0000000..91425b0 --- /dev/null +++ b/z3-application/src/main/resources/application.properties @@ -0,0 +1,132 @@ +# -- APPLICATION CONFIG +## - LOGGING +z3.logging.level=${APP_LOG_LEVEL:INFO} +# EXPORT +z3.export.resolver-fqdn=${APP_RESOLVER_FQDN} +## - DB +z3.db.kind=${APP_DB_KIND} +z3.db.username=${APP_DB_USERNAME} +z3.db.password=${APP_DB_PASSWORD} +z3.db.jdbc.url=${APP_DB_JDBC_URL} +## - JWT +z3.jwt.issuer=${APP_JWT_ISSUER} +z3.jwt.public-key=${APP_JWT_PUBLIC_KEY} +z3.jwt.private-key=${APP_JWT_PRIVATE_KEY} +## - UPLOAD +z3.upload.max-size=${APP_MAX_UPLOAD_SIZE:100M} +## - MAIL +z3.mail.auth-methods=${APP_MAIL_AUTH_METHOD:DIGEST-MD5 CRAM-SHA256 CRAM-SHA1 CRAM-MD5 PLAIN LOGIN} +z3.mail.from=${APP_MAIL_FROM} +z3.mail.host=${APP_MAIL_HOST} +z3.mail.port=${APP_MAIL_PORT} +z3.mail.start-tls=${APP_MAIL_START_TLS} +z3.mail.tls=${APP_MAIL_TLS} +z3.mail.username=${APP_MAIL_USERNAME} +z3.mail.password=${APP_MAIL_PASSWORD} +z3.mail.mock=${APP_MAIL_DEV_SIMULATE:true} +# - ENCRYPTION - RUNTIME +z3.infra.aes.key=${AES_KEY} +z3.infra.aes.salt=${AES_SALT} +z3.infra.aes.key-length=${AES_KEY_LENGTH} +# - ENCRYPTION - TESTS +%test.z3.infra.aes.key="password" +%test.z3.infra.aes.salt="salt" +%test.z3.infra.aes.key-length=256 + +# - ACCOUNT CREATION +z3.app.create-user-on-start=${APP_CREATE_USER_ON_START:} + +# - SCHEDULING +# By default, the syntax used for cron expressions is based on Quartz +# - https://www.quartz-scheduler.org/documentation/quartz-2.3.0/tutorials/crontrigger.html +# You can change the syntax using the following property: +# quarkus.scheduler.cron-type=unix +z3.cron.sendInvalidLinksReports=${APP_REPORT_INVALID_LINKS:0 0 0 * * ? *} + +# -- PRIVATE VARS + +# LOGGING - RUNTIME +quarkus.log.min-level=${z3.logging.level} + +# LOGGING - TEST +%test.quarkus.log.min-level=DEBUG + + +# DATABASE - RUNTIME +quarkus.datasource.db-kind=postgresql +quarkus.datasource.username=${z3.db.username} +quarkus.datasource.password=${z3.db.password} +quarkus.datasource.jdbc.url=${z3.db.jdbc.url} +quarkus.hibernate-orm.database.generation=validate +quarkus.hibernate-orm.jdbc.statement-batch-size=50 +quarkus.hibernate-orm.packages=tech.pegase.z3.domain,tech.pegase.z3.shared.valueobject,tech.pegase.z3.statistics.domain,tech.pegase.happy.core,com.blazebit.persistence.impl.function.entity +quarkus.flyway.active=true +quarkus.flyway.migrate-at-start=true +quarkus.flyway.default-schema=z3-backend +quarkus.hibernate-orm.database.default-schema=z3-backend +# To force quoting the any reserved key word on sql queries; +# https://www.baeldung.com/spring-boot-h2-jdbcsqlsyntaxerrorexception-expected-identifier +spring.jpa.properties.hibernate.globally_quoted_identifiers=true +%dev.quarkus.hibernate-orm.log.sql=true + +# DATABASE - TESTS +%test.quarkus.datasource.db-kind=h2 +%test.quarkus.datasource.username=hibernate +%test.quarkus.datasource.password=hibernate +%test.quarkus.datasource.jdbc.url=jdbc:h2:mem:default;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false;INIT=CREATE SCHEMA IF NOT EXISTS "z3-backend"\\;SET SCHEMA "z3-backend"; +%test.quarkus.hibernate-orm.database.generation=none +%test.quarkus.hibernate-orm.jdbc.statement-batch-size=50 +%test.quarkus.hibernate-orm.packages=tech.pegase.z3.domain,tech.pegase.happy.core,com.blazebit.persistence.impl.function.entity +%test.quarkus.flyway.active=true +%test.quarkus.flyway.migrate-at-start=true +%test.quarkus.flyway.default-schema=z3-backend +%test.quarkus.hibernate-orm.database.default-schema=z3-backend +%test.quarkus.hibernate-orm.database.generation.create-schemas=false +# To force quoting the any reserved key word on sql queries; +# https://www.baeldung.com/spring-boot-h2-jdbcsqlsyntaxerrorexception-expected-identifier +%test.spring.jpa.properties.hibernate.globally_quoted_identifiers=true + +## MAIL - RUNTIME +quarkus.mailer.auth-methods=${z3.mail.auth-methods} +quarkus.mailer.from=${z3.mail.from} +quarkus.mailer.host=${z3.mail.host} +quarkus.mailer.port=${z3.mail.port} +quarkus.mailer.start-tls=${z3.mail.start-tls} +quarkus.mailer.tls=${z3.mail.tls} +quarkus.mailer.username=${z3.mail.username} +quarkus.mailer.password=${z3.mail.password} +quarkus.mailer.mock=${z3.mail.mock} +## MAIL - TESTS +%test.quarkus.mailer.auth-methods=DIGEST-MD5 CRAM-SHA256 CRAM-SHA1 CRAM-MD5 PLAIN LOGIN +%test.quarkus.mailer.from=z3@test.net +%test.quarkus.mailer.host=localhost +%test.quarkus.mailer.port=485 +%test.quarkus.mailer.start-tls=DISABLED +%test.quarkus.mailer.tls=false +%test.quarkus.mailer.username= +%test.quarkus.mailer.password= +%test.quarkus.mailer.mock=true + +# SECURITY - JWT - RUNTIME +quarkus.smallrye-jwt.enabled=true +mp.jwt.verify.issuer=${z3.jwt.issuer} +mp.jwt.verify.publickey.location=${z3.jwt.public-key} +smallrye.jwt.sign.key.location=${z3.jwt.private-key} +quarkus.native.resources.includes=dev_private_key.pem,dev_public_key.pem,GeoLite2-Country.mmdb +# SECURITY - JWT - DEV +%dev.mp.jwt.verify.issuer=http://localhost:8080/issuer +%dev.mp.jwt.verify.publickey.location=dev_public_key.pem +%dev.smallrye.jwt.sign.key.location=dev_private_key.pem +# SECURITY - JWT - TESTS +%test.mp.jwt.verify.issuer=http://localhost:8080/issuer +%test.mp.jwt.verify.publickey.location=dev_public_key.pem +%test.smallrye.jwt.sign.key.location=dev_private_key.pem + +# SECURITY - UPLOAD +quarkus.http.limits.max-body-size=${z3.upload.max-size} + +# protocol negotiation to enable HTTP2 +quarkus.tls.alpn=true + +# i18n +quarkus.default-locale="fr-FR" diff --git a/z3-application/src/main/resources/db/migration/R__data_for_test.sql b/z3-application/src/main/resources/db/migration/R__data_for_test.sql new file mode 100644 index 0000000..f0498b7 --- /dev/null +++ b/z3-application/src/main/resources/db/migration/R__data_for_test.sql @@ -0,0 +1,46 @@ +-- ResolveLinkTest +DELETE +FROM "z3-backend"."links" +WHERE "source" = '123456789'; + +DELETE +FROM "z3-backend"."domain_names" +WHERE "fqdn" = 'resolve-example.net'; + +DELETE +FROM "z3-backend"."domain_names" +WHERE "fqdn" = 'another-example.net'; + +DELETE +FROM "z3-backend"."brands" +WHERE "uuid" = '0ff77213-812c-40f5-9b08-b818cc9a9727'; +DELETE +FROM "z3-backend"."brands" +WHERE "uuid" = '0ff77213-812c-40f5-9b08-b818cc9a9729'; + +INSERT INTO "z3-backend"."brands" ("uuid", "slug", "name") +VALUES ('0ff77213-812c-40f5-9b08-b818cc9a9727', 'resolve-link-test', 'Resolve Link Test'); +INSERT INTO "z3-backend"."brands" ("uuid", "slug", "name") +VALUES ('0ff77213-812c-40f5-9b08-b818cc9a9729', 'another-brand-test', 'Another Brand Test'); + +INSERT INTO "z3-backend"."domain_names" ("fqdn", "brand_uuid") +VALUES ('resolve-example.net', '0ff77213-812c-40f5-9b08-b818cc9a9727'); +INSERT INTO "z3-backend"."domain_names" ("fqdn", "brand_uuid") +VALUES ('another-example.net', '0ff77213-812c-40f5-9b08-b818cc9a9729'); + +INSERT INTO "z3-backend"."links" ("created_at", "brand_uuid", "tag_uuid", "catalog_uuid", + "is_external", "is_valid", "visits", "source", "target") +VALUES ('2020-08-02 10:00:00', '0ff77213-812c-40f5-9b08-b818cc9a9727', NULL, NULL, FALSE, 0, 0, + '123456789', + 'https://example.com'); + +-- CreateLinkTest + +INSERT INTO "z3-backend"."brands" + ("uuid", "slug", "name") +SELECT '0ff77213-812c-40f5-9b08-b818cc9a9720', 'new-brand', 'New Brand' +WHERE NOT EXISTS (SELECT "uuid" + FROM "z3-backend"."brands" + WHERE "uuid" = '0ff77213-812c-40f5-9b08-b818cc9a9720'); + + diff --git a/z3-application/src/main/resources/db/migration/R_data_for_test.sql.conf b/z3-application/src/main/resources/db/migration/R_data_for_test.sql.conf new file mode 100644 index 0000000..dfe5de2 --- /dev/null +++ b/z3-application/src/main/resources/db/migration/R_data_for_test.sql.conf @@ -0,0 +1 @@ +shouldExecute=${environment}==test \ No newline at end of file diff --git a/z3-application/src/main/resources/db/migration/V1.0.0__init.sql b/z3-application/src/main/resources/db/migration/V1.0.0__init.sql new file mode 100644 index 0000000..0d1d77f --- /dev/null +++ b/z3-application/src/main/resources/db/migration/V1.0.0__init.sql @@ -0,0 +1,141 @@ +CREATE SCHEMA IF NOT EXISTS "z3-backend"; + +-- CREATE TABLES + +-- TABLE - BRANDS +CREATE TABLE "z3-backend"."brands" +( + "uuid" UUID NOT NULL, + "slug" VARCHAR(255) NOT NULL, + "name" VARCHAR(255) NOT NULL, + CONSTRAINT "brands_slug_unique" UNIQUE ("slug"), + CONSTRAINT "brands_pk" PRIMARY KEY ("uuid") +); + +COMMENT ON TABLE "z3-backend"."brands" IS 'Les enseignes du groupe'; +COMMENT ON COLUMN "z3-backend"."brands"."slug" IS 'Le slug de l´enseigne pour la navigation'; +COMMENT ON COLUMN "z3-backend"."brands"."name" IS 'Le nom de l´enseigne'; + + +-- TABLE - CATALOGS +CREATE TABLE "z3-backend"."catalogs" +( + "uuid" UUID NOT NULL, + "slug" VARCHAR(255) NOT NULL, + "name" VARCHAR(255) NOT NULL, + "brand_uuid" UUID NOT NULL, + + CONSTRAINT "catalogs_pk" PRIMARY KEY ("uuid"), + CONSTRAINT "catalogs_slug_unique" UNIQUE ("slug"), + CONSTRAINT "catalogs_brands_uuid_fk" FOREIGN KEY ("brand_uuid") + REFERENCES "z3-backend"."brands" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT +); + +CREATE INDEX "catalogs_brands_id_fk" ON "z3-backend"."catalogs" ("brand_uuid"); + +COMMENT ON TABLE "z3-backend"."catalogs" IS 'Les catalogues qui peuvent référencer des liens'; +COMMENT ON COLUMN "z3-backend"."catalogs"."slug" IS 'Le slug du catalogue pour la navigation'; +COMMENT ON COLUMN "z3-backend"."catalogs"."name" IS 'Le nom du catalogue'; +COMMENT ON COLUMN "z3-backend"."catalogs"."brand_uuid" IS 'Brand uuid foreign key'; + + +-- TABLE - TAGS +CREATE TABLE "z3-backend"."tags" +( + "uuid" UUID NOT NULL, + "name" VARCHAR(255) NOT NULL, + "color" INTEGER NOT NULL, + CONSTRAINT "tags_name_unique" UNIQUE ("name"), + CONSTRAINT "tags_pk" PRIMARY KEY ("uuid") +); + +COMMENT ON TABLE "z3-backend"."tags" IS 'Les étiquettes pour caractériser des liens'; +COMMENT ON COLUMN "z3-backend"."tags"."name" IS 'The tag name'; +COMMENT ON COLUMN "z3-backend"."tags"."color" IS 'The tag color that represents an hex html color'; + + +-- TABLE - LINKS +CREATE TABLE "z3-backend"."links" +( + "source" VARCHAR(255) NOT NULL, + "target" VARCHAR(350) NOT NULL, + "visits" INTEGER NOT NULL, + "is_external" BOOLEAN NOT NULL, + "is_valid" BOOLEAN NOT NULL, + "brand_uuid" UUID DEFAULT NULL, + "tag_uuid" UUID DEFAULT NULL, + "catalog_uuid" UUID DEFAULT NULL, + "created_at" TIMESTAMP WITHOUT TIME ZONE, + "updated_at" TIMESTAMP WITHOUT TIME ZONE, + CONSTRAINT "links_pk" PRIMARY KEY ("source"), + CONSTRAINT "links_brands_uuid_fk" FOREIGN KEY ("brand_uuid") + REFERENCES "z3-backend"."brands" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT "links_tags_uuid_fk" FOREIGN KEY ("tag_uuid") + REFERENCES "z3-backend"."tags" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT "links_catalogs_uuid_fk" FOREIGN KEY ("catalog_uuid") + REFERENCES "z3-backend"."catalogs" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT +); + +CREATE INDEX "links_brands_id_fk" ON "z3-backend"."links" ("brand_uuid"); +CREATE INDEX "links_tags_id_fk" ON "z3-backend"."links" ("tag_uuid"); +CREATE INDEX "links_catalogs_id_fk" ON "z3-backend"."links" ("catalog_uuid"); + +COMMENT ON TABLE "z3-backend"."links" IS 'Les liens de redirections'; +COMMENT ON COLUMN "z3-backend"."links"."source" IS 'Le lien source'; +COMMENT ON COLUMN "z3-backend"."links"."target" IS 'Le lien cible'; +COMMENT ON COLUMN "z3-backend"."links"."visits" IS 'The times a web user has been redirected using this link'; +COMMENT ON COLUMN "z3-backend"."links"."is_valid" IS 'Le lien cible est valide'; +COMMENT ON COLUMN "z3-backend"."links"."is_external" IS 'Le lien est externe au groupe Mirwault et ne redirige pas sur un de ses services'; +COMMENT ON COLUMN "z3-backend"."links"."brand_uuid" IS 'La marque à laquelle ce lien est associé'; +COMMENT ON COLUMN "z3-backend"."links"."tag_uuid" IS 'Le tag associé au lien'; +COMMENT ON COLUMN "z3-backend"."links"."catalog_uuid" IS 'Le catalogue associé au lien'; +COMMENT ON COLUMN "z3-backend"."links"."created_at" IS 'La date et heure de création du lien'; +COMMENT ON COLUMN "z3-backend"."links"."updated_at" IS 'La date et heure de mise à jour du lien'; + + +-- TABLE - CREDENTIALS +CREATE TABLE "z3-backend"."credentials" +( + "uuid" UUID NOT NULL, + "login" VARCHAR(255) NOT NULL, + "password" VARCHAR(255) NOT NULL, + + CONSTRAINT "credentials_pk" PRIMARY KEY ("uuid"), + CONSTRAINT "credentials_login_unique" UNIQUE ("login") +); + +COMMENT ON TABLE "z3-backend"."credentials" IS 'Identifiants, le champ password doit être hashé'; +COMMENT ON COLUMN "z3-backend"."credentials"."uuid" IS 'Unique identifier'; +COMMENT ON COLUMN "z3-backend"."credentials"."login" IS 'Pour CreateCatalogPresenter´authentifier'; +COMMENT ON COLUMN "z3-backend"."credentials"."password" IS 'Pour CreateCatalogPresenter´authentifier'; + +-- TABLE - USERS +CREATE TABLE "z3-backend"."users" +( + "uuid" UUID NOT NULL, + "first_name" VARCHAR(255) NOT NULL, + "last_name" VARCHAR(255) NOT NULL, + "email" VARCHAR(255) NOT NULL, + "role" SMALLINT NOT NULL, + "credential_uuid" UUID NOT NULL, + + CONSTRAINT "users_pk" PRIMARY KEY ("uuid"), + CONSTRAINT "users_email_unique" UNIQUE ("email"), + CONSTRAINT "users_credentials_uuid_fk" FOREIGN KEY ("credential_uuid") + REFERENCES "z3-backend"."credentials" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT +); + +CREATE INDEX "users_credentials_id_fk" ON "z3-backend"."users" ("credential_uuid"); + +COMMENT ON TABLE "z3-backend"."users" IS 'Utilisateurs pouvant manipuler les entités, tous les champs doivent être cryptés'; +COMMENT ON COLUMN "z3-backend"."users"."uuid" IS 'Unique identifier pour réferencer les utilisateurs'; +COMMENT ON COLUMN "z3-backend"."users"."first_name" IS 'Prénom de l''utilisateur'; +COMMENT ON COLUMN "z3-backend"."users"."last_name" IS 'Nom de l''utilisateur'; +COMMENT ON COLUMN "z3-backend"."users"."email" IS 'Email de l''utilisateur'; +COMMENT ON COLUMN "z3-backend"."users"."role" IS 'Le role de l''utilisateur'; +COMMENT ON COLUMN "z3-backend"."users"."credential_uuid" IS 'Credential uuid foreign key'; \ No newline at end of file diff --git a/z3-application/src/main/resources/db/migration/V1.0.1__user_brand.sql b/z3-application/src/main/resources/db/migration/V1.0.1__user_brand.sql new file mode 100644 index 0000000..51dd625 --- /dev/null +++ b/z3-application/src/main/resources/db/migration/V1.0.1__user_brand.sql @@ -0,0 +1,25 @@ +CREATE SCHEMA IF NOT EXISTS "z3-backend"; + +-- CREATE TABLES + +-- TABLE - CATALOGS +CREATE TABLE "z3-backend"."user_brand" +( + "user_uuid" UUID NOT NULL, + "brand_uuid" UUID NOT NULL, + + CONSTRAINT "user_brand_unique" UNIQUE ("user_uuid", "brand_uuid"), + CONSTRAINT "user_brand_user_uuid_fk" FOREIGN KEY ("user_uuid") + REFERENCES "z3-backend"."users" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT "user_brand_brand_uuid_fk" FOREIGN KEY ("brand_uuid") + REFERENCES "z3-backend"."brands" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT +); + +CREATE INDEX "user_brand_user_uuid_fk" ON "z3-backend"."user_brand" ("user_uuid"); +CREATE INDEX "user_brand_brand_uuid_fk" ON "z3-backend"."user_brand" ("brand_uuid"); + +COMMENT ON TABLE "z3-backend"."user_brand" IS 'Les enseignes accessible au utilisateurs non admin'; +COMMENT ON COLUMN "z3-backend"."user_brand"."user_uuid" IS 'L´utilisateur concerné'; +COMMENT ON COLUMN "z3-backend"."user_brand"."brand_uuid" IS 'L´enseigne concernée'; \ No newline at end of file diff --git a/z3-application/src/main/resources/db/migration/V1.0.3__user_reporting_subscription.sql b/z3-application/src/main/resources/db/migration/V1.0.3__user_reporting_subscription.sql new file mode 100644 index 0000000..5f63425 --- /dev/null +++ b/z3-application/src/main/resources/db/migration/V1.0.3__user_reporting_subscription.sql @@ -0,0 +1,23 @@ +-- TABLE - CATALOGS +CREATE TABLE "z3-backend"."user_reporting_subscriptions" +( + "uuid" UUID NOT NULL, + "user_uuid" UUID NOT NULL, + "brand_uuid" UUID NOT NULL, + + CONSTRAINT "user_reporting_subscriptions_pk" PRIMARY KEY ("uuid"), + CONSTRAINT "user_reporting_subscriptions_unique" UNIQUE ("user_uuid", "brand_uuid"), + CONSTRAINT "user_reporting_subscriptions_user_uuid_fk" FOREIGN KEY ("user_uuid") + REFERENCES "z3-backend"."users" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT "user_reporting_subscriptions_brand_uuid_fk" FOREIGN KEY ("brand_uuid") + REFERENCES "z3-backend"."brands" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT +); + +CREATE INDEX "user_reporting_subscriptions_user_uuid_fk" ON "z3-backend"."user_brand" ("user_uuid"); +CREATE INDEX "user_reporting_subscriptions_brand_uuid_fk" ON "z3-backend"."user_brand" ("brand_uuid"); + +COMMENT ON TABLE "z3-backend"."user_reporting_subscriptions" IS 'Les souscriptions utilisateurs au rapport de liens pour une enseigne'; +COMMENT ON COLUMN "z3-backend"."user_reporting_subscriptions"."user_uuid" IS 'L´utilisateur concerné'; +COMMENT ON COLUMN "z3-backend"."user_reporting_subscriptions"."brand_uuid" IS 'L´enseigne concernée'; \ No newline at end of file diff --git a/z3-application/src/main/resources/db/migration/V1.0.4__change_Is_valid_on_links.sql b/z3-application/src/main/resources/db/migration/V1.0.4__change_Is_valid_on_links.sql new file mode 100644 index 0000000..9f6028f --- /dev/null +++ b/z3-application/src/main/resources/db/migration/V1.0.4__change_Is_valid_on_links.sql @@ -0,0 +1,4 @@ +ALTER TABLE "z3-backend"."links" + ALTER COLUMN "is_valid" TYPE smallint USING "is_valid"::bool::int::smallint; + +COMMENT ON COLUMN "z3-backend"."links"."is_valid" IS 'Enum LinkValidity, validité du lien'; diff --git a/z3-application/src/main/resources/db/migration/V1.0.5__domain_names.sql b/z3-application/src/main/resources/db/migration/V1.0.5__domain_names.sql new file mode 100644 index 0000000..c59989c --- /dev/null +++ b/z3-application/src/main/resources/db/migration/V1.0.5__domain_names.sql @@ -0,0 +1,15 @@ +-- TABLE - DOMAIN-NAMES +CREATE TABLE "z3-backend"."domain_names" +( + "fqdn" VARCHAR(255) NOT NULL, + "brand_uuid" UUID NOT NULL, + + CONSTRAINT "domain_names_fqdn_unique" UNIQUE ("fqdn"), + CONSTRAINT "domain_names_brand_uuid_fk" FOREIGN KEY ("brand_uuid") + REFERENCES "z3-backend"."brands" ("uuid") + ON UPDATE RESTRICT ON DELETE RESTRICT +); + +COMMENT ON TABLE "z3-backend"."domain_names" IS 'Les noms de domaines complets pouvant être associés à des enseignes'; +COMMENT ON COLUMN "z3-backend"."domain_names"."fqdn" IS 'Un nom de domain complet devant être unique'; +COMMENT ON COLUMN "z3-backend"."domain_names"."brand_uuid" IS 'L´enseigne concernée'; diff --git a/z3-application/src/main/resources/db/migration/V1.1.0__create_link_statistics.sql b/z3-application/src/main/resources/db/migration/V1.1.0__create_link_statistics.sql new file mode 100644 index 0000000..4ffb737 --- /dev/null +++ b/z3-application/src/main/resources/db/migration/V1.1.0__create_link_statistics.sql @@ -0,0 +1,52 @@ +CREATE SCHEMA IF NOT EXISTS "z3-statistics"; + +--- TABLE LINKS STATISTICS --- + +CREATE TABLE "z3-statistics"."link_statistics" +( + "uuid" UUID NOT NULL, + "source" VARCHAR(255) NOT NULL, + "brand" VARCHAR(255), + "catalog" VARCHAR(255), + "was_valid" BOOLEAN NOT NULL, + "user_type" SMALLINT NOT NULL, + "do_not_track" BOOLEAN NOT NULL, + "fingerprint" VARCHAR(255), + "country" SMALLINT, + "lang" VARCHAR(4), + "region" VARCHAR(4), + "referrer_host" VARCHAR(255), + "referrer_path" VARCHAR(255), + "browser_class" VARCHAR(255), + "browser_name" VARCHAR(255), + "browser_version" VARCHAR(255), + "screen_height" INTEGER, + "screen_width" INTEGER, + "screen_dpi" INTEGER, + "os_class" VARCHAR(255), + "os_name" VARCHAR(255), + "os_version" VARCHAR(255), + "visited_at" TIMESTAMP WITHOUT TIME ZONE NOT NULL, + + CONSTRAINT "link_statistics_pk" PRIMARY KEY ("uuid") +); + +CREATE INDEX "link_statistics_fingerprint" ON "z3-statistics"."link_statistics" ("fingerprint"); + +CREATE INDEX "link_statistics_source" ON "z3-statistics"."link_statistics" ("source"); +CREATE INDEX "link_statistics_brand" ON "z3-statistics"."link_statistics" ("brand"); +CREATE INDEX "link_statistics_catalog" ON "z3-statistics"."link_statistics" ("catalog"); + +CREATE INDEX "link_statistics_lang" ON "z3-statistics"."link_statistics" ("lang"); +CREATE INDEX "link_statistics_region" ON "z3-statistics"."link_statistics" ("region"); + +CREATE INDEX "link_statistics_referrer_host" ON "z3-statistics"."link_statistics" ("referrer_host"); + +CREATE INDEX "link_statistics_browser_class" ON "z3-statistics"."link_statistics" ("browser_class"); +CREATE INDEX "link_statistics_browser_name" ON "z3-statistics"."link_statistics" ("browser_name"); +CREATE INDEX "link_statistics_browser_version" ON "z3-statistics"."link_statistics" ("browser_version"); + +CREATE INDEX "link_statistics_os_class" ON "z3-statistics"."link_statistics" ("os_class"); +CREATE INDEX "link_statistics_os_name" ON "z3-statistics"."link_statistics" ("os_name"); +CREATE INDEX "link_statistics_os_version" ON "z3-statistics"."link_statistics" ("os_version"); + diff --git a/z3-application/src/main/resources/templates/links-import-template.csv b/z3-application/src/main/resources/templates/links-import-template.csv new file mode 100644 index 0000000..0ec68fe --- /dev/null +++ b/z3-application/src/main/resources/templates/links-import-template.csv @@ -0,0 +1,2 @@ +"source","url","isExternal" +"eb55c6a897691d16b3ac41ec1c313b3b95d65444","https://distorted-vibrissae.com","false" \ No newline at end of file diff --git a/z3-application/src/test/java/tech/pegase/z3/application/api/v1/RootResourceTest.java b/z3-application/src/test/java/tech/pegase/z3/application/api/v1/RootResourceTest.java new file mode 100644 index 0000000..3bdc403 --- /dev/null +++ b/z3-application/src/test/java/tech/pegase/z3/application/api/v1/RootResourceTest.java @@ -0,0 +1,18 @@ +package tech.pegase.z3.application.api.v1; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.is; + +import io.quarkus.test.junit.QuarkusTest; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.Test; + +@NullMarked +@QuarkusTest +class RootResourceTest { + + @Test + void testRoot() { + given().when().get("/").then().statusCode(200).body(is("Z3 App running")); + } +} diff --git a/z3-application/src/test/java/tech/pegase/z3/application/api/v1/link/RedirectLinkOutputTest.java b/z3-application/src/test/java/tech/pegase/z3/application/api/v1/link/RedirectLinkOutputTest.java new file mode 100644 index 0000000..21a2c99 --- /dev/null +++ b/z3-application/src/test/java/tech/pegase/z3/application/api/v1/link/RedirectLinkOutputTest.java @@ -0,0 +1,209 @@ +package tech.pegase.z3.application.api.v1.link; + +import static org.hamcrest.CoreMatchers.equalTo; + +import io.quarkus.test.TestTransaction; +import io.quarkus.test.junit.QuarkusMock; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import jakarta.ws.rs.core.Response.Status; +import java.time.ZonedDateTime; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.application.events.LinkRedirectionEventListener; +import tech.pegase.z3.application.presenter.link.GetLinkPresenter.LinkDto; +import tech.pegase.z3.domain.valueobject.LinkValidity; + +@NullMarked +@QuarkusTest +public class RedirectLinkOutputTest { + @BeforeAll + public static void setup() { + var redirectionEventListener = Mockito.mock(LinkRedirectionEventListener.class); + QuarkusMock.installMockForType(redirectionEventListener, LinkRedirectionEventListener.class); + Mockito.doNothing().when(redirectionEventListener).consumeRedirect(Mockito.any()); + } + + @Test + @TestTransaction + public void canResolveAnExistingLinkAndRedirectToUrlWithDefaultFqdn() { + // Arrange + var linkDto = + new LinkDto( + "https://resolver/123456789", + "123456789", + "https://example.com", + true, + LinkValidity.unknown().ordinal(), + 0, + null, + null, + null, + ZonedDateTime.now(), + ZonedDateTime.now()); + + // Act and Assert + RestAssured.given() + .redirects() + .follow(false) + .pathParam("source", linkDto.source()) + .queryParam("ip-address", "92.183.117.37") + .queryParam("dnt", true) + .when() + .get("/{source}") + .then() + .statusCode(Status.MOVED_PERMANENTLY.getStatusCode()) + .assertThat() + .header("Location", equalTo(linkDto.target())) + .header("Cache-Control", equalTo("no-store, no-cache, must-revalidate")) + .onFailMessage("Resolving an existing link with the resolver Fqdn should succeed any way."); + } + + @Test + @TestTransaction + public void canResolveAnExistingLinkAndRedirectToUrlWithGoodFqdn() { + // Arrange + var linkBrandFqdn = "resolve-example.net"; + var source = "123456789"; + var linkDto = + new LinkDto( + "https://" + linkBrandFqdn + "/" + source, + source, + "https://example.com", + true, + LinkValidity.unknown().ordinal(), + 0, + null, + null, + null, + ZonedDateTime.now(), + ZonedDateTime.now()); + + // Act and Assert + RestAssured.given() + .redirects() + .follow(false) + .pathParam("source", linkDto.source()) + .queryParam("ip-address", "92.183.117.37") + .queryParam("dnt", true) + .param("host", linkBrandFqdn) + .when() + .get("/{source}") + .then() + .statusCode(Status.MOVED_PERMANENTLY.getStatusCode()) + .assertThat() + .header("Location", equalTo(linkDto.target())) + .header("Cache-Control", equalTo("no-store, no-cache, must-revalidate")) + .onFailMessage("Resolving an exisiting link with its brand Fqdn should succeed."); + } + + @Test + @TestTransaction + public void cannotResolveNonExistingLink() { + // Arrange + var linkResource = + new LinkDto( + "https://resolver/123456789", + "does_not_exists_for_sure", + "https://example.com", + true, + LinkValidity.unknown().ordinal(), + 0, + null, + null, + null, + ZonedDateTime.now(), + ZonedDateTime.now()); + + // Act and Assert + RestAssured.given() + .contentType(ContentType.JSON) + .body(linkResource) + .pathParam("source", linkResource.source()) + .queryParam("ip-address", "92.183.117.37") + .queryParam("dnt", true) + .when() + .get("/{source}") + .then() + .statusCode(Status.NOT_FOUND.getStatusCode()) + .assertThat() + .header("Cache-Control", equalTo("no-store, no-cache, must-revalidate")) + .onFailMessage("Resolving to a non existing link should respond not found"); + } + + @Test + public void cannotResolveLinkWithBrandUsingNonExistingFqdn() { + // Arrange + + var linkBrandFqdn = "do-not-exists.net"; + var source = "123456789"; + var linkDto = + new LinkDto( + "https://" + linkBrandFqdn + "/" + source, + source, + "https://example.com", + true, + LinkValidity.unknown().ordinal(), + 0, + null, + null, + null, + ZonedDateTime.now(), + ZonedDateTime.now()); + + // Act and Assert + RestAssured.given() + .redirects() + .follow(false) + .pathParam("source", linkDto.source()) + .queryParam("ip-address", "92.183.117.37") + .queryParam("dnt", true) + .param("host", linkBrandFqdn) + .when() + .get("/{source}") + .then() + .statusCode(Status.BAD_REQUEST.getStatusCode()) + .assertThat() + .header("Cache-Control", equalTo("no-store, no-cache, must-revalidate")) + .onFailMessage("Resolving an existing link with a non existing Fqdn should fail."); + } + + @Test + public void cannotResolveLinkWithBrandUsingAnotherFqdn() { + // Arrange + var linkBrandFqdn = "another-example.net"; + var source = "123456789"; + var linkDto = + new LinkDto( + "https://" + linkBrandFqdn + "/" + source, + "123456789", + "https://example.com", + true, + LinkValidity.unknown().ordinal(), + 0, + null, + null, + null, + ZonedDateTime.now(), + ZonedDateTime.now()); + + // Act and Assert + RestAssured.given() + .redirects() + .follow(false) + .pathParam("source", linkDto.source()) + .queryParam("ip-address", "92.183.117.37") + .queryParam("dnt", true) + .param("host", linkBrandFqdn) + .when() + .get("/{source}") + .then() + .statusCode(Status.BAD_REQUEST.getStatusCode()) + .assertThat() + .header("Cache-Control", equalTo("no-store, no-cache, must-revalidate")) + .onFailMessage("Resolving an existing link with any other existing Fqdn should fail."); + } +} diff --git a/z3-cli/.env.example b/z3-cli/.env.example new file mode 100644 index 0000000..526a597 --- /dev/null +++ b/z3-cli/.env.example @@ -0,0 +1,10 @@ +# DATABASE +APP_DB_KIND= +APP_DB_USERNAME= +APP_DB_PASSWORD= +APP_DB_JDBC_URL= + +# ENCRYPTION +AES_KEY= +AES_SALT= +AES_KEY_LENGTH= \ No newline at end of file diff --git a/z3-cli/pom.xml b/z3-cli/pom.xml new file mode 100644 index 0000000..3c12086 --- /dev/null +++ b/z3-cli/pom.xml @@ -0,0 +1,131 @@ + + + z3-cli + + + + + quarkus-maven-plugin + + + + build + generate-code + generate-code-tests + native-image-agent + + + + true + ${quarkus.platform.group-id} + ${quarkus.platform.version} + + + maven-compiler-plugin + + + -parameters + + + ${maven-compiler-plugin.version} + + + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Xshare:off + + + org.jboss.logmanager.LogManager + ${maven.home} + ${project.build.directory}/coverage-reports/jacoco.exec + + ${project.build.directory}/coverage-jacoco + + true + + + ${surefire-plugin.version} + + + maven-failsafe-plugin + + + org.jboss.logmanager.LogManager + ${maven.home} + ${project.build.directory}/${project.build.finalName}-runner + + + + + + + integration-test + verify + + + + ${failsafe-plugin.version} + + + + + + + z3-domain + tech.pegase.z3 + ${z3.version} + + + z3-infrastructure + tech.pegase.z3 + ${z3.version} + + + z3-shared + tech.pegase.z3 + ${z3.version} + + + quarkus-flyway + io.quarkus + + + quarkus-junit5-mockito + io.quarkus + test + + + quarkus-jacoco + io.quarkus + test + + + quarkus-arc + io.quarkus + + + datafaker + net.datafaker + ${net-datafaker.version} + + + + + 4.0.0 + + + z3 + tech.pegase.z3 + 1.1.0-SNAPSHOT + + + + 22 + 22 + UTF-8 + + + \ No newline at end of file diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/CommandRunner.java b/z3-cli/src/main/java/tech/pegase/z3/cli/CommandRunner.java new file mode 100644 index 0000000..361b804 --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/CommandRunner.java @@ -0,0 +1,63 @@ +package tech.pegase.z3.cli; + +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.QuarkusApplication; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Arrays; +import org.jboss.logging.Logger; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.cli.seeders.SeederRunner; + +@NullMarked +@ApplicationScoped +public class CommandRunner implements QuarkusApplication { + private static final Logger logger = Logger.getLogger(CommandRunner.class); + private final SeederRunner seederRunner; + private final SeederConfig seederConfig; + + CommandRunner(SeederRunner seederRunner, SeederConfig seederConfig) { + this.seederRunner = seederRunner; + this.seederConfig = seederConfig; + } + + @Override + public int run(String... args) { + logger.debug("Running command: " + Arrays.toString(args)); + try { + if (args.length == 0) { + System.out.println("Usage: java -jar z3-cli.jar "); + Quarkus.asyncExit(-1); + return 0; + } + selectCommand(args); + } catch (Exception e) { + logger.error(e.getMessage(), e); + System.out.println(e.getMessage()); + Quarkus.asyncExit(-1); + } + Quarkus.waitForExit(); + return 0; + } + + private void selectCommand(String... args) { + switch (args[0]) { + case "migrate": + logger.debug("Migrating " + Arrays.toString(args)); + Quarkus.asyncExit(); + break; + case "seed": + logger.debug("Seeding " + Arrays.toString(args)); + seed(Arrays.copyOfRange(args, 1, args.length)); + Quarkus.asyncExit(); + break; + default: + System.out.println("Unknown command: " + args[0]); + Quarkus.asyncExit(-1); + } + } + + private void seed(String... args) { + var dropPrevious = args.length == 0 || args[0].equals("seed"); + seederRunner.run(seederConfig.getConfigs(dropPrevious)); + } +} diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/Main.java b/z3-cli/src/main/java/tech/pegase/z3/cli/Main.java new file mode 100644 index 0000000..a7765b7 --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/Main.java @@ -0,0 +1,12 @@ +package tech.pegase.z3.cli; + +import io.quarkus.runtime.Quarkus; +import io.quarkus.runtime.annotations.QuarkusMain; + +@QuarkusMain +public class Main { + + public static void main(String... args) { + Quarkus.run(CommandRunner.class, args); + } +} diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/SeederConfig.java b/z3-cli/src/main/java/tech/pegase/z3/cli/SeederConfig.java new file mode 100644 index 0000000..3dc7822 --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/SeederConfig.java @@ -0,0 +1,22 @@ +package tech.pegase.z3.cli; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.cli.params.SeederParam; +import tech.pegase.z3.cli.seeders.SeederFactory; +import tech.pegase.z3.cli.seeders.UserSeeder; + +@NullMarked +@ApplicationScoped +public class SeederConfig { + private final SeederFactory seederFactory; + + SeederConfig(SeederFactory seederFactory) { + this.seederFactory = seederFactory; + } + + public List getConfigs(boolean dropPrevious) { + return List.of(SeederParam.of(seederFactory.create(UserSeeder.class), dropPrevious)); + } +} diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/params/SeederParam.java b/z3-cli/src/main/java/tech/pegase/z3/cli/params/SeederParam.java new file mode 100644 index 0000000..950b091 --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/params/SeederParam.java @@ -0,0 +1,11 @@ +package tech.pegase.z3.cli.params; + +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.cli.seeders.Seeder; + +@NullMarked +public record SeederParam(Seeder seeder, boolean dropPrevious) { + public static SeederParam of(Seeder seeder, boolean dropPrevious) { + return new SeederParam(seeder, dropPrevious); + } +} diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/Seeder.java b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/Seeder.java new file mode 100644 index 0000000..1298741 --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/Seeder.java @@ -0,0 +1,5 @@ +package tech.pegase.z3.cli.seeders; + +public interface Seeder { + void seed(); +} diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/SeederFactory.java b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/SeederFactory.java new file mode 100644 index 0000000..9f86850 --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/SeederFactory.java @@ -0,0 +1,35 @@ +package tech.pegase.z3.cli.seeders; + +import jakarta.enterprise.context.ApplicationScoped; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.port.service.Slugifyier; + +@ApplicationScoped +public class SeederFactory { + private final CredentialRepository credentialRepository; + private final UserRepository userRepository; + private final HashProvider hashProvider; + private final Slugifyier slugifyier; + + SeederFactory( + CredentialRepository credentialRepository, + UserRepository userRepository, + HashProvider hashProvider, + Slugifyier slugifyier) { + this.credentialRepository = credentialRepository; + this.userRepository = userRepository; + this.hashProvider = hashProvider; + this.slugifyier = slugifyier; + } + + public Seeder create(Class type) { + + if (type.equals(UserSeeder.class)) { + return new UserSeeder(credentialRepository, userRepository, hashProvider, slugifyier); + } + + throw new RuntimeException("Unhandled class " + type); + } +} diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/SeederRunner.java b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/SeederRunner.java new file mode 100644 index 0000000..2dd11dd --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/SeederRunner.java @@ -0,0 +1,33 @@ +package tech.pegase.z3.cli.seeders; + +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.narayana.jta.TransactionSemantics; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import java.util.List; +import org.jboss.logging.Logger; +import tech.pegase.z3.cli.params.SeederParam; + +@ApplicationScoped +public class SeederRunner { + private static final Logger logger = Logger.getLogger(SeederRunner.class); + + @Transactional + public void run(List seederParams) { + for (var seederParam : seederParams) { + try { + seed(seederParam.seeder()); + } catch (Exception e) { + var message = "Failed to run seeder: " + seederParam.seeder().getClass(); + logger.error(message, e); + System.err.println(message); + System.exit(1); + } + } + } + + private void seed(Seeder seeder) { + logger.info("Running seeder: " + seeder.getClass().getSimpleName()); + QuarkusTransaction.runner(TransactionSemantics.REQUIRE_NEW).run(seeder::seed); + } +} diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/UserSeeder.java b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/UserSeeder.java new file mode 100644 index 0000000..c85877d --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/UserSeeder.java @@ -0,0 +1,60 @@ +package tech.pegase.z3.cli.seeders; + +import java.util.Locale; +import java.util.Random; +import net.datafaker.Faker; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +public class UserSeeder implements Seeder { + private final CredentialRepository credentialRepository; + private final UserRepository userRepository; + private final HashProvider hashProvider; + private final Slugifyier slugifyier; + private final Faker faker; + + public UserSeeder( + CredentialRepository credentialRepository, + UserRepository userRepository, + HashProvider hashProvider, + Slugifyier slugifyier) { + this.credentialRepository = credentialRepository; + this.userRepository = userRepository; + this.hashProvider = hashProvider; + this.slugifyier = slugifyier; + this.faker = new Faker(Locale.of("en-US")); + } + + @Override + public void seed() { + for (var i = 0; i < 15; i++) { + var credential = getRandomCredential(); + credentialRepository.persist(credential); + userRepository.persist(getRandomUser(credential)); + } + } + + private Credential getRandomCredential() { + return new Credential( + Login.of(slugifyier.slugify(faker.name().name()).value()), + hashProvider.hash(PasswordHash.class, "password")); + } + + private User getRandomUser(Credential credential) { + var accessLevels = Role.AccessLevel.values(); + return new User( + faker.name().firstName(), + faker.name().lastName(), + Email.of(faker.internet().emailAddress()), + Role.of(accessLevels[new Random().nextInt(accessLevels.length)]), + credential.getUuid()); + } +} diff --git a/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/package-info.java b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/package-info.java new file mode 100644 index 0000000..3a78bb3 --- /dev/null +++ b/z3-cli/src/main/java/tech/pegase/z3/cli/seeders/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.cli.seeders; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-cli/src/main/resources/application.properties b/z3-cli/src/main/resources/application.properties new file mode 100644 index 0000000..aee8b4b --- /dev/null +++ b/z3-cli/src/main/resources/application.properties @@ -0,0 +1,14 @@ +quarkus.datasource.db-kind=postgresql +quarkus.datasource.username=${APP_DB_USERNAME} +quarkus.datasource.password=${APP_DB_PASSWORD} +quarkus.datasource.jdbc.url=${APP_DB_JDBC_URL} +quarkus.hibernate-orm.database.generation=none +quarkus.hibernate-orm.jdbc.statement-batch-size=50 +quarkus.hibernate-orm.packages=tech.pegase.z3.domain,tech.pegase.z3.shared.valueobject,tech.pegase.z3.statistics.domain,tech.pegase.happy.core,com.blazebit.persistence.impl.function.entity +quarkus.flyway.active=false +quarkus.flyway.migrate-at-start=false +quarkus.flyway.default-schema=z3-backend +quarkus.hibernate-orm.database.default-schema=z3-backend + +# LOGGING +quarkus.log.category."tech.pegase.z3.cli".level=DEBUG \ No newline at end of file diff --git a/z3-domain/pom.xml b/z3-domain/pom.xml new file mode 100644 index 0000000..88d05b2 --- /dev/null +++ b/z3-domain/pom.xml @@ -0,0 +1,145 @@ + + + z3-domain + + + + + jacoco-maven-plugin + + + **/port/repository/*.class + **/port/service/*.class + + + + + + + + true + ${project.build.directory}/coverage-reports/jacoco-ut.exec + surefire.jacoco.args + + + prepare-agent + + pre-unit-test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + ${project.build.directory}/coverage-jacoco/unit-tests + + + + report + + post-unit-test + test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + + + BUNDLE + + + LINE + 1.00 + COVEREDRATIO + + + + + + + check + + jacoco-check-ut + verify + + + + org.jacoco + ${jacoco.version} + + + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + ${surefire.jacoco.args} -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + + + + **/*IT.java + + + org.jboss.logmanager.LogManager + ${maven.home} + + + ${surefire-plugin.version} + + + + + + + z3-shared + tech.pegase.z3 + ${z3.version} + + + quarkus-hibernate-orm + io.quarkus + + + quarkus-jdbc-postgresql + io.quarkus + + + quarkus-jdbc-h2 + io.quarkus + test + + + quarkus-junit5-mockito + io.quarkus + test + + + quarkus-jacoco + io.quarkus + test + + + commons-validator + commons-validator + ${commons-validator.version} + + + + 4.0.0 + + + + z3 + tech.pegase.z3 + 1.1.0-SNAPSHOT + + + + 22 + 22 + UTF-8 + + \ No newline at end of file diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Brand.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Brand.java new file mode 100644 index 0000000..67dff6e --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Brand.java @@ -0,0 +1,104 @@ +package tech.pegase.z3.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import tech.pegase.happy.core.AggregateRoot; +import tech.pegase.z3.domain.exception.CannotAssignFqdnException; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; + +@Entity +@Table(schema = "z3-backend", name = "brands") +public class Brand implements AggregateRoot { + @jakarta.persistence.Id + @Column(name = "uuid", columnDefinition = "uuid", updatable = false) + private final UUID uuid; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "domain_names", + joinColumns = @JoinColumn(name = "brand_uuid", nullable = false), + uniqueConstraints = {@UniqueConstraint(columnNames = {"fqdn"})}) + @AttributeOverride(name = "value", column = @Column(name = "fqdn")) + private final Set fqdns; + + @AttributeOverride(name = "value", column = @Column(name = "slug")) + private Slug slug; + + @Column(name = "name") + private String name; + + public Brand(Slug slug, String name) { + this.uuid = UUID.randomUUID(); + this.slug = slug; + this.name = name; + this.fqdns = new HashSet<>(); + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + public Brand() { + this.uuid = null; + this.slug = null; + this.name = null; + this.fqdns = null; + } + + @Override + public Id getId() { + return new Id<>(getUuid()); + } + + public UUID getUuid() { + return uuid; + } + + public Slug getSlug() { + return slug; + } + + public void setSlug(Slug slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) throws IllegalStateException { + if (name.isBlank()) { + throw new IllegalStateException("Name cannot be empty"); + } + this.name = name; + } + + public Set getFqdns() { + return fqdns; + } + + public void clearFqdns() { + this.fqdns.clear(); + } + + public void assignFqdn(Fqdn fqdn, Predicate canAssignFqdnToBrand) + throws CannotAssignFqdnException { + if (!canAssignFqdnToBrand.test(fqdn)) { + throw new CannotAssignFqdnException(fqdn); + } + + this.fqdns.add(fqdn); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Catalog.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Catalog.java new file mode 100644 index 0000000..4b2b4ea --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Catalog.java @@ -0,0 +1,76 @@ +package tech.pegase.z3.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.util.UUID; +import tech.pegase.happy.core.AggregateRoot; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; + +@Entity +@Table(schema = "z3-backend", name = "catalogs") +public class Catalog implements AggregateRoot { + @jakarta.persistence.Id + @Column(name = "uuid", columnDefinition = "uuid", updatable = false) + private final UUID uuid; + + @AttributeOverride(name = "value", column = @Column(name = "slug")) + private Slug slug; + + @Column(name = "name") + private String name; + + @Column(name = "brand_uuid", columnDefinition = "uuid", updatable = false) + private UUID brand; + + public Catalog(Slug slug, String name, UUID brand) { + this.uuid = UUID.randomUUID(); + this.slug = slug; + this.brand = brand; + this.setName(name); + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + protected Catalog() { + this.uuid = null; + this.slug = null; + this.name = null; + this.brand = null; + } + + @Override + public Id getId() { + return new Id<>(getUuid()); + } + + public UUID getUuid() { + return uuid; + } + + public Slug getSlug() { + return slug; + } + + public void setSlug(Slug slug) { + this.slug = slug; + } + + public String getName() { + return name; + } + + public void setName(String name) throws IllegalStateException { + if (name.isBlank()) { + throw new IllegalStateException("Name cannot be empty"); + } + this.name = name; + } + + public UUID getBrandUuid() { + return brand; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Credential.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Credential.java new file mode 100644 index 0000000..3b25def --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Credential.java @@ -0,0 +1,68 @@ +package tech.pegase.z3.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.util.UUID; +import tech.pegase.happy.core.AggregateRoot; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; + +@Entity +@Table(schema = "z3-backend", name = "credentials") +public class Credential implements AggregateRoot { + @jakarta.persistence.Id + @Column(name = "uuid", columnDefinition = "uuid", updatable = false) + private final UUID uuid; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "login")) + private Login login; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "password")) + private PasswordHash password; + + public Credential(Login login, PasswordHash password) { + this.uuid = UUID.randomUUID(); + this.login = login; + this.setPassword(password); + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + protected Credential() { + this.uuid = null; + this.login = null; + this.password = null; + } + + @Override + public Id getId() { + return new Id<>(uuid); + } + + public UUID getUuid() { + return uuid; + } + + public PasswordHash getPassword() { + return password; + } + + public void setPassword(PasswordHash password) { + this.password = password; + } + + public Login getLogin() { + return login; + } + + public void setLogin(Login login) { + this.login = login; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Link.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Link.java new file mode 100644 index 0000000..0aa9c19 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Link.java @@ -0,0 +1,175 @@ +package tech.pegase.z3.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.ZonedDateTime; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.AggregateRoot; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; +import tech.pegase.z3.shared.valueobject.Source; + +@Entity +@Table(schema = "z3-backend", name = "links") +public class Link implements AggregateRoot { + @EmbeddedId + @AttributeOverride( + name = "value", + column = @Column(name = "source", columnDefinition = "value", nullable = false)) + private final Source source; + + @Nullable + @Column(name = "brand_uuid", columnDefinition = "uuid", updatable = false) + private final UUID brand; + + @Column(name = "created_at", nullable = false, updatable = false) + private final ZonedDateTime createdAt; + + @Nullable + @Column(name = "updated_at") + private ZonedDateTime updatedAt; + + @Nullable + @Column(name = "catalog_uuid", columnDefinition = "uuid") + private UUID catalog; + + @Column(name = "is_external", nullable = false) + private boolean isExternal; + + @Column(name = "visits", nullable = false) + private Integer visits; + + @Embedded + @AttributeOverride( + name = "value", + column = @Column(name = "target", columnDefinition = "value", nullable = false)) + private Target target; + + @Embedded + @AttributeOverride( + name = "value", + column = @Column(name = "is_valid", columnDefinition = "value", nullable = false)) + private LinkValidity validity; + + @Nullable + @Column(name = "tag_uuid", columnDefinition = "uuid") + private UUID tag; + + public Link( + Source source, + Target target, + boolean isExternal, + @Nullable UUID brandUuid, + @Nullable UUID tagUuid, + @Nullable UUID catalogUuid) { + this.source = source; + this.brand = brandUuid; + this.catalog = catalogUuid; + this.tag = tagUuid; + this.isExternal = isExternal; + this.target = target; + this.validity = LinkValidity.unknown(); + this.createdAt = ZonedDateTime.now(); + this.updatedAt = null; + this.visits = 0; + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + protected Link() { + this.source = null; + this.target = null; + this.createdAt = null; + this.updatedAt = null; + this.validity = null; + this.isExternal = false; + this.brand = null; + this.catalog = null; + this.tag = null; + this.visits = 0; + } + + public void incVisits() { + visits += 1; + } + + public int getVisits() { + return visits; + } + + @Override + public Id getId() { + return new Id<>(getSource()); + } + + public Source getSource() { + return source; + } + + public Target getTarget() { + return target; + } + + public void setTarget(Target target) { + this.target = target; + } + + public LinkValidity getValidity() { + return this.validity; + } + + public void setValidity(LinkValidity validity) { + this.validity = validity; + } + + public ZonedDateTime getCreatedAt() { + return createdAt; + } + + public @Nullable UUID getTag() { + return this.tag; + } + + public void setTag(@Nullable UUID tag) { + this.tag = tag; + } + + public @Nullable UUID getCatalog() { + return catalog; + } + + public void setCatalog(@Nullable UUID catalog) { + this.catalog = catalog; + } + + public void removeTag() { + this.tag = null; + } + + public void setIsExternal(boolean isExternal) { + this.isExternal = isExternal; + } + + public boolean isExternal() { + return isExternal; + } + + public @Nullable UUID getBrand() { + return brand; + } + + public @Nullable ZonedDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(ZonedDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/ReportingSubscription.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/ReportingSubscription.java new file mode 100644 index 0000000..3bc83e2 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/ReportingSubscription.java @@ -0,0 +1,54 @@ +package tech.pegase.z3.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.util.UUID; +import tech.pegase.happy.core.AggregateRoot; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; + +@Entity +@Table(schema = "z3-backend", name = "user_reporting_subscriptions") +public class ReportingSubscription implements AggregateRoot { + @jakarta.persistence.Id + @Column(name = "uuid", columnDefinition = "uuid", updatable = false) + private final UUID uuid; + + @Column(name = "user_uuid", columnDefinition = "uuid", updatable = false) + private UUID user; + + @Column(name = "brand_uuid", columnDefinition = "uuid", updatable = false) + private UUID brand; + + public ReportingSubscription(UUID user, UUID brand) { + this.uuid = UUID.randomUUID(); + this.user = user; + this.brand = brand; + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + protected ReportingSubscription() { + this.uuid = null; + this.user = null; + this.brand = null; + } + + @Override + public Id getId() { + return new Id<>(getUuid()); + } + + public UUID getUuid() { + return uuid; + } + + public UUID getUser() { + return user; + } + + public UUID getBrand() { + return brand; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Tag.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Tag.java new file mode 100644 index 0000000..23514e4 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/Tag.java @@ -0,0 +1,69 @@ +package tech.pegase.z3.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.util.UUID; +import tech.pegase.happy.core.AggregateRoot; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; + +@Entity +@Table(schema = "z3-backend", name = "tags") +public class Tag implements AggregateRoot { + @jakarta.persistence.Id + @Column(name = "uuid", columnDefinition = "uuid", updatable = false) + private final UUID uuid; + + @Column(name = "name") + private String name; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "color")) + private Color color; + + public Tag(String name, Color color) { + this.uuid = UUID.randomUUID(); + this.setName(name); + this.setColor(color); + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + protected Tag() { + this.uuid = null; + this.name = null; + this.color = null; + } + + @Override + public Id getId() { + return new Id<>(getUuid()); + } + + public UUID getUuid() { + return uuid; + } + + public String getName() { + return name; + } + + public void setName(String name) throws IllegalStateException { + if (name.isBlank()) { + throw new IllegalStateException("Name cannot be empty"); + } + this.name = name; + } + + public Color getColor() { + return color; + } + + public void setColor(Color color) { + this.color = color; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/User.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/User.java new file mode 100644 index 0000000..cfc8657 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/User.java @@ -0,0 +1,138 @@ +package tech.pegase.z3.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.AggregateRoot; +import tech.pegase.z3.domain.entity.converters.StringCryptoConverter; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; + +/** NOTE: All fields are encrypted. */ +@Entity +@Table(schema = "z3-backend", name = "users") +public class User implements AggregateRoot { + @jakarta.persistence.Id + @Column(name = "uuid", columnDefinition = "uuid", updatable = false) + private final UUID uuid; + + @Convert(converter = StringCryptoConverter.class) + @Column(name = "first_name") + private String firstName; + + @Convert(converter = StringCryptoConverter.class) + @Column(name = "last_name") + private String lastName; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "email")) + private Email email; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "role")) + private Role role; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable( + name = "user_brand", + joinColumns = @JoinColumn(name = "user_uuid"), + uniqueConstraints = {@UniqueConstraint(columnNames = {"user_uuid", "brand_uuid"})}) + @Column(name = "brand_uuid") + private Set brands; + + @Column(name = "credential_uuid", columnDefinition = "uuid", updatable = false) + private UUID credentialUuid; + + public User(String firstName, String lastName, Email email, Role role, UUID credentialUuid) { + this.uuid = UUID.randomUUID(); + setFirstName(firstName); + setLastName(lastName); + setEmail(email); + setRole(role); + this.credentialUuid = credentialUuid; + this.brands = new HashSet<>(); + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + protected User() { + this.uuid = null; + this.firstName = null; + this.lastName = null; + this.email = null; + this.role = null; + this.credentialUuid = null; + } + + @Override + public Id getId() { + return new Id<>(uuid); + } + + public UUID getUuid() { + return uuid; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) throws IllegalStateException { + if (firstName.isBlank()) { + throw new IllegalStateException("First name cannot be empty"); + } + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) throws IllegalStateException { + if (lastName.isBlank()) { + throw new IllegalStateException("Last name cannot be empty"); + } + this.lastName = lastName; + } + + public Email getEmail() { + return email; + } + + public void setEmail(Email email) { + this.email = email; + } + + public Role getRole() { + return role; + } + + public void setRole(Role role) { + this.role = role; + } + + public UUID getCredentialUuid() { + return this.credentialUuid; + } + + public Set getBrands() { + return brands; + } + + public void setBrands(Set brands) { + this.brands = brands; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/converters/StringCryptoConverter.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/converters/StringCryptoConverter.java new file mode 100644 index 0000000..a9ba59b --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/converters/StringCryptoConverter.java @@ -0,0 +1,41 @@ +package tech.pegase.z3.domain.entity.converters; + +import io.quarkus.runtime.Startup; +import jakarta.annotation.PostConstruct; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import tech.pegase.z3.domain.port.service.EncryptionProvider; +import tech.pegase.z3.domain.port.service.EncryptionProvider.CryptException; + +@Singleton +@Converter +@Startup +public class StringCryptoConverter implements AttributeConverter { + private static EncryptionProvider staticEncryptionProvider; + @Inject EncryptionProvider encryptionProvider; + + @PostConstruct + public void init() { + staticEncryptionProvider = this.encryptionProvider; + } + + @Override + public String convertToDatabaseColumn(String value) { + try { + return encryptionProvider.encrypt(value); + } catch (CryptException e) { + throw new RuntimeException("Failed to convert field to encrypted database value", e); + } + } + + @Override + public String convertToEntityAttribute(String dbValue) { + try { + return encryptionProvider.decrypt(dbValue); + } catch (CryptException e) { + throw new RuntimeException("Failed to convert field from encrypted database value", e); + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/entity/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/package-info.java new file mode 100644 index 0000000..8f807dd --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/entity/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.entity; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/exception/CannotAssignFqdnException.java b/z3-domain/src/main/java/tech/pegase/z3/domain/exception/CannotAssignFqdnException.java new file mode 100644 index 0000000..be72987 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/exception/CannotAssignFqdnException.java @@ -0,0 +1,16 @@ +package tech.pegase.z3.domain.exception; + +import tech.pegase.z3.domain.valueobject.Fqdn; + +public class CannotAssignFqdnException extends RuntimeException { + private final Fqdn fqdn; + + public CannotAssignFqdnException(Fqdn fqdn) { + super("Cannot assign fqdn " + fqdn.getValue()); + this.fqdn = fqdn; + } + + public Fqdn getFqdn() { + return fqdn; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/BrandRepository.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/BrandRepository.java new file mode 100644 index 0000000..4202ee6 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/BrandRepository.java @@ -0,0 +1,34 @@ +package tech.pegase.z3.domain.port.repository; + +import java.util.Optional; +import java.util.UUID; +import tech.pegase.happy.core.DomainEntity; +import tech.pegase.happy.core.Repository; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public interface BrandRepository extends Repository { + void persist(Brand brand); + + void update(Brand brand); + + boolean exists(UUID uuid); + + boolean exists(Slug slug); + + Optional findByUuid(UUID uuid); + + Optional findBySlug(Slug slug); + + Optional findByFqdn(Fqdn fqdn); + + PaginatedData findAll(Pagination pagination); + + @Override + default Optional find(DomainEntity.Id id) { + return findByUuid(id.value()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/CatalogRepository.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/CatalogRepository.java new file mode 100644 index 0000000..e476d11 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/CatalogRepository.java @@ -0,0 +1,37 @@ +package tech.pegase.z3.domain.port.repository; + +import java.util.Optional; +import java.util.UUID; +import tech.pegase.happy.core.DomainEntity; +import tech.pegase.happy.core.Repository; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public interface CatalogRepository extends Repository { + void persist(Catalog catalog); + + void update(Catalog catalog); + + boolean exists(UUID uuid); + + boolean exists(Slug slug); + + boolean remove(UUID uuid); + + boolean remove(Slug slug); + + Optional findByUuid(UUID uuid); + + Optional findBySlug(Slug slug); + + PaginatedData findAll(Pagination pagination); + + PaginatedData findAllForBrand(UUID brandUuid, Pagination pagination); + + @Override + default Optional find(DomainEntity.Id id) { + return findByUuid(id.value()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/CredentialRepository.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/CredentialRepository.java new file mode 100644 index 0000000..43b6a33 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/CredentialRepository.java @@ -0,0 +1,27 @@ +package tech.pegase.z3.domain.port.repository; + +import java.util.Optional; +import java.util.UUID; +import tech.pegase.happy.core.DomainEntity; +import tech.pegase.happy.core.Repository; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.valueobject.Login; + +public interface CredentialRepository extends Repository { + void persist(Credential credential); + + void update(Credential credential); + + boolean exists(UUID uuid); + + boolean remove(UUID uuid); + + Optional findById(UUID uuid); + + Optional findByLogin(Login login); + + @Override + default Optional find(DomainEntity.Id id) { + return findById(id.value()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/LinkRepository.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/LinkRepository.java new file mode 100644 index 0000000..cf3aaff --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/LinkRepository.java @@ -0,0 +1,83 @@ +package tech.pegase.z3.domain.port.repository; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.DomainEntity; +import tech.pegase.happy.core.Repository; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; +import tech.pegase.z3.shared.valueobject.Source; + +public interface LinkRepository extends Repository { + void persist(Link link); + + void update(Link link); + + default void updateOrPersist(Link link) { + update(link); + } + + boolean exists(Source source); + + Optional findBySource(Source source); + + PaginatedData findAllForBrand(Pagination pagination, @Nullable UUID brandUuid); + + PaginatedData findAllForTag(Pagination pagination, @Nullable UUID tagUuid); + + PaginatedData findAllForCatalog(Pagination pagination, @Nullable UUID catalogUuid); + + PaginatedData findAll(Pagination pagination, LinkFiltering filters); + + default PaginatedData findAll(Pagination pagination) { + return findAll(pagination, LinkFiltering.of(Map.of())); + } + + PaginatedData findAll(Pagination pagination, LinkFiltering filters, LinkOrdering ordering); + + default PaginatedData findAll( + Pagination pagination, LinkFiltering filters, LinkSearching... searching) { + return findAll(pagination, filters, List.of(searching)); + } + + PaginatedData findAll( + Pagination pagination, LinkFiltering filters, List searching); + + default PaginatedData findAll( + Pagination pagination, + LinkFiltering filters, + LinkOrdering ordering, + LinkSearching... searching) { + return findAll(pagination, filters, ordering, List.of(searching)); + } + + PaginatedData findAll( + Pagination pagination, + LinkFiltering filters, + LinkOrdering ordering, + List searching); + + @Override + default Optional find(DomainEntity.Id id) { + return findBySource(id.value()); + } + + Long countTotalVisits(@Nullable UUID brandUuid); + + Long countTotalVisits(); + + Long countTotalExternal(@Nullable UUID brandUuid); + + Long countTotalExternal(); + + Long countTotalInvalids(@Nullable UUID brandUuid); + + Long countTotalInvalids(); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/ReportingSubscriptionRepository.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/ReportingSubscriptionRepository.java new file mode 100644 index 0000000..7b31983 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/ReportingSubscriptionRepository.java @@ -0,0 +1,16 @@ +package tech.pegase.z3.domain.port.repository; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.Repository; +import tech.pegase.z3.domain.entity.ReportingSubscription; + +public interface ReportingSubscriptionRepository extends Repository { + Set getAllSubscriptions(); + + Set getAllSubscriptions(UUID userUuid); + + void removeAllUserSubscriptions(UUID userUuid); + + void setSubscriptions(Set subscriptions); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/TagRepository.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/TagRepository.java new file mode 100644 index 0000000..9dfd112 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/TagRepository.java @@ -0,0 +1,30 @@ +package tech.pegase.z3.domain.port.repository; + +import java.util.Optional; +import java.util.UUID; +import tech.pegase.happy.core.DomainEntity; +import tech.pegase.happy.core.Repository; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public interface TagRepository extends Repository { + void persist(Tag tag); + + void update(Tag tag); + + boolean exists(UUID uuid); + + boolean remove(UUID uuid); + + Optional findByUuid(UUID uuid); + + Optional findByName(String name); + + PaginatedData findAll(Pagination pagination); + + @Override + default Optional find(DomainEntity.Id id) { + return findByUuid(id.value()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/UserRepository.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/UserRepository.java new file mode 100644 index 0000000..02f610c --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/UserRepository.java @@ -0,0 +1,33 @@ +package tech.pegase.z3.domain.port.repository; + +import java.util.Optional; +import java.util.UUID; +import tech.pegase.happy.core.DomainEntity; +import tech.pegase.happy.core.Repository; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public interface UserRepository extends Repository { + void persist(User user); + + void update(User user); + + boolean exists(UUID uuid); + + boolean exists(Email email); + + boolean remove(UUID uuid); + + Optional findByUuid(UUID uuid); + + Optional findByCredentialUuid(UUID credentialUuid); + + PaginatedData findAll(Pagination pagination); + + @Override + default Optional find(DomainEntity.Id id) { + return findByUuid(id.value()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/package-info.java new file mode 100644 index 0000000..ae5dcb1 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/repository/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.port.repository; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/CsvEditor.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/CsvEditor.java new file mode 100644 index 0000000..3c7e23c --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/CsvEditor.java @@ -0,0 +1,9 @@ +package tech.pegase.z3.domain.port.service; + +import java.io.InputStream; +import java.util.function.Function; +import tech.pegase.z3.domain.valueobject.Target; + +public interface CsvEditor { + InputStream doMagic(InputStream csvFile, Function getShortUrlFor); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/EncryptionProvider.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/EncryptionProvider.java new file mode 100644 index 0000000..bc3e02b --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/EncryptionProvider.java @@ -0,0 +1,13 @@ +package tech.pegase.z3.domain.port.service; + +public interface EncryptionProvider { + String encrypt(String plainText) throws CryptException; + + String decrypt(String cipherText) throws CryptException; + + class CryptException extends Exception { + public CryptException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/HashProvider.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/HashProvider.java new file mode 100644 index 0000000..dce0828 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/HashProvider.java @@ -0,0 +1,15 @@ +package tech.pegase.z3.domain.port.service; + +import tech.pegase.z3.domain.valueobject.concepts.Hash; + +public interface HashProvider { + + /** Produce a Hash. */ + H hash(Class type, String value); + + /** Check is the hash is valid for this provider. */ + boolean check(Hash hash); + + /** Check if hash equals the value. */ + boolean areEquals(Hash hash, String value); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/JwtGenerator.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/JwtGenerator.java new file mode 100644 index 0000000..979dcf1 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/JwtGenerator.java @@ -0,0 +1,8 @@ +package tech.pegase.z3.domain.port.service; + +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.valueobject.JwtToken; + +public interface JwtGenerator { + JwtToken generateJwt(User user); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/MessageSender.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/MessageSender.java new file mode 100644 index 0000000..bb99f3b --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/MessageSender.java @@ -0,0 +1,13 @@ +package tech.pegase.z3.domain.port.service; + +import tech.pegase.z3.domain.valueobject.Message; + +public interface MessageSender { + Status send(Message message); + + sealed interface Status { + record Success() implements MessageSender.Status {} + + record Failed() implements MessageSender.Status {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/PasswordGenerator.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/PasswordGenerator.java new file mode 100644 index 0000000..e177442 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/PasswordGenerator.java @@ -0,0 +1,5 @@ +package tech.pegase.z3.domain.port.service; + +public interface PasswordGenerator { + String generatePassword(int length); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/RandomSourceGenerator.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/RandomSourceGenerator.java new file mode 100644 index 0000000..8b29825 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/RandomSourceGenerator.java @@ -0,0 +1,7 @@ +package tech.pegase.z3.domain.port.service; + +import tech.pegase.z3.shared.valueobject.Source; + +public interface RandomSourceGenerator { + Source generate(); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/Slugifyier.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/Slugifyier.java new file mode 100644 index 0000000..f63cf0c --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/Slugifyier.java @@ -0,0 +1,7 @@ +package tech.pegase.z3.domain.port.service; + +import tech.pegase.z3.domain.valueobject.Slug; + +public interface Slugifyier { + Slug slugify(String value) throws IllegalArgumentException; +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/UrlChecker.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/UrlChecker.java new file mode 100644 index 0000000..8b435dd --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/UrlChecker.java @@ -0,0 +1,12 @@ +package tech.pegase.z3.domain.port.service; + +import io.smallrye.mutiny.Uni; +import java.net.URL; + +public interface UrlChecker { + default Uni verify(URL url) { + return verify(url, false); + } + + Uni verify(URL url, boolean assertIsHttps); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/package-info.java new file mode 100644 index 0000000..110d04d --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/port/service/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.port.service; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/service/DomainNameService.java b/z3-domain/src/main/java/tech/pegase/z3/domain/service/DomainNameService.java new file mode 100644 index 0000000..1618bb4 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/service/DomainNameService.java @@ -0,0 +1,8 @@ +package tech.pegase.z3.domain.service; + +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.valueobject.Fqdn; + +public interface DomainNameService { + Boolean canFqdnBeAssignedToBrand(Fqdn fqdn, Brand brand); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/service/DomainNameServiceImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/service/DomainNameServiceImpl.java new file mode 100644 index 0000000..9b58871 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/service/DomainNameServiceImpl.java @@ -0,0 +1,29 @@ +package tech.pegase.z3.domain.service; + +import jakarta.enterprise.context.ApplicationScoped; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.valueobject.Fqdn; + +@ApplicationScoped +public class DomainNameServiceImpl implements DomainNameService { + private final BrandRepository brandRepository; + + DomainNameServiceImpl(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + + @Override + public Boolean canFqdnBeAssignedToBrand(Fqdn fqdn, Brand brand) { + var fqdnBrand = brandRepository.findByFqdn(fqdn).orElse(null); + + // * Ok if the FQDN is not already assigned. + if (fqdnBrand == null) { + return true; + } + + // * OK if the FQDN is already associated with the brand + // ! NOK if another brand already owns the FQDN + return brand.getUuid().equals(fqdnBrand.getUuid()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/service/VerifyLinkService.java b/z3-domain/src/main/java/tech/pegase/z3/domain/service/VerifyLinkService.java new file mode 100644 index 0000000..f11c51c --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/service/VerifyLinkService.java @@ -0,0 +1,7 @@ +package tech.pegase.z3.domain.service; + +import tech.pegase.z3.domain.entity.Link; + +public interface VerifyLinkService { + void verifyAndPersistValidity(Link link); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/service/VerifyLinkServiceImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/service/VerifyLinkServiceImpl.java new file mode 100644 index 0000000..7935f51 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/service/VerifyLinkServiceImpl.java @@ -0,0 +1,53 @@ +package tech.pegase.z3.domain.service; + +import io.smallrye.mutiny.TimeoutException; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Duration; +import java.util.concurrent.CompletionException; +import org.jboss.logging.Logger; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.service.UrlChecker; +import tech.pegase.z3.domain.valueobject.LinkValidity; + +@ApplicationScoped +public class VerifyLinkServiceImpl implements VerifyLinkService { + private static final long resolveLinkTimeoutInSeconds = 6; + // Not final to silence log in tests + private static Logger logger = Logger.getLogger(VerifyLinkServiceImpl.class.getName()); + private final LinkRepository linkRepository; + private final UrlChecker urlChecker; + + VerifyLinkServiceImpl(LinkRepository linkRepository, UrlChecker urlChecker) { + this.linkRepository = linkRepository; + this.urlChecker = urlChecker; + } + + @Override + public void verifyAndPersistValidity(Link link) { + try { + var target = link.getTarget(); + var linkValidity = + urlChecker + .verify(target.toUrl()) + .chain(Uni.createFrom()::item) + .await() + .atMost(Duration.ofSeconds(resolveLinkTimeoutInSeconds)) + ? LinkValidity.valid() + : LinkValidity.invalid(); + + link.setValidity(linkValidity); + logger.debug("Link '%s' is valid".formatted(link.getSource())); + } catch (TimeoutException e) { + var message = "Timed out verifying link: '%s'".formatted(link.getSource()); + logger.debug(message); + link.setValidity(LinkValidity.invalid()); + } catch (CompletionException e) { + var message = "Failed to verify link: '%s'".formatted(link.getSource()); + logger.warn(message, e); + } finally { + linkRepository.update(link); + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/service/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/service/package-info.java new file mode 100644 index 0000000..de7ff5f --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/service/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.service; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUser.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUser.java new file mode 100644 index 0000000..565a360 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUser.java @@ -0,0 +1,17 @@ +package tech.pegase.z3.domain.usecase.auth; + +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.JwtToken; + +public interface AuthenticateUser extends UseCase { + sealed interface Input { + record Authenticate(String login, String password) implements AuthenticateUser.Input {} + } + + sealed interface Output { + record Valid(JwtToken token) implements AuthenticateUser.Output {} + + record Invalid() implements AuthenticateUser.Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUserImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUserImpl.java new file mode 100644 index 0000000..c806c66 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUserImpl.java @@ -0,0 +1,64 @@ +package tech.pegase.z3.domain.usecase.auth; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.port.service.JwtGenerator; +import tech.pegase.z3.domain.valueobject.Login; + +@ApplicationScoped +public class AuthenticateUserImpl implements AuthenticateUser { + private final JwtGenerator jwtGenerator; + private final UserRepository userRepository; + private final CredentialRepository credentialRepository; + private final HashProvider hashProvider; + + @Inject + public AuthenticateUserImpl( + UserRepository userRepository, + CredentialRepository credentialRepository, + JwtGenerator jwtGenerator, + HashProvider hashProvider) { + this.userRepository = userRepository; + this.credentialRepository = credentialRepository; + this.jwtGenerator = jwtGenerator; + this.hashProvider = hashProvider; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.Authenticate inputCase -> run(inputCase); + }; + } + + private Output run(Input.Authenticate inputCase) { + try { + var login = inputCase.login(); + var password = inputCase.password(); + var credential = credentialRepository.findByLogin(Login.of(login)).orElse(null); + User user; + + if (credential == null) { + throw new IllegalStateException("Credentials not found"); + } + + user = userRepository.findByCredentialUuid(credential.getUuid()).orElse(null); + + if (user == null) { + throw new IllegalStateException("User not found"); + } + + if (!hashProvider.areEquals(credential.getPassword(), password)) { + throw new IllegalStateException("Passwords do not match"); + } + + return new Output.Valid(jwtGenerator.generateJwt(user)); + } catch (IllegalStateException e) { + return new Output.Invalid(); + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/package-info.java new file mode 100644 index 0000000..e92df9b --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/auth/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.usecase.auth; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/CreateBrand.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/CreateBrand.java new file mode 100644 index 0000000..5de5de2 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/CreateBrand.java @@ -0,0 +1,24 @@ +package tech.pegase.z3.domain.usecase.brands; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; +import tech.pegase.z3.shared.lib.ReadOnlySet; + +public interface CreateBrand extends UseCase { + sealed interface Input { + record With(String name, Set fqdns) implements Input {} + } + + sealed interface Output { + record Created(BrandOutput brand) implements CreateBrand.Output {} + + record NotCreated(InvalidFields reasons) implements CreateBrand.Output, Failure {} + + record BrandOutput(UUID uuid, Slug slug, String name, ReadOnlySet fqdnSet) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/CreateBrandImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/CreateBrandImpl.java new file mode 100644 index 0000000..f426e9d --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/CreateBrandImpl.java @@ -0,0 +1,101 @@ +package tech.pegase.z3.domain.usecase.brands; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.HashSet; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.exception.CannotAssignFqdnException; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.service.DomainNameService; +import tech.pegase.z3.domain.usecase.brands.CreateBrand.Output.BrandOutput; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; +import tech.pegase.z3.shared.lib.ReadOnlySet; + +@ApplicationScoped +public class CreateBrandImpl implements CreateBrand { + private final Slugifyier slugifyierService; + private final BrandRepository brandRepository; + private final DomainNameService domainNameService; + + @Inject + CreateBrandImpl( + Slugifyier slugifyierService, + BrandRepository brandRepository, + DomainNameService domainNameService) { + this.slugifyierService = slugifyierService; + this.brandRepository = brandRepository; + this.domainNameService = domainNameService; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.With inputCase -> run(inputCase); + }; + } + + public Output run(Input.With inputCase) { + Slug slug; + Brand brand; + + var name = inputCase.name(); + var fqdnSet = inputCase.fqdns(); + + if (name.isBlank()) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("name", "ne peut être vide"))); + } + + try { + slug = this.slugifyierService.slugify(name); + } catch (IllegalArgumentException e) { + return new Output.NotCreated( + InvalidFields.of(InvalidField.of("name", "ne peut être simplifié"))); + } + + if (brandRepository.findBySlug(slug).isPresent()) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("name", "existe déjà"))); + } + + brand = new Brand(slug, name); + + Predicate assignFqdnPredicate = + fqdn -> domainNameService.canFqdnBeAssignedToBrand(fqdn, brand); + + var alreadyUsedFqdns = new HashSet(); + + for (var fqdn : fqdnSet) { + try { + brand.assignFqdn(fqdn, assignFqdnPredicate); + } catch (CannotAssignFqdnException e) { + var invalidFqdn = e.getFqdn(); + alreadyUsedFqdns.add(invalidFqdn.getValue()); + } + } + + if (!alreadyUsedFqdns.isEmpty()) { + return new Output.NotCreated( + InvalidFields.of( + InvalidField.of( + "fqdns", + "Les noms de domaines suivants sont déja utilisés : " + + String.join(", ", alreadyUsedFqdns)))); + } + + brandRepository.persist(brand); + + var brandOutput = + new BrandOutput( + brand.getUuid(), + brand.getSlug(), + brand.getName(), + new ReadOnlySet<>(fqdnSet.stream().map(Fqdn::getValue).collect(Collectors.toSet()))); + + return new Output.Created(brandOutput); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/GetBrand.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/GetBrand.java new file mode 100644 index 0000000..0cff8b1 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/GetBrand.java @@ -0,0 +1,23 @@ +package tech.pegase.z3.domain.usecase.brands; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Slug; + +public interface GetBrand extends UseCase { + sealed interface Input { + record ForSlug(Slug slug) implements GetBrand.Input {} + + record ForUuid(UUID uuid) implements GetBrand.Input {} + } + + sealed interface Output { + record Found(BrandOutput brand) implements GetBrand.Output {} + + record NoSuchBrand() implements GetBrand.Output, Failure {} + + record BrandOutput(UUID uuid, Slug slug, String name, Set fqdnSet) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/GetBrandImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/GetBrandImpl.java new file mode 100644 index 0000000..1dc8430 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/GetBrandImpl.java @@ -0,0 +1,60 @@ +package tech.pegase.z3.domain.usecase.brands; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.valueobject.Fqdn; + +@ApplicationScoped +public class GetBrandImpl implements GetBrand { + final BrandRepository brandRepository; + + @Inject + GetBrandImpl(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.ForUuid inputCase -> run(inputCase); + case Input.ForSlug inputCase -> run(inputCase); + }; + } + + private Output run(Input.ForUuid inputCase) { + var brandUuid = inputCase.uuid(); + + return findBrand(() -> brandRepository.findByUuid(brandUuid)); + } + + private Output run(Input.ForSlug inputCase) { + var brandSlug = inputCase.slug(); + + return findBrand(() -> brandRepository.findBySlug(brandSlug)); + } + + private Output findBrand(Supplier> brandSupplier) { + Brand brand; + var optionalBrand = brandSupplier.get(); + + if (optionalBrand.isEmpty()) { + return new Output.NoSuchBrand(); + } + + brand = optionalBrand.get(); + + var brandOutput = + new Output.BrandOutput( + brand.getUuid(), + brand.getSlug(), + brand.getName(), + brand.getFqdns().stream().map(Fqdn::getValue).collect(Collectors.toSet())); + + return new Output.Found(brandOutput); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/ListBrands.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/ListBrands.java new file mode 100644 index 0000000..87c99be --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/ListBrands.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.usecase.brands; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public interface ListBrands extends UseCase { + sealed interface Input { + record WithPagination(Pagination pagination) implements ListBrands.Input {} + } + + sealed interface Output { + record PaginatedBrands(PaginatedData brandList) implements ListBrands.Output {} + + record BrandOutput(UUID uuid, Slug slug, String name, Set fqdnSet) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/ListBrandsImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/ListBrandsImpl.java new file mode 100644 index 0000000..47bd1b4 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/ListBrandsImpl.java @@ -0,0 +1,41 @@ +package tech.pegase.z3.domain.usecase.brands; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.stream.Collectors; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.usecase.brands.ListBrands.Output.BrandOutput; +import tech.pegase.z3.domain.valueobject.Fqdn; + +@ApplicationScoped +public class ListBrandsImpl implements ListBrands { + final BrandRepository brandRepository; + + @Inject + public ListBrandsImpl(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.WithPagination inputCase -> run(inputCase); + }; + } + + private Output run(Input.WithPagination inputCase) { + var paginatedLinkDto = + this.brandRepository.findAll(inputCase.pagination()).map(this::brandToBrandOutput); + + return new Output.PaginatedBrands(paginatedLinkDto); + } + + private Output.BrandOutput brandToBrandOutput(Brand brand) { + return new BrandOutput( + brand.getUuid(), + brand.getSlug(), + brand.getName(), + brand.getFqdns().stream().map(Fqdn::getValue).collect(Collectors.toSet())); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/UpdateBrand.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/UpdateBrand.java new file mode 100644 index 0000000..a117ac4 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/UpdateBrand.java @@ -0,0 +1,25 @@ +package tech.pegase.z3.domain.usecase.brands; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +public interface UpdateBrand extends UseCase { + sealed interface Input { + record ForSlug(Slug slug, String name, Set fqdns) implements Input {} + } + + sealed interface Output { + record Updated(BrandOutput brand) implements UpdateBrand.Output {} + + record NoSuchBrand() implements UpdateBrand.Output, Failure {} + + record NotUpdated(InvalidFields reasons) implements UpdateBrand.Output, Failure {} + + record BrandOutput(UUID uuid, Slug slug, String name, Set fqdns) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/UpdateBrandImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/UpdateBrandImpl.java new file mode 100644 index 0000000..3a79a01 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/UpdateBrandImpl.java @@ -0,0 +1,117 @@ +package tech.pegase.z3.domain.usecase.brands; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.HashSet; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.exception.CannotAssignFqdnException; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.service.DomainNameService; +import tech.pegase.z3.domain.usecase.brands.UpdateBrand.Input.ForSlug; +import tech.pegase.z3.domain.usecase.brands.UpdateBrand.Output.BrandOutput; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +@ApplicationScoped +public class UpdateBrandImpl implements UpdateBrand { + private final Slugifyier slugifyierService; + private final BrandRepository brandRepository; + private final DomainNameService domainNameService; + + @Inject + UpdateBrandImpl( + Slugifyier slugifyierService, + BrandRepository brandRepository, + DomainNameService domainNameService) { + this.slugifyierService = slugifyierService; + this.brandRepository = brandRepository; + this.domainNameService = domainNameService; + } + + @Override + public Output run(Input input) { + return switch (input) { + case ForSlug inputCase -> run(inputCase); + }; + } + + private Output run(ForSlug inputCase) { + Slug newSlug; + Brand brand; + + var brandSlug = inputCase.slug(); + var name = inputCase.name(); + var fqdnSet = inputCase.fqdns(); + + if (name.isEmpty()) { + return new Output.NotUpdated(InvalidFields.of(InvalidField.of("name", "ne peut être vide"))); + } + + brand = this.brandRepository.findBySlug(brandSlug).orElse(null); + + if (brand == null) { + return new Output.NoSuchBrand(); + } + + try { + newSlug = this.slugifyierService.slugify(name); + } catch (IllegalArgumentException e) { + return new Output.NotUpdated( + InvalidFields.of(InvalidField.of("name", "ne peut être simplifié"))); + } + + if (brandSlugAlreadyExists(newSlug, brand.getUuid())) { + return new Output.NotUpdated(InvalidFields.of(InvalidField.of("name", "est déjà utilisé"))); + } + + brand.setName(name); + brand.setSlug(newSlug); + + brand.clearFqdns(); + + Predicate assignFqdnPredicate = + fqdn -> domainNameService.canFqdnBeAssignedToBrand(fqdn, brand); + + var alreadyUsedFqdns = new HashSet(); + + for (var fqdn : fqdnSet) { + try { + brand.assignFqdn(fqdn, assignFqdnPredicate); + } catch (CannotAssignFqdnException e) { + var invalidFqdn = e.getFqdn(); + alreadyUsedFqdns.add(invalidFqdn.getValue()); + } + } + + if (!alreadyUsedFqdns.isEmpty()) { + return new Output.NotUpdated( + InvalidFields.of( + InvalidField.of( + "fqdns", + "Les noms de domaines suivants sont déja utilisés : " + + String.join(", ", alreadyUsedFqdns)))); + } + + brandRepository.update(brand); + + var brandOutput = + new BrandOutput( + brand.getUuid(), + brand.getSlug(), + brand.getName(), + brand.getFqdns().stream().map(Fqdn::getValue).collect(Collectors.toSet())); + + return new Output.Updated(brandOutput); + } + + private boolean brandSlugAlreadyExists(Slug newSlug, UUID current) { + var matchingBrand = brandRepository.findBySlug(newSlug); + return matchingBrand.isPresent() && !matchingBrand.get().getUuid().equals(current); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/package-info.java new file mode 100644 index 0000000..0d29585 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/brands/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.usecase.brands; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalog.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalog.java new file mode 100644 index 0000000..ca1c4af --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalog.java @@ -0,0 +1,21 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +public interface CreateCatalog extends UseCase { + sealed interface Input { + record With(String name, UUID brandUuid) implements Input {} + } + + sealed interface Output { + record Created(CatalogOutput value) implements Output {} + + record NotCreated(InvalidFields reasons) implements Output, Failure {} + + record CatalogOutput(UUID uuid, Slug slug, String name, UUID brandUuid) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalogImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalogImpl.java new file mode 100644 index 0000000..3ea8a29 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalogImpl.java @@ -0,0 +1,69 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.usecase.catalogs.CreateCatalog.Output.CatalogOutput; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +@ApplicationScoped +public class CreateCatalogImpl implements CreateCatalog { + private final Slugifyier slugifyierService; + private final CatalogRepository catalogRepository; + private final BrandRepository brandRepository; + + @Inject + CreateCatalogImpl( + Slugifyier slugifyierService, + CatalogRepository catalogRepository, + BrandRepository brandRepository) { + this.slugifyierService = slugifyierService; + this.catalogRepository = catalogRepository; + this.brandRepository = brandRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.With inputCase -> run(inputCase); + }; + } + + private Output run(Input.With inputCase) { + Slug slug; + var name = inputCase.name(); + var brandUuid = inputCase.brandUuid(); + + if (name.isBlank()) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("name", "ne peut être vide"))); + } + + try { + slug = this.slugifyierService.slugify(name); + } catch (IllegalArgumentException e) { + return new Output.NotCreated( + InvalidFields.of(InvalidField.of("name", "ne peut être simplifié"))); + } + + if (catalogRepository.exists(slug)) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("name", "existe déjà"))); + } + if (!brandRepository.exists(brandUuid)) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("brand", "n'existe pas"))); + } + + var catalog = new Catalog(slug, name, brandUuid); + catalogRepository.persist(catalog); + + var catalogOutput = + new CatalogOutput( + catalog.getUuid(), catalog.getSlug(), catalog.getName(), catalog.getBrandUuid()); + + return new Output.Created(catalogOutput); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalog.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalog.java new file mode 100644 index 0000000..3f1c9bd --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalog.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Slug; + +public interface GetCatalog extends UseCase { + sealed interface Input { + record ForSlug(Slug slug) implements Input {} + } + + sealed interface Output { + record Found(CatalogOutput catalog) implements Output {} + + record NoSuchCatalog() implements Output, Failure {} + + record CatalogOutput(UUID uuid, Slug slug, String name, UUID brandUuid) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalogImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalogImpl.java new file mode 100644 index 0000000..4917ecb --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalogImpl.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog.Input.ForSlug; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog.Output.CatalogOutput; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog.Output.NoSuchCatalog; + +@ApplicationScoped +public class GetCatalogImpl implements GetCatalog { + final CatalogRepository catalogRepository; + + @Inject + GetCatalogImpl(CatalogRepository catalogRepository) { + this.catalogRepository = catalogRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case ForSlug inputCase -> run(inputCase); + }; + } + + private Output run(ForSlug inputCase) { + var catalogSlug = inputCase.slug(); + var catalog = this.catalogRepository.findBySlug(catalogSlug).orElse(null); + + if (catalog == null) { + return new NoSuchCatalog(); + } + + var catalogOutput = + new CatalogOutput( + catalog.getUuid(), catalog.getSlug(), catalog.getName(), catalog.getBrandUuid()); + + return new Output.Found(catalogOutput); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogs.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogs.java new file mode 100644 index 0000000..7520bb2 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogs.java @@ -0,0 +1,21 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public interface ListCatalogs extends UseCase { + sealed interface Input { + record WithPagination(Pagination pagination) implements Input {} + + record WithPaginationForBrand(UUID brand, Pagination pagination) implements Input {} + } + + sealed interface Output { + record PaginatedCatalogs(PaginatedData catalogList) implements Output {} + + record CatalogOutput(UUID uuid, Slug slug, String name, UUID brandUuid) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogsImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogsImpl.java new file mode 100644 index 0000000..7e96214 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogsImpl.java @@ -0,0 +1,46 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.usecase.catalogs.ListCatalogs.Input.WithPagination; +import tech.pegase.z3.domain.usecase.catalogs.ListCatalogs.Input.WithPaginationForBrand; +import tech.pegase.z3.domain.usecase.catalogs.ListCatalogs.Output.CatalogOutput; +import tech.pegase.z3.domain.usecase.catalogs.ListCatalogs.Output.PaginatedCatalogs; + +@ApplicationScoped +public class ListCatalogsImpl implements ListCatalogs { + private final CatalogRepository catalogRepository; + + @Inject + public ListCatalogsImpl(CatalogRepository catalogRepository) { + this.catalogRepository = catalogRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case WithPagination caseInput -> run(caseInput); + case WithPaginationForBrand caseInput -> run(caseInput); + }; + } + + private Output run(WithPagination input) { + var paginatedCatalogOutputs = catalogRepository.findAll(input.pagination()).map(this::toOutput); + + return new PaginatedCatalogs(paginatedCatalogOutputs); + } + + private Output run(WithPaginationForBrand input) { + var paginatedCatalogOutputs = + catalogRepository.findAllForBrand(input.brand(), input.pagination()).map(this::toOutput); + + return new PaginatedCatalogs(paginatedCatalogOutputs); + } + + private CatalogOutput toOutput(Catalog catalog) { + return new CatalogOutput( + catalog.getUuid(), catalog.getSlug(), catalog.getName(), catalog.getBrandUuid()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalog.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalog.java new file mode 100644 index 0000000..3b43ec2 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalog.java @@ -0,0 +1,17 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Slug; + +public interface RemoveCatalog extends UseCase { + sealed interface Input { + record ForSlug(Slug slug) implements Input {} + } + + sealed interface Output { + record Removed() implements Output {} + + record NoSuchCatalog() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalogImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalogImpl.java new file mode 100644 index 0000000..90d7f3c --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalogImpl.java @@ -0,0 +1,64 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.UUID; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.usecase.catalogs.RemoveCatalog.Input.ForSlug; +import tech.pegase.z3.domain.usecase.catalogs.RemoveCatalog.Output.Removed; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@ApplicationScoped +public class RemoveCatalogImpl implements RemoveCatalog { + private final CatalogRepository catalogRepository; + private final LinkRepository linkRepository; + + @Inject + RemoveCatalogImpl(CatalogRepository catalogRepository, LinkRepository linkRepository) { + this.catalogRepository = catalogRepository; + this.linkRepository = linkRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case ForSlug inputCase -> run(inputCase); + }; + } + + private Output run(ForSlug inputCase) { + var slug = inputCase.slug(); + var catalog = catalogRepository.findBySlug(slug).orElse(null); + + if (catalog == null) { + return new Output.NoSuchCatalog(); + } + + removeCatalogFromLinks(catalog.getUuid()); + + // Delete is an idempotent action. + catalogRepository.remove(slug); + return new Removed(); + } + + private void removeCatalogFromLinks(UUID catalogUuid) { + var i = 0; + final var perPage = 50; + PaginatedData paginatedLinks; + + do { + paginatedLinks = + linkRepository.findAllForCatalog(Pagination.fromOffsetAndLimit(i, perPage), catalogUuid); + paginatedLinks.items().forEach(this::removeCatalogFromLink); + i += 50; + } while (!paginatedLinks.items().isEmpty()); + } + + private void removeCatalogFromLink(Link link) { + link.setCatalog(null); + linkRepository.update(link); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalog.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalog.java new file mode 100644 index 0000000..65d234d --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalog.java @@ -0,0 +1,23 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +public interface UpdateCatalog extends UseCase { + sealed interface Input { + record ForSlug(Slug slug, String name) implements Input {} + } + + sealed interface Output { + record Updated(CatalogOutput value) implements Output {} + + record NotUpdated(InvalidFields reasons) implements Output, Failure {} + + record NoSuchCatalog() implements Output, Failure {} + + record CatalogOutput(UUID uuid, Slug slug, String name, UUID brandUuid) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalogImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalogImpl.java new file mode 100644 index 0000000..fd9d76d --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalogImpl.java @@ -0,0 +1,75 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.usecase.catalogs.UpdateCatalog.Input.ForSlug; +import tech.pegase.z3.domain.usecase.catalogs.UpdateCatalog.Output.CatalogOutput; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +@ApplicationScoped +public class UpdateCatalogImpl implements UpdateCatalog { + private final Slugifyier slugifyierService; + private final CatalogRepository catalogRepository; + + @Inject + UpdateCatalogImpl(Slugifyier slugifyierService, CatalogRepository catalogRepository) { + this.slugifyierService = slugifyierService; + this.catalogRepository = catalogRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case ForSlug inputCase -> run(inputCase); + }; + } + + private Output run(ForSlug inputCase) { + var slug = inputCase.slug(); + var name = inputCase.name(); + + if (name.isBlank()) { + return new Output.NotUpdated(InvalidFields.of(InvalidField.of("name", "ne peut être vide"))); + } + + // Find the catalogUuid we need to update + var catalog = catalogRepository.findBySlug(slug).orElse(null); + + if (catalog == null) { + return new Output.NoSuchCatalog(); + } + + // Generate new slug + Slug newSlug; + try { + newSlug = this.slugifyierService.slugify(name); + } catch (IllegalArgumentException e) { + return new Output.NotUpdated( + InvalidFields.of(InvalidField.of("name", "ne peut être simplifié"))); + } + + // Verify is new slug already exists + if (catalogRepository.exists(newSlug)) { + return new Output.NotUpdated(InvalidFields.of(InvalidField.of("name", "existe déjà"))); + } + + catalog.setName(name); + catalog.setSlug(newSlug); + + catalogRepository.update(catalog); + + var catalogOutput = toOutput(catalog); + + return new Output.Updated(catalogOutput); + } + + private CatalogOutput toOutput(Catalog catalog) { + return new CatalogOutput( + catalog.getUuid(), catalog.getSlug(), catalog.getName(), catalog.getBrandUuid()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/package-info.java new file mode 100644 index 0000000..7aaf2cb --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/catalogs/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.usecase.catalogs; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/CreateLink.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/CreateLink.java new file mode 100644 index 0000000..a1d6ad1 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/CreateLink.java @@ -0,0 +1,49 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.time.ZonedDateTime; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; +import tech.pegase.z3.shared.valueobject.Source; + +public interface CreateLink extends UseCase { + sealed interface Input { + record WithUrl(CommonLinkInput values) implements Input {} + + record WithSourceAndUrl(Source source, CommonLinkInput values) implements Input {} + + record CommonLinkInput( + Target target, + boolean isExternal, + @Nullable UUID brandUuid, + @Nullable UUID tagUuid, + @Nullable UUID catalogUuid) {} + } + + sealed interface Output { + record Created(LinkOutput link) implements Output {} + + record NotCreated(InvalidFields reasons) implements Output, Failure {} + + record LinkOutput( + Source source, + Target target, + boolean isExternal, + LinkValidity validity, + int visits, + @Nullable UUID brandUuid, + @Nullable LinkTag tag, + @Nullable LinkCatalog catalog, + ZonedDateTime createdAt, + @Nullable ZonedDateTime updatedAt) { + public record LinkTag(UUID uuid, String name, String color) {} + + public record LinkCatalog(Slug slug, String name) {} + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/CreateLinkImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/CreateLinkImpl.java new file mode 100644 index 0000000..8e40ca2 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/CreateLinkImpl.java @@ -0,0 +1,159 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.port.service.RandomSourceGenerator; +import tech.pegase.z3.domain.service.VerifyLinkService; +import tech.pegase.z3.domain.usecase.links.CreateLink.Input.CommonLinkInput; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; +import tech.pegase.z3.shared.valueobject.Source; + +@ApplicationScoped +public class CreateLinkImpl implements CreateLink { + private final RandomSourceGenerator randomSourceGenerator; + private final LinkRepository linkRepository; + private final BrandRepository brandRepository; + private final CatalogRepository catalogRepository; + private final TagRepository tagRepository; + private final VerifyLinkService verifyLinkService; + + public CreateLinkImpl( + RandomSourceGenerator randomSourceGenerator, + LinkRepository linkRepository, + BrandRepository brandRepository, + CatalogRepository catalogRepository, + TagRepository tagRepository, + VerifyLinkService verifyLinkService) { + this.randomSourceGenerator = randomSourceGenerator; + this.linkRepository = linkRepository; + this.brandRepository = brandRepository; + this.catalogRepository = catalogRepository; + this.tagRepository = tagRepository; + this.verifyLinkService = verifyLinkService; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.WithUrl inputCase -> run(inputCase); + case Input.WithSourceAndUrl inputCase -> run(inputCase); + }; + } + + private Output run(Input.WithUrl withUrl) { + var source = tryToGenerateSource(); + var values = withUrl.values(); + + if (source == null) { + return new Output.NotCreated( + InvalidFields.of(InvalidField.of("source", "n'a pas pu être generé"))); + } + + return createNewLink(source, values); + } + + private Output run(Input.WithSourceAndUrl withSourceAndUrl) { + var source = withSourceAndUrl.source(); + var values = withSourceAndUrl.values(); + + return createNewLink(source, values); + } + + private Output createNewLink(Source source, CommonLinkInput values) { + var target = values.target(); + var isExternal = values.isExternal(); + var brandUuid = values.brandUuid(); + var catalogUuid = values.catalogUuid(); + var tagUuid = values.tagUuid(); + + var catalog = + (catalogUuid != null + ? catalogRepository.findByUuid(catalogUuid) + : Optional.empty()) + .orElse(null); + var tag = + (tagUuid != null ? tagRepository.findByUuid(tagUuid) : Optional.empty()).orElse(null); + + if (brandUuid != null && !this.brandRepository.exists(brandUuid)) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("brand", "n'existe pas"))); + } + if (catalogUuid != null && catalog == null) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("catalog", "n'existe pas"))); + } + if (tagUuid != null && tag == null) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("tag", "n'existe pas"))); + } + if (linkRepository.exists(source)) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("source", "existe déjà"))); + } + + var link = new Link(source, target, isExternal, brandUuid, tagUuid, catalogUuid); + + linkRepository.persist(link); + verifyLinkService.verifyAndPersistValidity(link); + + var linkOutput = getLinkOutputFrom(link, tag, catalog); + + return new Output.Created(linkOutput); + } + + private Output.LinkOutput getLinkOutputFrom( + Link link, @Nullable Tag tag, @Nullable Catalog catalog) { + var linkTag = linkTagFrom(tag); + var linkCatalog = linkCatalogFrom(catalog); + + return new Output.LinkOutput( + Source.of(link.getSource().value()), + Target.of(link.getTarget().value()), + link.isExternal(), + link.getValidity(), + link.getVisits(), + link.getBrand(), + linkTag, + linkCatalog, + link.getCreatedAt(), + link.getUpdatedAt()); + } + + private Output.LinkOutput.@Nullable LinkCatalog linkCatalogFrom(@Nullable Catalog catalog) { + if (catalog == null) { + return null; + } + + return new Output.LinkOutput.LinkCatalog(catalog.getSlug(), catalog.getName()); + } + + private Output.LinkOutput.@Nullable LinkTag linkTagFrom(@Nullable Tag tag) { + if (tag == null) { + return null; + } + + return new Output.LinkOutput.LinkTag(tag.getUuid(), tag.getName(), tag.getColor().toString()); + } + + private @Nullable Source tryToGenerateSource() { + var maxTries = 10; + Source newSource; + Source source = null; + + do { + newSource = randomSourceGenerator.generate(); + if (!linkRepository.exists(newSource)) { + maxTries = 0; + source = newSource; + } + } while (--maxTries > 0); + + return source; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ExportLinks.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ExportLinks.java new file mode 100644 index 0000000..80765c6 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ExportLinks.java @@ -0,0 +1,48 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.List; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public interface ExportLinks extends UseCase { + sealed interface Input { + + record PageForNonBranded(Pagination pagination) implements Input {} + + record PageForBrand(Slug brandSlug, Pagination pagination) implements Input {} + + record PageForCatalog(Slug catalogSlug, Pagination pagination) implements Input {} + + record Pagination(int page, int perPage) { + public Pagination { + if (page < 0) { + throw new IllegalStateException("Page must be a integer greater than 0"); + } + if (perPage < 1) { + throw new IllegalStateException("Per page must be a integer greater than 1"); + } + } + } + } + + sealed interface Output { + record Page(List rows) implements Output {} + + record NoMoreToExport() implements Output {} + + record Failed(String reason) implements Output, Failure {} + + record LinkOutput( + @Nullable Fqdn host, + Source source, + Target target, + boolean isExternal, + LinkValidity validity) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ExportLinksImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ExportLinksImpl.java new file mode 100644 index 0000000..88cde8b --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ExportLinksImpl.java @@ -0,0 +1,155 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.jboss.logging.Logger; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.usecase.links.ExportLinks.Output.LinkOutput; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@ApplicationScoped +public class ExportLinksImpl implements ExportLinks { + private static final Logger logger = Logger.getLogger(ExportLinksImpl.class.getName()); + private final LinkRepository linkRepository; + private final BrandRepository brandRepository; + private final CatalogRepository catalogRepository; + + public ExportLinksImpl( + LinkRepository linkRepository, + BrandRepository brandRepository, + CatalogRepository catalogRepository) { + this.linkRepository = linkRepository; + this.brandRepository = brandRepository; + this.catalogRepository = catalogRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.PageForBrand inputCase -> run(inputCase); + case Input.PageForCatalog inputCase -> run(inputCase); + case Input.PageForNonBranded inputCase -> run(inputCase); + }; + } + + private Output run(Input.PageForBrand inputCase) { + var brandSlug = inputCase.brandSlug(); + var pagination = inputCase.pagination(); + var brand = brandRepository.findBySlug(brandSlug).orElse(null); + + if (brand == null) { + logger.debug("Brand not found: %s".formatted(brandSlug)); + return new Output.Failed("Brand slug '%s' does not exists".formatted(brandSlug)); + } + + var pageContent = + generatePageContentForBrand(brand.getUuid(), pagination.page(), pagination.perPage()); + + if (pageContent.isEmpty()) { + logger.debug("No more pages to export"); + return new Output.NoMoreToExport(); + } + + return new Output.Page(pageContent); + } + + private Output run(Input.PageForCatalog inputCase) { + var catalogSlug = inputCase.catalogSlug(); + var pagination = inputCase.pagination(); + var catalog = catalogRepository.findBySlug(catalogSlug).orElse(null); + + if (catalog == null) { + logger.debug("Catalog not found: %s".formatted(catalogSlug)); + return new Output.Failed("Catalog uuid '%s' does not exists".formatted(catalogSlug)); + } + + var pageContent = + generatePageContentForCatalog(catalog.getUuid(), pagination.page(), pagination.perPage()); + + if (pageContent.isEmpty()) { + logger.debug("No more pages to export"); + return new Output.NoMoreToExport(); + } + + return new Output.Page(pageContent); + } + + private Output run(Input.PageForNonBranded inputCase) { + var pagination = inputCase.pagination(); + var pageContent = generatePageContentForBrand(null, pagination.page(), pagination.perPage()); + + if (pageContent.isEmpty()) { + logger.debug("No more pages to export"); + return new Output.NoMoreToExport(); + } + + return new Output.Page(pageContent); + } + + private List paginatedLinksToOutput( + @Nullable Fqdn host, PaginatedData paginatedLinks) { + List output = new ArrayList<>(); + if (paginatedLinks.items().isEmpty()) { + return output; + } + + for (var link : paginatedLinks.items()) { + var linkOutput = + new LinkOutput( + host, link.getSource(), link.getTarget(), link.isExternal(), link.getValidity()); + + output.add(linkOutput); + } + + return output; + } + + private List generatePageContentForBrand( + @Nullable UUID brandUuid, int page, int perPage) { + + var fqdn = getBrandFirstFqdn(brandUuid); + var paginatedLinks = + linkRepository.findAllForBrand( + Pagination.fromOffsetAndLimit(page * perPage, perPage), brandUuid); + + return paginatedLinksToOutput(fqdn, paginatedLinks); + } + + private List generatePageContentForCatalog( + @Nullable UUID catalogUuid, int page, int perPage) { + + var brandUuid = getBrandUuidFromCatalogUuid(catalogUuid); + var fqdn = getBrandFirstFqdn(brandUuid); + var paginatedLinks = + linkRepository.findAllForCatalog( + Pagination.fromOffsetAndLimit(page * perPage, perPage), catalogUuid); + + return paginatedLinksToOutput(fqdn, paginatedLinks); + } + + private @Nullable UUID getBrandUuidFromCatalogUuid(@Nullable UUID catalogUuid) { + return Optional.ofNullable(catalogUuid) + .map(catalogRepository::findByUuid) + .flatMap(optionalCatalog -> optionalCatalog) + .map(Catalog::getBrandUuid) + .orElse(null); + } + + private @Nullable Fqdn getBrandFirstFqdn(@Nullable UUID brandUuid) { + return Optional.ofNullable(brandUuid) + .map(brandRepository::findByUuid) + .flatMap(optionalBrand -> optionalBrand) + .flatMap(brand -> brand.getFqdns().stream().findFirst()) + .orElse(null); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/GetLink.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/GetLink.java new file mode 100644 index 0000000..1056aaa --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/GetLink.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.time.ZonedDateTime; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public interface GetLink extends UseCase { + sealed interface Input { + record ForSource(Source source) implements GetLink.Input {} + } + + sealed interface Output { + record Found(LinkOutput link) implements GetLink.Output {} + + record NoSuchSource() implements GetLink.Output, Failure {} + + record LinkOutput( + Source source, + Target target, + boolean isExternal, + LinkValidity validity, + int visits, + @Nullable UUID brandUuid, + LinkOutput.@Nullable LinkTag tag, + LinkOutput.@Nullable LinkCatalog catalog, + ZonedDateTime createdAt, + @Nullable ZonedDateTime updatedAt) { + + public record LinkTag(UUID uuid, String name, String color) {} + + public record LinkCatalog(Slug slug, String name) {} + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/GetLinkImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/GetLinkImpl.java new file mode 100644 index 0000000..456f42e --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/GetLinkImpl.java @@ -0,0 +1,101 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.links.GetLink.Input.ForSource; +import tech.pegase.z3.domain.usecase.links.GetLink.Output.LinkOutput; +import tech.pegase.z3.domain.usecase.links.GetLink.Output.NoSuchSource; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +@ApplicationScoped +public class GetLinkImpl implements GetLink { + private final LinkRepository linkRepository; + private final CatalogRepository catalogRepository; + private final TagRepository tagRepository; + + @Inject + public GetLinkImpl( + LinkRepository linkRepository, + CatalogRepository catalogRepository, + TagRepository tagRepository) { + this.linkRepository = linkRepository; + this.catalogRepository = catalogRepository; + this.tagRepository = tagRepository; + } + + public Output.LinkOutput linkOutputFrom(Link link, @Nullable Tag tag, @Nullable Catalog catalog) { + var linkTag = linkTagOf(tag); + var linkCatalog = linkCatalogOf(catalog); + + return new Output.LinkOutput( + Source.of(link.getSource().value()), + Target.of(link.getTarget().value()), + link.isExternal(), + link.getValidity(), + link.getVisits(), + link.getBrand(), + linkTag, + linkCatalog, + link.getCreatedAt(), + link.getUpdatedAt()); + } + + @Override + public GetLink.Output run(GetLink.Input input) { + return switch (input) { + case ForSource inputCase -> run(inputCase); + }; + } + + private Output run(ForSource inputCase) { + var source = inputCase.source(); + var link = linkRepository.findBySource(source).orElse(null); + Tag tag; + Catalog catalog; + + if (link == null) { + return new NoSuchSource(); + } + + tag = link.getTag() != null ? tagRepository.findByUuid(link.getTag()).orElseThrow() : null; + catalog = + link.getCatalog() != null + ? catalogRepository.findByUuid(link.getCatalog()).orElseThrow() + : null; + + var linkOutput = linkOutputFrom(link, tag, catalog); + + return new Output.Found(linkOutput); + } + + Output.LinkOutput.@Nullable LinkCatalog linkCatalogOf(@Nullable Catalog catalog) { + if (catalog == null) { + return null; + } + + var slug = catalog.getSlug(); + var name = catalog.getName(); + + return new LinkOutput.LinkCatalog(slug, name); + } + + Output.LinkOutput.@Nullable LinkTag linkTagOf(@Nullable Tag tag) { + if (tag == null) { + return null; + } + + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + + return new LinkOutput.LinkTag(uuid, name, color.toString()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ImportLinks.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ImportLinks.java new file mode 100644 index 0000000..a798836 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ImportLinks.java @@ -0,0 +1,31 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.annotation.Nullable; +import java.util.List; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; + +public interface ImportLinks extends UseCase { + sealed interface Input { + record ImportForBrand(UUID brandUuid, @Nullable UUID tagUuid, List lines) + implements Input {} + + record ImportForCatalog(UUID catalogUuid, @Nullable UUID tagUuid, List lines) + implements Input {} + + record ImportLine(String source, String url, boolean isExternal) {} + } + + sealed interface Output { + record ImportResults(List failedImports, Integer importedLinks) + implements Output { + public record FailedImport( + int line, String reason, String source, String target, boolean isExternal) {} + } + + record NoSuchBrand() implements Output, Failure {} + + record NoSuchCatalog() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ImportLinksImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ImportLinksImpl.java new file mode 100644 index 0000000..3a42f05 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ImportLinksImpl.java @@ -0,0 +1,191 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.jboss.logging.Logger; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.port.service.RandomSourceGenerator; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Input.ImportLine; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Output.ImportResults; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Output.NoSuchBrand; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Output.NoSuchCatalog; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +@ApplicationScoped +public class ImportLinksImpl implements ImportLinks { + private static final Logger logger = Logger.getLogger(ImportLinksImpl.class); + private final RandomSourceGenerator randomSourceGenerator; + private final BrandRepository brandRepository; + private final CatalogRepository catalogRepository; + private final LinkRepository linkRepository; + private final TagRepository tagRepository; + private final Slugifyier slugifyier; + + public ImportLinksImpl( + RandomSourceGenerator randomSourceGenerator, + Slugifyier slugifyier, + LinkRepository linkRepository, + TagRepository tagRepository, + BrandRepository brandRepository, + CatalogRepository catalogRepository) { + this.randomSourceGenerator = randomSourceGenerator; + this.linkRepository = linkRepository; + this.tagRepository = tagRepository; + this.slugifyier = slugifyier; + this.brandRepository = brandRepository; + this.catalogRepository = catalogRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.ImportForBrand inputCase -> run(inputCase); + case Input.ImportForCatalog inputCase -> run(inputCase); + }; + } + + private Output run(Input.ImportForCatalog inputCase) { + List failedImports = new ArrayList<>(); + Map importedSources = new HashMap<>(); + + var catalogUuid = inputCase.catalogUuid(); + var catalog = catalogRepository.findByUuid(catalogUuid).orElse(null); + + if (catalog == null) { + return new NoSuchCatalog(); + } + + var brandUuid = catalog.getBrandUuid(); + var tagUuid = + Optional.ofNullable(inputCase.tagUuid()).filter(tagRepository::exists).orElse(null); + + var i = 1; + for (var line : inputCase.lines()) { + try { + var source = toSource(line.source()); + var target = toTarget(line.url()); + var isExternal = line.isExternal(); + + if (importedSources.containsKey(source)) { + var previousSourceLine = importedSources.get(source); + throw new IllegalStateException( + "Le lien '%s' est déjà présent à la ligne %d et ne sera donc pas importé" + .formatted(source.value(), previousSourceLine)); + } + + importLine(source, target, isExternal, brandUuid, tagUuid, catalogUuid); + + importedSources.put(source, i); + } catch (IllegalStateException e) { + var failedImport = failedImportFrom(i, e.getMessage(), line); + + failedImports.add(failedImport); + logger.debug(e.getMessage(), e); + } finally { + i++; + } + } + + return new ImportResults(failedImports, i - 1 - failedImports.size()); + } + + private Output run(Input.ImportForBrand inputCase) { + List failedImports = new ArrayList<>(); + Map importedSources = new HashMap<>(); + + var brandUuid = inputCase.brandUuid(); + var tagUuid = + Optional.ofNullable(inputCase.tagUuid()).filter(tagRepository::exists).orElse(null); + + if (!brandRepository.exists(brandUuid)) { + return new NoSuchBrand(); + } + + var i = 1; + for (var line : inputCase.lines()) { + try { + var source = toSource(line.source()); + var target = toTarget(line.url()); + var isExternal = line.isExternal(); + + if (importedSources.containsKey(source)) { + var previousSourceLine = importedSources.get(source); + throw new IllegalStateException( + "Le lien '%s' est déjà présent à la ligne %d et ne sera donc pas importé" + .formatted(source.value(), previousSourceLine)); + } + + importLine(source, target, isExternal, brandUuid, tagUuid, null); + + importedSources.put(source, i); + } catch (IllegalStateException e) { + var failedImport = failedImportFrom(i, e.getMessage(), line); + + failedImports.add(failedImport); + logger.debug(e.getMessage(), e); + } finally { + i++; + } + } + + return new ImportResults(failedImports, i - 1 - failedImports.size()); + } + + public Output.ImportResults.FailedImport failedImportFrom( + int line, String reason, ImportLine importLine) { + return new Output.ImportResults.FailedImport( + line, reason, importLine.source(), importLine.url(), importLine.isExternal()); + } + + private void importLine( + Source source, + Target target, + Boolean isExternal, + UUID brandUuid, + @Nullable UUID tagUuid, + @Nullable UUID catalogUuid) + throws IllegalStateException { + + var link = linkRepository.findBySource(source).orElse(null); + + if (link != null && !brandUuid.equals(link.getBrand())) { + throw new IllegalStateException("Ne peut écraser le lien d'une autre enseigne"); + } + + if (link == null) { + link = new Link(source, target, isExternal, brandUuid, tagUuid, catalogUuid); + } else { + link.setTarget(target); + link.setIsExternal(isExternal); + link.setTag(tagUuid); + link.setCatalog(catalogUuid); + link.setUpdatedAt(ZonedDateTime.now()); + } + + linkRepository.updateOrPersist(link); + } + + private Source toSource(String value) { + return Source.of( + !value.isBlank() + ? slugifyier.slugify(value).value() + : randomSourceGenerator.generate().value()); + } + + private Target toTarget(String value) { + return Target.of(value); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ListLinks.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ListLinks.java new file mode 100644 index 0000000..aa07508 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ListLinks.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.time.ZonedDateTime; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; +import tech.pegase.z3.shared.valueobject.Source; + +public interface ListLinks extends UseCase { + sealed interface Input { + record WithPaginationSorted(Pagination pagination, LinkFiltering filters, LinkOrdering ordering) + implements Input {} + + record WithPaginationAndSearchingAllSorted( + Pagination pagination, + LinkFiltering filters, + LinkOrdering ordering, + LinkSearching searching) + implements Input {} + } + + sealed interface Output { + record PaginatedLinks(PaginatedData linkList) implements Output {} + + record LinkOutput( + Source source, + Target target, + boolean isExternal, + LinkValidity validity, + int visits, + @Nullable UUID brandUuid, + LinkOutput.@Nullable LinkTag tag, + LinkOutput.@Nullable LinkCatalog catalog, + ZonedDateTime createdAt, + @Nullable ZonedDateTime updatedAt) { + + public record LinkTag(UUID uuid, String name, String color) {} + + public record LinkCatalog(Slug slug, String name) {} + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ListLinksImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ListLinksImpl.java new file mode 100644 index 0000000..3e119c8 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/ListLinksImpl.java @@ -0,0 +1,124 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.links.ListLinks.Input.WithPaginationAndSearchingAllSorted; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; +import tech.pegase.z3.shared.valueobject.Source; + +@ApplicationScoped +public class ListLinksImpl implements ListLinks { + private final LinkRepository linkRepository; + private final CatalogRepository catalogRepository; + private final TagRepository tagRepository; + + @Inject + ListLinksImpl( + LinkRepository linkRepository, + CatalogRepository catalogRepository, + TagRepository tagRepository) { + this.linkRepository = linkRepository; + this.catalogRepository = catalogRepository; + this.tagRepository = tagRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.WithPaginationSorted inputCase -> run(inputCase); + case WithPaginationAndSearchingAllSorted inputCase -> run(inputCase); + }; + } + + private Output run(Input.WithPaginationSorted input) { + var pagination = input.pagination(); + var filters = input.filters(); + var ordering = input.ordering(); + + var paginatedLinkOutputs = + linkRepository.findAll(pagination, filters, ordering).map(this::linkMapper); + + return new Output.PaginatedLinks(paginatedLinkOutputs); + } + + private Output run(WithPaginationAndSearchingAllSorted input) { + var pagination = input.pagination(); + var filters = input.filters(); + var ordering = input.ordering(); + + var searching = input.searching(); + var linkSearching = LinkSearching.of(searching.field(), searching.value()); + + var paginatedLinkOutputs = + linkRepository.findAll(pagination, filters, ordering, linkSearching).map(this::linkMapper); + + return new Output.PaginatedLinks(paginatedLinkOutputs); + } + + private Output.LinkOutput.@Nullable LinkTag linkTagFrom(@Nullable Tag tag) { + if (tag == null) { + return null; + } + + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + + return new Output.LinkOutput.LinkTag(uuid, name, color.toString()); + } + + private Output.LinkOutput.@Nullable LinkCatalog linkCatalogFrom(@Nullable Catalog catalog) { + if (catalog == null) { + return null; + } + + var slug = catalog.getSlug(); + var name = catalog.getName(); + + return new Output.LinkOutput.LinkCatalog(slug, name); + } + + private Output.LinkOutput linkOutputFrom( + Link link, @Nullable Tag tag, @Nullable Catalog catalog) { + var linkTag = linkTagFrom(tag); + var linkCatalog = linkCatalogFrom(catalog); + + return new Output.LinkOutput( + Source.of(link.getSource().value()), + Target.of(link.getTarget().value()), + link.isExternal(), + link.getValidity(), + link.getVisits(), + link.getBrand(), + linkTag, + linkCatalog, + link.getCreatedAt(), + link.getUpdatedAt()); + } + + private Output.LinkOutput linkMapper(Link link) { + var tag = getTag(link.getTag()); + var catalog = getCatalog(link.getCatalog()); + + return linkOutputFrom(link, tag, catalog); + } + + private @Nullable Tag getTag(@Nullable UUID uuid) { + return (uuid != null ? tagRepository.findByUuid(uuid) : Optional.empty()).orElse(null); + } + + private @Nullable Catalog getCatalog(@Nullable UUID uuid) { + return (uuid != null ? catalogRepository.findByUuid(uuid) : Optional.empty()) + .orElse(null); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/RedirectLink.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/RedirectLink.java new file mode 100644 index 0000000..6767846 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/RedirectLink.java @@ -0,0 +1,23 @@ +package tech.pegase.z3.domain.usecase.links; + +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public interface RedirectLink extends UseCase { + sealed interface Input { + record ResolveWith(Fqdn defaultFqdn, Fqdn actualFqdn, Source source) implements Input {} + } + + sealed interface Output { + record Found(LinkOutput link) implements Output {} + + record WrongFqdn() implements Output, Failure {} + + record NoSuchSource() implements Output, Failure {} + + record LinkOutput(Source source, Target target) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/RedirectLinkImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/RedirectLinkImpl.java new file mode 100644 index 0000000..771198d --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/RedirectLinkImpl.java @@ -0,0 +1,82 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import org.jboss.logging.Logger; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.service.VerifyLinkService; + +@ApplicationScoped +public class RedirectLinkImpl implements RedirectLink { + private final Logger logger = Logger.getLogger(RedirectLinkImpl.class); + private final LinkRepository linkRepository; + private final BrandRepository brandRepository; + private final VerifyLinkService verifyLinkService; + + @Inject + public RedirectLinkImpl( + LinkRepository linkRepository, + BrandRepository brandRepository, + VerifyLinkService verifyLinkService) { + this.linkRepository = linkRepository; + this.brandRepository = brandRepository; + this.verifyLinkService = verifyLinkService; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.ResolveWith inputCase -> run(inputCase); + }; + } + + private Output run(Input.ResolveWith resolveWith) { + var source = resolveWith.source(); + var defaultFqdn = resolveWith.defaultFqdn(); + var actualFqdn = resolveWith.actualFqdn(); + var link = linkRepository.findBySource(source).orElse(null); + Brand brand; + + if (link == null) { + return new Output.NoSuchSource(); + } + + brand = Optional.ofNullable(link.getBrand()).flatMap(brandRepository::findByUuid).orElse(null); + + if (brand != null + && !defaultFqdn.equals(actualFqdn) + && !brand.getFqdns().contains(actualFqdn)) { + logger.debug( + "Current request Fqdn '%s' does not match any of the resolved link brand '%s' fqdns" + .formatted(actualFqdn.getValue(), brand.getName())); + return new Output.WrongFqdn(); + } + + if (brand == null && !defaultFqdn.equals(actualFqdn)) { + logger.debug( + "Current request Fqdn '%s' does not match the default fqdn '%s'" + .formatted(actualFqdn.getValue(), defaultFqdn.getValue())); + return new Output.WrongFqdn(); + } + + link.incVisits(); + linkRepository.update(link); + + verifyLinkService.verifyAndPersistValidity(link); + + var linkOutput = linkOutputFrom(link); + + return new Output.Found(linkOutput); + } + + Output.LinkOutput linkOutputFrom(Link link) { + var source = link.getSource(); + var target = link.getTarget(); + + return new Output.LinkOutput(source, target); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/UpdateLink.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/UpdateLink.java new file mode 100644 index 0000000..4926b7f --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/UpdateLink.java @@ -0,0 +1,49 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.time.ZonedDateTime; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; +import tech.pegase.z3.shared.valueobject.Source; + +public interface UpdateLink extends UseCase { + sealed interface Input { + record ForSource( + Source source, + Target target, + boolean isExternal, + @Nullable UUID tagUuid, + @Nullable Slug catalogSlug) + implements Input {} + } + + sealed interface Output { + record Updated(LinkOutput link) implements Output {} + + record NotUpdated(InvalidFields reasons) implements Output, Failure {} + + record NoSuchSource() implements Output, Failure {} + + record LinkOutput( + Source source, + Target target, + boolean isExternal, + LinkValidity validity, + int visits, + ZonedDateTime createdAt, + @Nullable ZonedDateTime updatedAt, + @Nullable UUID brandUuid, + LinkOutput.@Nullable LinkTag tag, + LinkOutput.@Nullable LinkCatalog catalog) { + + public record LinkTag(UUID uuid, String name, String color) {} + + public record LinkCatalog(Slug slug, String name) {} + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/UpdateLinkImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/UpdateLinkImpl.java new file mode 100644 index 0000000..d4e73cd --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/UpdateLinkImpl.java @@ -0,0 +1,126 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.service.VerifyLinkService; +import tech.pegase.z3.domain.usecase.links.UpdateLink.Input.ForSource; +import tech.pegase.z3.domain.usecase.links.UpdateLink.Output.LinkOutput; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; +import tech.pegase.z3.shared.valueobject.Source; + +@ApplicationScoped +public class UpdateLinkImpl implements UpdateLink { + private final LinkRepository linkRepository; + private final TagRepository tagRepository; + private final CatalogRepository catalogRepository; + private final VerifyLinkService verifyLinkService; + + @Inject + public UpdateLinkImpl( + LinkRepository linkRepository, + TagRepository tagRepository, + CatalogRepository catalogRepository, + VerifyLinkService verifyLinkService) { + this.linkRepository = linkRepository; + this.tagRepository = tagRepository; + this.catalogRepository = catalogRepository; + this.verifyLinkService = verifyLinkService; + } + + private Output.LinkOutput linkOutputFrom( + Link link, @Nullable Tag tag, @Nullable Catalog catalog) { + var linkCatalog = linkCatalogFrom(catalog); + var linkTag = linkTagFrom(tag); + + return new LinkOutput( + Source.of(link.getSource().value()), + Target.of(link.getTarget().value()), + link.isExternal(), + link.getValidity(), + link.getVisits(), + link.getCreatedAt(), + link.getUpdatedAt(), + link.getBrand(), + linkTag, + linkCatalog); + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.ForSource inputCase -> run(inputCase); + }; + } + + private Output run(ForSource forSource) { + var url = forSource.target(); + var isExternalSlug = forSource.isExternal(); + var tagUuid = forSource.tagUuid(); + var catalogSlug = forSource.catalogSlug(); + + var link = linkRepository.findBySource(forSource.source()).orElse(null); + Tag tag; + Catalog catalog; + + if (link == null) { + return new Output.NoSuchSource(); + } + + tag = tagUuid != null ? tagRepository.findByUuid(tagUuid).orElse(null) : null; + catalog = catalogSlug != null ? catalogRepository.findBySlug(catalogSlug).orElse(null) : null; + + if (tagUuid != null && tag == null) { + return new Output.NotUpdated(InvalidFields.of(InvalidField.of("tag", "n'existe pas"))); + } + if (catalogSlug != null && catalog == null) { + return new Output.NotUpdated(InvalidFields.of(InvalidField.of("catalog", "n'existe pas"))); + } + + link.setTarget(url); + link.setIsExternal(isExternalSlug); + link.setTag(tagUuid); + link.setCatalog(catalog != null ? catalog.getUuid() : null); + link.setUpdatedAt(ZonedDateTime.now()); + + linkRepository.update(link); + + verifyLinkService.verifyAndPersistValidity(link); + + var linkOutput = linkOutputFrom(link, tag, catalog); + + return new Output.Updated(linkOutput); + } + + private Output.LinkOutput.@Nullable LinkCatalog linkCatalogFrom(@Nullable Catalog catalog) { + if (catalog == null) { + return null; + } + + var slug = catalog.getSlug(); + var name = catalog.getName(); + + return new Output.LinkOutput.LinkCatalog(slug, name); + } + + private Output.LinkOutput.@Nullable LinkTag linkTagFrom(@Nullable Tag tag) { + if (tag == null) { + return null; + } + + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + + return new Output.LinkOutput.LinkTag(uuid, name, color.toString()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/VerifyLink.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/VerifyLink.java new file mode 100644 index 0000000..fe2627c --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/VerifyLink.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.usecase.links; + +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public interface VerifyLink extends UseCase { + sealed interface Input { + record Verify(Source linkSource) implements Input {} + } + + sealed interface Output { + record TargetIsValid(Target linkTarget) implements Output {} + + record TargetIsNotValid(Target linkTarget) implements Output {} + + record NoSuchLink() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/VerifyLinkImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/VerifyLinkImpl.java new file mode 100644 index 0000000..76004e7 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/VerifyLinkImpl.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.domain.usecase.links; + +import jakarta.enterprise.context.ApplicationScoped; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.service.VerifyLinkService; +import tech.pegase.z3.domain.valueobject.LinkValidity; + +@ApplicationScoped +public class VerifyLinkImpl implements VerifyLink { + private final LinkRepository linkRepository; + private final VerifyLinkService verifyLinkService; + + VerifyLinkImpl(LinkRepository linkRepository, VerifyLinkService verifyLinkService) { + this.linkRepository = linkRepository; + this.verifyLinkService = verifyLinkService; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.Verify inputCase -> run(inputCase); + }; + } + + private Output run(Input.Verify inputCase) { + var link = linkRepository.findBySource(inputCase.linkSource()).orElse(null); + + if (link == null) { + return new Output.NoSuchLink(); + } + + verifyLinkService.verifyAndPersistValidity(link); + + if (link.getValidity().equals(LinkValidity.invalid())) { + return new Output.TargetIsNotValid(link.getTarget()); + } + + return new Output.TargetIsValid(link.getTarget()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/package-info.java new file mode 100644 index 0000000..25e9c1c --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/links/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.usecase.links; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptions.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptions.java new file mode 100644 index 0000000..b98eb83 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptions.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.entity.ReportingSubscription; + +public interface GetReportingSubscriptions + extends UseCase { + sealed interface Input { + record GetSubscriptionsForUser(UUID userUuid) implements Input {} + } + + sealed interface Output { + record ReportSubscriptionsForUser(Set subscriptions) implements Output {} + + record NoSuchUser() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptionsImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptionsImpl.java new file mode 100644 index 0000000..a615917 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptionsImpl.java @@ -0,0 +1,37 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import jakarta.enterprise.context.ApplicationScoped; +import tech.pegase.z3.domain.port.repository.ReportingSubscriptionRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.usecase.reporting.GetReportingSubscriptions.Input.GetSubscriptionsForUser; + +@ApplicationScoped +public class GetReportingSubscriptionsImpl implements GetReportingSubscriptions { + private final ReportingSubscriptionRepository reportingSubscriptionRepository; + private final UserRepository userRepository; + + GetReportingSubscriptionsImpl( + ReportingSubscriptionRepository reportingSubscriptionRepository, + UserRepository userRepository) { + this.reportingSubscriptionRepository = reportingSubscriptionRepository; + this.userRepository = userRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.GetSubscriptionsForUser inputCase -> run(inputCase); + }; + } + + private Output run(GetSubscriptionsForUser getSubscriptionsForUser) { + var user = userRepository.findByUuid(getSubscriptionsForUser.userUuid()).orElse(null); + + if (user == null) { + return new Output.NoSuchUser(); + } + + return new Output.ReportSubscriptionsForUser( + reportingSubscriptionRepository.getAllSubscriptions(user.getUuid())); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/SendReports.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/SendReports.java new file mode 100644 index 0000000..025a6dc --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/SendReports.java @@ -0,0 +1,18 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import java.util.Map; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; + +public interface SendReports extends UseCase { + sealed interface Input { + record SendReportsForInvalidLinks(String sender) implements Input {} + } + + sealed interface Output { + record ReportsFailedFor(Map reasonsPerUser) implements Output, Failure {} + + record AllReportSentWithSuccess() implements Output {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/SendReportsImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/SendReportsImpl.java new file mode 100644 index 0000000..77cdc55 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/SendReportsImpl.java @@ -0,0 +1,234 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.jboss.logging.Logger; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.ReportingSubscription; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.ReportingSubscriptionRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.MessageSender; +import tech.pegase.z3.domain.port.service.MessageSender.Status; +import tech.pegase.z3.domain.valueobject.Message; +import tech.pegase.z3.domain.valueobject.SubscriptionReport; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering.Field; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering.Value; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@ApplicationScoped +public class SendReportsImpl implements SendReports { + private static Logger logger = Logger.getLogger(SendReportsImpl.class.getName()); + private final ReportingSubscriptionRepository reportingSubscriptionRepository; + private final LinkRepository linkRepository; + private final UserRepository userRepository; + private final BrandRepository brandRepository; + private final MessageSender messageSender; + + SendReportsImpl( + ReportingSubscriptionRepository reportingSubscriptionRepository, + LinkRepository linkRepository, + UserRepository userRepository, + BrandRepository brandRepository, + MessageSender messageSender) { + this.reportingSubscriptionRepository = reportingSubscriptionRepository; + this.linkRepository = linkRepository; + this.userRepository = userRepository; + this.brandRepository = brandRepository; + this.messageSender = messageSender; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.SendReportsForInvalidLinks inputCase -> run(inputCase); + }; + } + + private Output run(Input.SendReportsForInvalidLinks inputCase) { + var sender = inputCase.sender(); + var reportingSubscriptionsByBrandsMap = getReportingSubscriptionsByBrands(); + var failedReportsMap = new HashMap(); + var subscriptionReportsList = + new ArrayList(reportingSubscriptionsByBrandsMap.size()); + + reportingSubscriptionsByBrandsMap + .entrySet() + .forEach( + entry -> + generateSubscriptionReportsForEntry( + entry, subscriptionReportsList, failedReportsMap)); + + subscriptionReportsList.stream() + .collect(Collectors.groupingBy(SubscriptionReport::user)) + .entrySet() + .forEach(entry -> sendAggregatedReportsToUser(sender, entry, failedReportsMap)); + + if (!failedReportsMap.isEmpty()) { + logger.debug("Some reports have failed"); + return new Output.ReportsFailedFor(failedReportsMap); + } + + logger.debug("All reports send with success"); + return new Output.AllReportSentWithSuccess(); + } + + private void sendAggregatedReportsToUser( + String from, + Map.Entry> entry, + Map failedReports) { + var user = entry.getKey(); + var reportsList = entry.getValue(); + + var message = aggregateReportsToMessage(reportsList, from); + var sentStatus = + messageSender.send( + new Message( + user.getEmail(), + "[%s] Rapport journalier de %s" + .formatted(LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE), from), + message)); + + if (sentStatus instanceof Status.Failed) { + var errorMessage = "Failed sending report to user '%s'".formatted(user.getUuid()); + logger.warn(errorMessage); + failedReports.put(user.getUuid(), errorMessage); + return; + } + + logger.debug("Message sent to user '%s'".formatted(user.getUuid())); + } + + private String aggregateReportsToMessage(List reports, String from) { + var messageBuilder = new StringBuilder(); + var reportList = new ArrayList(); + + messageBuilder.append( + "Bonjour,\n\nVeuillez trouver ci-dessous le rapport de liens invalides du '%s' (%s)\n\n" + .formatted(LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE), from)); + + messageBuilder.append("\n\n --- Début du rapport --- \n\n"); + + reports.forEach(report -> reportList.add(reportToMessage(report))); + messageBuilder.append(String.join("\n\n----------------------------\n\n", reportList)); + + messageBuilder.append("\n\n --- Fin du rapport, bonne journée --- \n\n"); + + return messageBuilder.toString(); + } + + private String reportToMessage(SubscriptionReport report) { + var reportMessageBuilder = new StringBuilder(); + reportMessageBuilder.append( + "Rapport pour les liens de l'enseigne '%s'".formatted(report.brand().getName())); + reportMessageBuilder.append("\n\n"); + reportMessageBuilder.append("Liste des liens invalides :\n"); + + report.invalidLinks().stream() + .sorted(Comparator.comparing(Link::getSource)) + .forEach(link -> reportMessageBuilder.append("%s\n".formatted(linkToString(link)))); + + return reportMessageBuilder.toString(); + } + + private String linkToString(Link link) { + var sourceParts = + Arrays.stream(link.getSource().value().split("(?<=\\G.{30})")) + .map(v -> padRight(v, 30, ' ')) + .toArray(String[]::new); + var multiLineSourceStr = String.join("\n", sourceParts); + return "%s\t\t%s".formatted(multiLineSourceStr, link.getTarget().value()); + } + + private void generateSubscriptionReportsForEntry( + Map.Entry> entry, + List subscriptionReports, + Map failedReports) { + var currentBrandUuid = entry.getKey(); + var userUuidSet = + entry.getValue().stream() + .collect(Collectors.groupingBy(ReportingSubscription::getUser)) + .keySet(); + var invalidLinks = getInvalidLinksForBrand(currentBrandUuid); + + if (invalidLinks.isEmpty()) { + return; + } + + userUuidSet.forEach( + userUuid -> { + try { + subscriptionReports.add( + getNewSubscriptionReportForUserAndBrand(userUuid, currentBrandUuid, invalidLinks)); + } catch (NoSuchElementException e) { + var message = + "Failed to get user or brand to generate the report for user '%s' and brand '%s'" + .formatted(userUuid, currentBrandUuid); + logger.warn(message, e); + failedReports.put(userUuid, message); + } + }); + } + + private SubscriptionReport getNewSubscriptionReportForUserAndBrand( + UUID userUuid, UUID brandUuid, Set invalidLinks) throws NoSuchElementException { + var user = userRepository.findByUuid(userUuid).orElseThrow(); + var brand = brandRepository.findByUuid(brandUuid).orElseThrow(); + return new SubscriptionReport(invalidLinks, brand, user); + } + + private Set getInvalidLinksForBrand(UUID brandUuid) { + List brandLinks = new ArrayList<>(); + var i = 0; + final var perPage = 50; + PaginatedData paginatedLinks; + do { + paginatedLinks = + linkRepository.findAll( + Pagination.fromOffsetAndLimit(i, perPage), + LinkFiltering.ofMap( + Map.of( + Field.BRAND, Value.of(brandUuid), + Field.IS_EXTERNAL, Value.of("false")))); + i += 50; + brandLinks = + Stream.of(brandLinks, paginatedLinks.items()) + .flatMap(Collection::stream) + .collect(Collectors.toList()); + } while (!paginatedLinks.items().isEmpty()); + + return new HashSet<>(brandLinks); + } + + private Map> getReportingSubscriptionsByBrands() { + return reportingSubscriptionRepository.getAllSubscriptions().stream() + .collect(Collectors.groupingBy(ReportingSubscription::getBrand)); + } + + String padRight(String input, int length, Character padStr) { + if (input.length() >= length) { + return input; + } + + var padLength = length - input.length(); + return input + String.valueOf(padStr).repeat(padLength); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptions.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptions.java new file mode 100644 index 0000000..e1f789b --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptions.java @@ -0,0 +1,24 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; + +public interface UpdateUserReportingSubscriptions + extends UseCase< + UpdateUserReportingSubscriptions.Input, UpdateUserReportingSubscriptions.Output> { + sealed interface Input { + record UpdateSubscriptionForUser(UUID userUuid, Set brandsUuids) implements Input {} + } + + sealed interface Output { + record SubscriptionsUpdated() implements Output {} + + record NoSuchUser() implements Output, Failure {} + + record TheseBrandsDoNotExist(Set brandsUuids) implements Output, Failure {} + + record UserDoNotBelongToTheseBrands(Set brandsUuids) implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptionsImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptionsImpl.java new file mode 100644 index 0000000..4d7b72c --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptionsImpl.java @@ -0,0 +1,93 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import tech.pegase.z3.domain.entity.ReportingSubscription; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.ReportingSubscriptionRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; + +@ApplicationScoped +public class UpdateUserReportingSubscriptionsImpl implements UpdateUserReportingSubscriptions { + private final UserRepository userRepository; + private final BrandRepository brandRepository; + private final ReportingSubscriptionRepository reportingSubscriptionRepository; + + public UpdateUserReportingSubscriptionsImpl( + UserRepository userRepository, + BrandRepository brandRepository, + ReportingSubscriptionRepository reportingSubscriptionRepository) { + this.userRepository = userRepository; + this.brandRepository = brandRepository; + this.reportingSubscriptionRepository = reportingSubscriptionRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.UpdateSubscriptionForUser inputCase -> run(inputCase); + }; + } + + private Output run(Input.UpdateSubscriptionForUser inputCase) { + var user = userRepository.findByUuid(inputCase.userUuid()).orElse(null); + var existingBrandsUuids = getBrandsUuidsThatActuallyExists(inputCase.brandsUuids()); + var nonExistingBrands = + getBrandUuidsThatDoesNotExists(inputCase.brandsUuids(), existingBrandsUuids); + + if (user == null) { + return new Output.NoSuchUser(); + } + + if (!nonExistingBrands.isEmpty()) { + return new Output.TheseBrandsDoNotExist(nonExistingBrands); + } + + var nonUserAssociatedBrandsUuids = + getBrandsUuidsThatAreNotAssociatedWithThisUser(user, existingBrandsUuids); + + if (!nonUserAssociatedBrandsUuids.isEmpty()) { + return new Output.UserDoNotBelongToTheseBrands(nonUserAssociatedBrandsUuids); + } + + reportingSubscriptionRepository.removeAllUserSubscriptions(user.getUuid()); + reportingSubscriptionRepository.setSubscriptions( + makeReportingSubscriptions(user, existingBrandsUuids)); + + return new Output.SubscriptionsUpdated(); + } + + private Set getBrandsUuidsThatActuallyExists(Set brandsUuidsToLookFor) { + return brandsUuidsToLookFor.stream() + .filter(brandRepository::exists) + .collect(Collectors.toSet()); + } + + private Set getBrandUuidsThatDoesNotExists( + Set brandUuids, Set existingBrandsUuids) { + return brandUuids.stream() + .filter(brandUuid -> existingBrandsUuids.stream().noneMatch(brandUuid::equals)) + .collect(Collectors.toSet()); + } + + private Set makeReportingSubscriptions(User user, Set brandsUuids) { + return brandsUuids.stream() + .map(brandUuid -> brandToReportingSubscription(user.getUuid(), brandUuid)) + .collect(Collectors.toSet()); + } + + private ReportingSubscription brandToReportingSubscription(UUID userUuid, UUID brandUuid) { + return new ReportingSubscription(userUuid, brandUuid); + } + + private Set getBrandsUuidsThatAreNotAssociatedWithThisUser( + User user, Set brandsUuids) { + return brandsUuids.stream() + .filter( + exisitingBrandUuid -> user.getBrands().stream().noneMatch(exisitingBrandUuid::equals)) + .collect(Collectors.toSet()); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/package-info.java new file mode 100644 index 0000000..5bf4fb4 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/reporting/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.usecase.reporting; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatistics.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatistics.java new file mode 100644 index 0000000..0f0a087 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatistics.java @@ -0,0 +1,44 @@ +package tech.pegase.z3.domain.usecase.statistics; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.shared.lib.ReadOnlyList; + +public interface GetGlobalStatistics + extends UseCase { + sealed interface Input { + record GetStatistics() implements Input {} + + record GetStatisticsForBrand(UUID brandUuid) implements Input {} + } + + sealed interface Output { + record GeneralStatistics(ReadOnlyList statistics) implements Output {} + + record NoSuchBrand() implements Output, Failure {} + + record GlobalStatistic(String type, String statistic, Long value) { + + public enum StatisticType { + TOTAL_INVALID_LINKS_FOR_ALL("total_invalid_links_for_all"), + TOTAL_INVALID_LINKS_FOR_BRAND("total_invalid_links_for_brand"), + TOTAL_VISITS_FOR_ALL("total_visits_for_all"), + TOTAL_VISITS_FOR_BRAND("total_visits_for_brand"), + TOTAL_EXTERNAL_VISITS_FOR_ALL("total_external_visits_for_all"), + TOTAL_EXTERNAL_VISITS_FOR_BRAND("total_external_visits_for_brand"), + TOTAL_BRAND_LESS_VISITS("total_brand_less_visits"); + + private final String value; + + StatisticType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatisticsImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatisticsImpl.java new file mode 100644 index 0000000..05b5ef9 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatisticsImpl.java @@ -0,0 +1,118 @@ +package tech.pegase.z3.domain.usecase.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Input.GetStatistics; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Input.GetStatisticsForBrand; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Output.GlobalStatistic; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Output.GlobalStatistic.StatisticType; +import tech.pegase.z3.shared.lib.ReadOnlyList; + +@ApplicationScoped +public class GetGlobalStatisticsImpl implements GetGlobalStatistics { + private final LinkRepository linkRepository; + private final BrandRepository brandRepository; + + public GetGlobalStatisticsImpl(LinkRepository linkRepository, BrandRepository brandRepository) { + this.linkRepository = linkRepository; + this.brandRepository = brandRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case GetStatistics inputCase -> run(inputCase); + case GetStatisticsForBrand inputCase -> run(inputCase); + }; + } + + private Output run(GetStatistics ignored) { + var globalStatistics = + new ReadOnlyList<>( + List.of( + countTotalVisits(null), + countInvalidLinks(null), + countBrandLessLinks(), + countExternalLinks(null))); + + return new Output.GeneralStatistics(globalStatistics); + } + + private Output run(GetStatisticsForBrand inputCase) { + var brandUuid = inputCase.brandUuid(); + + if (!brandRepository.exists(brandUuid)) { + return new Output.NoSuchBrand(); + } + + var globalStatistics = + new ReadOnlyList<>( + List.of( + countTotalVisits(brandUuid), + countInvalidLinks(brandUuid), + countExternalLinks(brandUuid))); + + return new Output.GeneralStatistics(globalStatistics); + } + + // STATISTICS METHODS + + private GlobalStatistic countBrandLessLinks() { + var label = "Visites sur des liens sans enseigne"; + var totalVisitsFor = linkRepository.countTotalVisits(null); + + return globalStatisticFrom(StatisticType.TOTAL_BRAND_LESS_VISITS, label, totalVisitsFor); + } + + private GlobalStatistic countExternalLinks(@Nullable UUID brandUuid) { + var forBrand = brandUuid != null; + var label = + forBrand + ? "Visites sur les liens externes de l'enseigne" + : "Visites sur tous les liens externes"; + var key = + forBrand + ? StatisticType.TOTAL_EXTERNAL_VISITS_FOR_BRAND + : StatisticType.TOTAL_EXTERNAL_VISITS_FOR_ALL; + var totalVisitsFor = + forBrand + ? linkRepository.countTotalExternal(brandUuid) + : linkRepository.countTotalExternal(); + + return globalStatisticFrom(key, label, totalVisitsFor); + } + + private GlobalStatistic countTotalVisits(@Nullable UUID brandUuid) { + var forBrand = brandUuid != null; + var label = forBrand ? "Visites sur les liens de l'enseigne" : "Visites sur tous les liens"; + var key = forBrand ? StatisticType.TOTAL_VISITS_FOR_BRAND : StatisticType.TOTAL_VISITS_FOR_ALL; + var totalVisitsFor = + forBrand ? linkRepository.countTotalVisits(brandUuid) : linkRepository.countTotalVisits(); + + return globalStatisticFrom(key, label, totalVisitsFor); + } + + private GlobalStatistic countInvalidLinks(@Nullable UUID brandUuid) { + var forBrand = brandUuid != null; + var label = + forBrand ? "Liens invalide pour cette enseigne" : "Liens invalides de toutes enseignes"; + var key = + forBrand + ? StatisticType.TOTAL_INVALID_LINKS_FOR_BRAND + : StatisticType.TOTAL_INVALID_LINKS_FOR_ALL; + var totalVisitsFor = + forBrand + ? linkRepository.countTotalInvalids(brandUuid) + : linkRepository.countTotalInvalids(); + + return globalStatisticFrom(key, label, totalVisitsFor); + } + + private GlobalStatistic globalStatisticFrom(StatisticType type, String statistic, Long value) { + return new GlobalStatistic(type.getValue(), statistic, value); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetLinksStatistics.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetLinksStatistics.java new file mode 100644 index 0000000..aad44d8 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetLinksStatistics.java @@ -0,0 +1,25 @@ +package tech.pegase.z3.domain.usecase.statistics; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.shared.lib.ReadOnlyMap; + +public interface GetLinksStatistics + extends UseCase { + sealed interface Input { + record ForBrands() implements Input {} + + record ForCatalogs(UUID ofBrandUuid) implements Input {} + + record ForTags(UUID ofBrandUuid) implements Input {} + } + + sealed interface Output { + record Statistics(ReadOnlyMap visitsPerEntity) implements Output {} + + record StatisticLine(UUID uuid, String name, Integer totalVisits) {} + + record NoSuchBrand() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetLinksStatisticsImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetLinksStatisticsImpl.java new file mode 100644 index 0000000..579f996 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/GetLinksStatisticsImpl.java @@ -0,0 +1,187 @@ +package tech.pegase.z3.domain.usecase.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.AbstractMap.SimpleEntry; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Input.ForBrands; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Input.ForCatalogs; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Input.ForTags; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Output.StatisticLine; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering.Field; +import tech.pegase.z3.shared.lib.ReadOnlyMap; +import tech.pegase.z3.shared.pagination.Pagination; + +@ApplicationScoped +public class GetLinksStatisticsImpl implements GetLinksStatistics { + private final LinkRepository linkRepository; + private final BrandRepository brandRepository; + private final CatalogRepository catalogRepository; + private final TagRepository tagRepository; + + public GetLinksStatisticsImpl( + LinkRepository linkRepository, + BrandRepository brandRepository, + CatalogRepository catalogRepository, + TagRepository tagRepository) { + this.linkRepository = linkRepository; + this.brandRepository = brandRepository; + this.catalogRepository = catalogRepository; + this.tagRepository = tagRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case ForBrands inputCase -> run(inputCase); + case ForCatalogs inputCase -> run(inputCase); + case ForTags inputCase -> run(inputCase); + }; + } + + private Output run(ForBrands ignored) { + return new Output.Statistics(getStatisticsFor(Field.BRAND, null)); + } + + private Output run(ForCatalogs inputCase) { + var brandUuid = inputCase.ofBrandUuid(); + + if (!brandExists(brandUuid)) { + return new Output.NoSuchBrand(); + } + + return new Output.Statistics(getStatisticsFor(Field.CATALOG, brandUuid)); + } + + private Output run(ForTags inputCase) { + var brandUuid = inputCase.ofBrandUuid(); + + if (!brandExists(brandUuid)) { + return new Output.NoSuchBrand(); + } + + return new Output.Statistics(getStatisticsFor(Field.TAG, brandUuid)); + } + + private boolean brandExists(UUID brandUuid) { + return brandRepository.exists(brandUuid); + } + + private ReadOnlyMap getStatisticsFor( + LinkFiltering.Field type, @Nullable UUID brandUuid) { + var totalStatistics = countTotalStatisticsFor(type, brandUuid); + + return new ReadOnlyMap<>(mapEntitiesToStatisticsFor(totalStatistics)); + } + + private Map mapEntitiesToStatisticsFor( + Map statistics) { + return statistics.entrySet().stream() + .map(this::statisticsMapper) + .collect(Collectors.toMap(StatisticLine::uuid, Function.identity())); + } + + private StatisticLine statisticsMapper(Entry entry) { + var foreignUuid = entry.getKey(); + var countOf = entry.getValue(); + + return new Output.StatisticLine(foreignUuid, countOf.name(), countOf.visits()); + } + + private Map countTotalStatisticsFor( + LinkFiltering.Field type, @Nullable UUID brandUuid) { + Map totalLinkCounts = new HashMap<>(); + Map paginatedLinksCount; + + var page = 1; + do { + paginatedLinksCount = countLinksOnPageFor(page++, type, brandUuid); + paginatedLinksCount.forEach((key, value) -> totalLinkCounts.merge(key, value, Integer::sum)); + } while (!paginatedLinksCount.isEmpty()); + + return totalLinkCounts.entrySet().stream() + .>mapMulti( + ((uuidIntegerEntry, consumer) -> { + var foreignUuid = uuidIntegerEntry.getKey(); + var count = uuidIntegerEntry.getValue(); + var name = getNameOf(type, foreignUuid); + + consumer.accept( + new SimpleEntry<>(foreignUuid, new CountOf(foreignUuid, name, count))); + })) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + } + + private Map countLinksOnPageFor( + int page, LinkFiltering.Field type, @Nullable UUID brandUuid) { + Map linkCounts = new HashMap<>(); + + var linkFiltering = + LinkFiltering.of(brandUuid != null ? Map.of("BRAND", brandUuid.toString()) : Map.of()); + + linkRepository + .findAll(Pagination.fromPageNumberAndPageSize(page, 50), linkFiltering) + .items() + .forEach( + link -> { + UUID foreignUuid = null; + + if (type.equals(LinkFiltering.Field.BRAND)) { + foreignUuid = link.getBrand(); + } + if (type.equals(LinkFiltering.Field.CATALOG)) { + foreignUuid = link.getCatalog(); + } + if (type.equals(LinkFiltering.Field.TAG)) { + foreignUuid = link.getTag(); + } + + if (foreignUuid != null) { + linkCounts.merge(foreignUuid, link.getVisits(), Integer::sum); + } + }); + + return linkCounts; + } + + private String getNameOf(LinkFiltering.Field type, @Nullable UUID foreignUuid) { + String name = null; + + if (type.equals(LinkFiltering.Field.TAG)) { + name = tagRepository.findByUuid(foreignUuid).map(Tag::getName).orElse(name); + } + if (type.equals(LinkFiltering.Field.CATALOG)) { + name = catalogRepository.findByUuid(foreignUuid).map(Catalog::getName).orElse(name); + } + if (name == null) { + name = + brandRepository.findByUuid(foreignUuid).map(Brand::getName).orElse("entité non résolue"); + } + + return name; + } + + record CountOf(UUID uuid, String name, Integer visits) { + CountOf { + if (name.isBlank()) { + throw new IllegalStateException("name cannot be blank"); + } + if (visits < 0) { + throw new IllegalStateException("visits cannot be negative"); + } + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/package-info.java new file mode 100644 index 0000000..b9e9529 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/statistics/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.usecase.statistics; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/CreateTag.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/CreateTag.java new file mode 100644 index 0000000..1a940fa --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/CreateTag.java @@ -0,0 +1,21 @@ +package tech.pegase.z3.domain.usecase.tags; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +public interface CreateTag extends UseCase { + sealed interface Input { + record With(String name, Color color) implements Input {} + } + + sealed interface Output { + record Created(TagOutput value) implements Output {} + + record NotCreated(InvalidFields reasons) implements Output, Failure {} + + record TagOutput(UUID uuid, String name, Color color) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/CreateTagImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/CreateTagImpl.java new file mode 100644 index 0000000..df4ba58 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/CreateTagImpl.java @@ -0,0 +1,58 @@ +package tech.pegase.z3.domain.usecase.tags; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Objects; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.CreateTag.Output.TagOutput; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +@ApplicationScoped +public class CreateTagImpl implements CreateTag { + private final TagRepository tagRepository; + + @Inject + CreateTagImpl(TagRepository tagRepository) { + this.tagRepository = tagRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.With inputCase -> run(inputCase); + }; + } + + private Output run(Input.With inputCase) { + var name = inputCase.name(); + var color = inputCase.color(); + + Objects.requireNonNull(name, "name cannot be null"); + Objects.requireNonNull(color, "color cannot be null"); + + if (name.isBlank()) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("name", "ne peut être vide"))); + } + + if (tagRepository.findByName(name).isPresent()) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("name", "est déjà utilisé"))); + } + + var tag = new Tag(name, color); + tagRepository.persist(tag); + + var tagOutput = tagOutputOf(tag); + + return new Output.Created(tagOutput); + } + + private TagOutput tagOutputOf(Tag tag) { + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + + return new TagOutput(uuid, name, color); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/GetTag.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/GetTag.java new file mode 100644 index 0000000..c525f67 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/GetTag.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.usecase.tags; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Color; + +public interface GetTag extends UseCase { + sealed interface Input { + record ForId(UUID uuid) implements Input {} + } + + sealed interface Output { + record Found(TagOutput tag) implements Output {} + + record NoSuchTag() implements Output, Failure {} + + record TagOutput(UUID uuid, String name, Color color) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/GetTagImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/GetTagImpl.java new file mode 100644 index 0000000..f4201dd --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/GetTagImpl.java @@ -0,0 +1,47 @@ +package tech.pegase.z3.domain.usecase.tags; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.GetTag.Input.ForId; +import tech.pegase.z3.domain.usecase.tags.GetTag.Output.NoSuchTag; + +@ApplicationScoped +public class GetTagImpl implements GetTag { + private final TagRepository tagRepository; + + @Inject + GetTagImpl(TagRepository tagRepository) { + this.tagRepository = tagRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case ForId inputCase -> run(inputCase); + }; + } + + private Output run(ForId inputCase) { + var tagUuid = inputCase.uuid(); + var optionalTag = this.tagRepository.findByUuid(tagUuid); + + if (optionalTag.isPresent()) { + var tag = optionalTag.get(); + var tagOutput = tagOutputOf(tag); + + return new Output.Found(tagOutput); + } + + return new NoSuchTag(); + } + + private Output.TagOutput tagOutputOf(Tag tag) { + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + + return new Output.TagOutput(uuid, name, color); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/ListTags.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/ListTags.java new file mode 100644 index 0000000..8c98832 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/ListTags.java @@ -0,0 +1,19 @@ +package tech.pegase.z3.domain.usecase.tags; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public interface ListTags extends UseCase { + sealed interface Input { + record WithPagination(Pagination pagination) implements Input {} + } + + sealed interface Output { + record PaginatedTags(PaginatedData tagList) implements Output {} + + record TagOutput(UUID uuid, String name, Color color) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/ListTagsImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/ListTagsImpl.java new file mode 100644 index 0000000..f0f2e44 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/ListTagsImpl.java @@ -0,0 +1,39 @@ +package tech.pegase.z3.domain.usecase.tags; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.ListTags.Input.WithPagination; +import tech.pegase.z3.domain.usecase.tags.ListTags.Output.PaginatedTags; + +@ApplicationScoped +public class ListTagsImpl implements ListTags { + private final TagRepository tagRepository; + + @Inject + public ListTagsImpl(TagRepository tagRepository) { + this.tagRepository = tagRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case WithPagination caseInput -> run(caseInput); + }; + } + + private ListTags.Output run(ListTags.Input.WithPagination input) { + var paginatedTagOutputs = tagRepository.findAll(input.pagination()).map(this::tagOutputOf); + + return new PaginatedTags(paginatedTagOutputs); + } + + private Output.TagOutput tagOutputOf(Tag tag) { + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + + return new Output.TagOutput(uuid, name, color); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/RemoveTag.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/RemoveTag.java new file mode 100644 index 0000000..6b9402a --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/RemoveTag.java @@ -0,0 +1,17 @@ +package tech.pegase.z3.domain.usecase.tags; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; + +public interface RemoveTag extends UseCase { + sealed interface Input { + record WhereUuid(UUID uuid) implements Input {} + } + + sealed interface Output { + record Removed() implements Output {} + + record NoSuchTag() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/RemoveTagImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/RemoveTagImpl.java new file mode 100644 index 0000000..d774576 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/RemoveTagImpl.java @@ -0,0 +1,62 @@ +package tech.pegase.z3.domain.usecase.tags; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.UUID; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.RemoveTag.Input.WhereUuid; +import tech.pegase.z3.domain.usecase.tags.RemoveTag.Output.Removed; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@ApplicationScoped +public class RemoveTagImpl implements RemoveTag { + TagRepository tagRepository; + LinkRepository linkRepository; + + @Inject + RemoveTagImpl(TagRepository tagRepository, LinkRepository linkRepository) { + this.tagRepository = tagRepository; + this.linkRepository = linkRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case WhereUuid inputCase -> run(inputCase); + }; + } + + private Output run(WhereUuid inputCase) { + var uuid = inputCase.uuid(); + + if (!tagRepository.exists(uuid)) { + return new RemoveTag.Output.NoSuchTag(); + } + + removeTagFromLinks(uuid); + + // Delete is an idempotent action. + tagRepository.remove(uuid); + return new Removed(); + } + + private void removeTagFromLinks(UUID tagUuid) { + var i = 0; + final var perPage = 50; + PaginatedData paginatedLinks; + do { + paginatedLinks = + linkRepository.findAllForTag(Pagination.fromOffsetAndLimit(i, perPage), tagUuid); + paginatedLinks.items().forEach(this::removeTagFromLink); + i += 50; + } while (!paginatedLinks.items().isEmpty()); + } + + private void removeTagFromLink(Link link) { + link.setTag(null); + linkRepository.update(link); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/UpdateTag.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/UpdateTag.java new file mode 100644 index 0000000..767db5f --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/UpdateTag.java @@ -0,0 +1,21 @@ +package tech.pegase.z3.domain.usecase.tags; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +public interface UpdateTag extends UseCase { + sealed interface Input { + record WhereUuid(UUID uuid, String name, Color color) implements Input {} + } + + sealed interface Output { + record Updated(TagOutput value) implements Output {} + + record NotUpdated(InvalidFields reasons) implements Output, Failure {} + + record TagOutput(UUID uuid, String name, Color color) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/UpdateTagImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/UpdateTagImpl.java new file mode 100644 index 0000000..3e12de2 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/UpdateTagImpl.java @@ -0,0 +1,76 @@ +package tech.pegase.z3.domain.usecase.tags; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.UUID; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.UpdateTag.Input.WhereUuid; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +@ApplicationScoped +public class UpdateTagImpl implements UpdateTag { + private final TagRepository tagRepository; + + @Inject + UpdateTagImpl(TagRepository tagRepository) { + this.tagRepository = tagRepository; + } + + @Override + public UpdateTag.Output run(UpdateTag.Input input) { + return switch (input) { + case WhereUuid inputCase -> run(inputCase); + }; + } + + private UpdateTag.Output run(WhereUuid inputCase) { + + var uuid = inputCase.uuid(); + var name = inputCase.name(); + var color = inputCase.color(); + + if (name.isBlank()) { + return new UpdateTag.Output.NotUpdated( + InvalidFields.of(InvalidField.of("name", "ne peut être vide"))); + } + + // Find the tagUuid we need to update + var optionalTag = tagRepository.findByUuid(uuid); + + if (optionalTag.isEmpty()) { + return new UpdateTag.Output.NotUpdated( + InvalidFields.of(InvalidField.of("tag", "n'existe pas"))); + } + + if (isTagNameAlreadyUsed(name, uuid)) { + return new UpdateTag.Output.NotUpdated( + InvalidFields.of(InvalidField.of("name", "est déjà utilisé"))); + } + + var tag = optionalTag.get(); + + tag.setName(name); + tag.setColor(color); + + tagRepository.update(tag); + + var tagOutput = tagOutputOf(tag); + + return new UpdateTag.Output.Updated(tagOutput); + } + + private Output.TagOutput tagOutputOf(Tag tag) { + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + + return new Output.TagOutput(uuid, name, color); + } + + private boolean isTagNameAlreadyUsed(String tagName, UUID currentTag) { + var anyMatchingTag = tagRepository.findByName(tagName); + return anyMatchingTag.isPresent() && !anyMatchingTag.get().getUuid().equals(currentTag); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/package-info.java new file mode 100644 index 0000000..b4ca0a6 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/tags/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.usecase.tags; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ChangeUserPassword.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ChangeUserPassword.java new file mode 100644 index 0000000..46dbb0a --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ChangeUserPassword.java @@ -0,0 +1,22 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Password; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +public interface ChangeUserPassword + extends UseCase { + sealed interface Input { + record WhereId(UUID uuid, Password password, Password newPassword) implements Input {} + } + + sealed interface Output { + record Changed() implements Output {} + + record NotChanged(InvalidFields reasons) implements Output, Failure {} + + record NoSuchUser() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ChangeUserPasswordImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ChangeUserPasswordImpl.java new file mode 100644 index 0000000..d465ec7 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ChangeUserPasswordImpl.java @@ -0,0 +1,69 @@ +package tech.pegase.z3.domain.usecase.users; + +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.ApplicationScoped; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.usecase.users.ChangeUserPassword.Input.WhereId; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +@ApplicationScoped +public class ChangeUserPasswordImpl implements ChangeUserPassword { + private final UserRepository userRepository; + private final CredentialRepository credentialRepository; + private final HashProvider hashProvider; + + public ChangeUserPasswordImpl( + UserRepository userRepository, + CredentialRepository credentialRepository, + HashProvider hashProvider) { + this.userRepository = userRepository; + this.credentialRepository = credentialRepository; + this.hashProvider = hashProvider; + } + + @Override + public Output run(Input input) { + return switch (input) { + case WhereId inputCase -> run(inputCase); + }; + } + + private Output run(WhereId inputCase) { + var password = inputCase.password(); + var newPassword = inputCase.newPassword(); + @Nullable var user = userRepository.findByUuid(inputCase.uuid()).orElse(null); + @Nullable Credential credential; + + if (user == null) { + return new Output.NoSuchUser(); + } + + credential = credentialRepository.findById(user.getCredentialUuid()).orElse(null); + + if (credential == null) { + throw new IllegalStateException("Missing credentials for a user"); + } + + if (password.equals(newPassword)) { + return new Output.NotChanged( + InvalidFields.of( + InvalidField.of("new-password", "Ne peut être identique au mot de passe actuel"))); + } + + if (!hashProvider.areEquals(credential.getPassword(), password.value())) { + return new Output.NotChanged( + InvalidFields.of(InvalidField.of("password", "le mot de passe n'a pas pu être vérifié"))); + } + + credential.setPassword(hashProvider.hash(PasswordHash.class, newPassword.value())); + + credentialRepository.update(credential); + + return new Output.Changed(); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/CreateUser.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/CreateUser.java new file mode 100644 index 0000000..a6655d7 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/CreateUser.java @@ -0,0 +1,51 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +public interface CreateUser extends UseCase { + sealed interface Input { + record NewUserBy( + UUID askingUserUuid, + String firstName, + String lastName, + Email email, + Role role, + Login login, + Set brandsUuids) + implements Input {} + + record NewUser( + String firstName, + String lastName, + Email email, + Role role, + Login login, + Set brandsUuids) + implements Input {} + } + + sealed interface Output { + record Created(UserOutput value) implements Output {} + + record Forbidden(String reason) implements Output, Failure {} + + record MessageCannotBeSent() implements Output, Failure {} + + record NotCreated(InvalidFields reasons) implements Output, Failure {} + + record UserOutput( + UUID uuid, + String firstName, + String lastName, + Email email, + Role role, + Set brandsUuids) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/CreateUserImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/CreateUserImpl.java new file mode 100644 index 0000000..9878b03 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/CreateUserImpl.java @@ -0,0 +1,184 @@ +package tech.pegase.z3.domain.usecase.users; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.port.service.MessageSender; +import tech.pegase.z3.domain.port.service.PasswordGenerator; +import tech.pegase.z3.domain.usecase.users.CreateUser.Output.UserOutput; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.Message; +import tech.pegase.z3.domain.valueobject.Password; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +@ApplicationScoped +public class CreateUserImpl implements CreateUser { + private final PasswordGenerator passwordGenerator; + private final MessageSender messageSender; + private final UserRepository userRepository; + private final BrandRepository brandRepository; + private final CredentialRepository credentialRepository; + private final HashProvider hashProvider; + + @Inject + public CreateUserImpl( + PasswordGenerator passwordGenerator, + MessageSender messageSender, + UserRepository userRepository, + BrandRepository brandRepository, + CredentialRepository credentialRepository, + HashProvider hashProvider) { + this.passwordGenerator = passwordGenerator; + this.messageSender = messageSender; + this.userRepository = userRepository; + this.brandRepository = brandRepository; + this.credentialRepository = credentialRepository; + this.hashProvider = hashProvider; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.NewUser inputCase -> run(inputCase); + case Input.NewUserBy inputCase -> run(inputCase); + }; + } + + private Output run(Input.NewUser inputCase) { + var email = inputCase.email(); + var role = inputCase.role(); + var firstName = inputCase.firstName(); + var lastName = inputCase.lastName(); + var login = inputCase.login(); + var password = Password.of(passwordGenerator.generatePassword(10)); + var brands = inputCase.brandsUuids(); + + return createUser(firstName, lastName, email, role, login, password, brands); + } + + private Output run(Input.NewUserBy inputCase) { + Optional authUser; + var askingUserUuid = inputCase.askingUserUuid(); + var email = inputCase.email(); + var role = inputCase.role(); + var firstName = inputCase.firstName(); + var lastName = inputCase.lastName(); + var login = inputCase.login(); + var password = Password.of(passwordGenerator.generatePassword(10)); + var brands = inputCase.brandsUuids(); + + authUser = userRepository.findByUuid(askingUserUuid); + + if (authUser.isEmpty()) { + return new Output.Forbidden("Asking user does not exist"); + } + + if (authUser.get().getRole().compareTo(role) > 0) { + return new Output.NotCreated( + InvalidFields.of( + InvalidField.of("role", "impossible de définir un rôle supérieur au votre"))); + } + + return createUser(firstName, lastName, email, role, login, password, brands); + } + + private Output createUser( + String firstName, + String lastName, + Email email, + Role role, + Login login, + Password password, + Set brandsUuids) { + + Credential credential; + + if (firstName.isBlank()) { + return new Output.NotCreated( + InvalidFields.of(InvalidField.of("firstName", "ne peut être vide"))); + } + if (lastName.isBlank()) { + return new Output.NotCreated( + InvalidFields.of(InvalidField.of("lastName", "ne peut être vide"))); + } + + if (userRepository.exists(email)) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("email", "existe déjà"))); + } + + if (credentialRepository.findByLogin(login).isPresent()) { + return new Output.NotCreated(InvalidFields.of(InvalidField.of("login", "existe déjà"))); + } + + var passwordHash = hashProvider.hash(PasswordHash.class, password.value()); + + var anyBrandIsMissing = + brandsUuids.stream() + .reduce(false, (v, k) -> v || !brandRepository.exists(k), Boolean::logicalOr); + if (anyBrandIsMissing) { + return new Output.NotCreated( + InvalidFields.of(InvalidField.of("brands", "une des enseignes n'existe pas"))); + } + + credential = new Credential(login, passwordHash); + + credentialRepository.persist(credential); + + var user = new User(firstName, lastName, email, role, credential.getUuid()); + user.setBrands(brandsUuids); + userRepository.persist(user); + + var messageIsSent = sendMessageToUser(email, login, password); + + if (messageIsSent instanceof MessageSender.Status.Failed) { + return new Output.MessageCannotBeSent(); + } + + var userOutput = userOutputOf(user); + + return new Output.Created(userOutput); + } + + private UserOutput userOutputOf(User user) { + var uuid = user.getUuid(); + var firstName = user.getFirstName(); + var lastName = user.getLastName(); + var email = user.getEmail(); + var role = user.getRole(); + var brands = user.getBrands(); + + return new UserOutput(uuid, firstName, lastName, email, role, brands); + } + + private MessageSender.Status sendMessageToUser(Email email, Login login, Password password) { + var message = + """ + Bonjour, + Votre compte viens d'être crée veuillez retrouver + les informations pertinentes pour vous connecter ci-dessous : + + login : %s + password : %s + + N´oubliez pas de modifier votre mot de passe lors de votre première connexion. + Pensez également a supprimer ce message (y compris de votre corbeille). + + Cordialement + """ + .formatted(login.value(), password.value()); + + return messageSender.send(Message.of(email, "Votre compte Z3 à été crée", message)); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/GetUser.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/GetUser.java new file mode 100644 index 0000000..6965571 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/GetUser.java @@ -0,0 +1,28 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; + +public interface GetUser extends UseCase { + sealed interface Input { + record ForId(UUID uuid) implements GetUser.Input {} + } + + sealed interface Output { + record Found(UserOutput user) implements GetUser.Output {} + + record NoSuchUser() implements GetUser.Output, Failure {} + + record UserOutput( + UUID uuid, + String firstName, + String lastName, + Email email, + Role role, + Set brandsUuids) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/GetUserImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/GetUserImpl.java new file mode 100644 index 0000000..4897588 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/GetUserImpl.java @@ -0,0 +1,45 @@ +package tech.pegase.z3.domain.usecase.users; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.UserRepository; + +@ApplicationScoped +public class GetUserImpl implements GetUser { + + private final UserRepository userRepository; + + @Inject + public GetUserImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.ForId inputCase -> run(inputCase); + }; + } + + private Output run(Input.ForId inputCase) { + var id = inputCase.uuid(); + + return userRepository + .findByUuid(id) + .map(this::userOutputOf) + .map(Output.Found::new) + .orElseGet(Output.NoSuchUser::new); + } + + private Output.UserOutput userOutputOf(User user) { + var uuid = user.getUuid(); + var firstName = user.getFirstName(); + var lastName = user.getLastName(); + var email = user.getEmail(); + var role = user.getRole(); + var brands = user.getBrands(); + + return new Output.UserOutput(uuid, firstName, lastName, email, role, brands); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ListUsers.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ListUsers.java new file mode 100644 index 0000000..2a3403a --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ListUsers.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public interface ListUsers extends UseCase { + sealed interface Input { + record WithPagination(Pagination pagination) implements Input {} + } + + sealed interface Output { + record PaginatedUsers(PaginatedData userList) implements Output {} + + record UserOutput(UUID uuid, String firstName, String lastName, Email email, Role role) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ListUsersImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ListUsersImpl.java new file mode 100644 index 0000000..e5158ac --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ListUsersImpl.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.domain.usecase.users; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.usecase.users.ListUsers.Output.PaginatedUsers; + +@ApplicationScoped +public class ListUsersImpl implements ListUsers { + private final UserRepository userRepository; + + @Inject + public ListUsersImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.WithPagination caseInput -> run(caseInput); + }; + } + + private Output run(Input.WithPagination input) { + var paginatedUserOutputs = userRepository.findAll(input.pagination()).map(this::userOutputOf); + + return new PaginatedUsers(paginatedUserOutputs); + } + + private Output.UserOutput userOutputOf(User user) { + var uuid = user.getUuid(); + var firstName = user.getFirstName(); + var lastName = user.getLastName(); + var email = user.getEmail(); + var role = user.getRole(); + + return new Output.UserOutput(uuid, firstName, lastName, email, role); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/RemoveUser.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/RemoveUser.java new file mode 100644 index 0000000..e99ce52 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/RemoveUser.java @@ -0,0 +1,19 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; + +public interface RemoveUser extends UseCase { + sealed interface Input { + record WhereUuid(UUID requestingUserUuid, UUID uuid) implements Input {} + } + + sealed interface Output { + record Removed() implements Output {} + + record NotSuchUser() implements Output, Failure {} + + record ForbiddenAction() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/RemoveUserImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/RemoveUserImpl.java new file mode 100644 index 0000000..67d95be --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/RemoveUserImpl.java @@ -0,0 +1,56 @@ +package tech.pegase.z3.domain.usecase.users; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.Optional; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; + +@ApplicationScoped +public class RemoveUserImpl implements RemoveUser { + private final UserRepository userRepository; + private final CredentialRepository credentialRepository; + + @Inject + public RemoveUserImpl(UserRepository userRepository, CredentialRepository credentialRepository) { + this.userRepository = userRepository; + this.credentialRepository = credentialRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.WhereUuid inputCase -> run(inputCase); + }; + } + + private Output run(Input.WhereUuid inputCase) { + var requestingUserUuid = inputCase.requestingUserUuid(); + var uuid = inputCase.uuid(); + Optional optionalUser; + + var requestingUser = userRepository.findByUuid(requestingUserUuid); + + if (requestingUser.isEmpty()) { + return new Output.ForbiddenAction(); + } + + if (requestingUser.get().getUuid().equals(uuid)) { + return new Output.ForbiddenAction(); + } + + optionalUser = userRepository.findByUuid(uuid); + + if (optionalUser.isEmpty()) { + return new Output.NotSuchUser(); + } + + var userToRemove = optionalUser.get(); + + // Delete is an idempotent action. + userRepository.remove(userToRemove.getUuid()); + credentialRepository.remove(userToRemove.getCredentialUuid()); + return new Output.Removed(); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ResetUserPassword.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ResetUserPassword.java new file mode 100644 index 0000000..f25b747 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ResetUserPassword.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; + +public interface ResetUserPassword + extends UseCase { + sealed interface Input { + record ForUser(UUID uuid) implements Input {} + } + + sealed interface Output { + record MessageSent() implements Output {} + + record MessageCannotBeSent() implements Output, Failure {} + + record NotSuchUser() implements Output, Failure {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ResetUserPasswordImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ResetUserPasswordImpl.java new file mode 100644 index 0000000..5a7f7d7 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/ResetUserPasswordImpl.java @@ -0,0 +1,93 @@ +package tech.pegase.z3.domain.usecase.users; + +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.ApplicationScoped; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.port.service.MessageSender; +import tech.pegase.z3.domain.port.service.PasswordGenerator; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.Message; +import tech.pegase.z3.domain.valueobject.Password; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +@ApplicationScoped +public class ResetUserPasswordImpl implements ResetUserPassword { + private final PasswordGenerator passwordGenerator; + private final MessageSender messageSender; + private final HashProvider hashProvider; + private final CredentialRepository credentialRepository; + private final UserRepository userRepository; + + public ResetUserPasswordImpl( + PasswordGenerator passwordGenerator, + MessageSender messageSender, + CredentialRepository credentialRepository, + UserRepository userRepository, + HashProvider hashProvider) { + this.passwordGenerator = passwordGenerator; + this.messageSender = messageSender; + this.credentialRepository = credentialRepository; + this.userRepository = userRepository; + this.hashProvider = hashProvider; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.ForUser useCaseInput -> run(useCaseInput); + }; + } + + private Output run(Input.ForUser useCaseInput) { + var userUuid = useCaseInput.uuid(); + @Nullable var user = userRepository.findByUuid(userUuid).orElse(null); + @Nullable Credential credential; + Password newPassword; + + if (user == null) { + return new Output.NotSuchUser(); + } + + credential = credentialRepository.findById(user.getCredentialUuid()).orElse(null); + + if (credential == null) { + throw new IllegalStateException("A user has to be associated credentials"); + } + + newPassword = Password.of(passwordGenerator.generatePassword(10)); + credential.setPassword(hashProvider.hash(PasswordHash.class, newPassword.value())); + credentialRepository.update(credential); + + var messageIsSent = sendMessageToUser(user.getEmail(), credential.getLogin(), newPassword); + + if (messageIsSent instanceof MessageSender.Status.Failed) { + return new Output.MessageCannotBeSent(); + } + + return new Output.MessageSent(); + } + + private MessageSender.Status sendMessageToUser(Email email, Login login, Password password) { + var message = + """ + Bonjour, + Votre mot de passe viens d'être réinitialisé, veuillez retrouver + les informations pertinentes pour vous connecter ci-dessous : + + login : %s + password : %s + + N´oubliez pas de modifier votre mot de passe lors de votre connexion. + Pensez également a supprimer ce message (y compris de votre corbeille). + + Cordialement + """ + .formatted(login.value(), password.value()); + + return messageSender.send(Message.of(email, "Votre accès Z3 à été réinitialisé", message)); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/UpdateUser.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/UpdateUser.java new file mode 100644 index 0000000..5589121 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/UpdateUser.java @@ -0,0 +1,32 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Set; +import java.util.UUID; +import tech.pegase.happy.core.usecase.Failure; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +public interface UpdateUser extends UseCase { + sealed interface Input { + record WhereId(UUID uuid, String firstName, String lastName, Email email, Set brandsUuids) + implements Input {} + } + + sealed interface Output { + record Updated(UserOutput value) implements Output {} + + record NotUpdated(InvalidFields reasons) implements Output, Failure {} + + record NoSuchUser() implements Output, Failure {} + + record UserOutput( + UUID uuid, + String firstName, + String lastName, + Email email, + Role role, + Set brandsUuids) {} + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/UpdateUserImpl.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/UpdateUserImpl.java new file mode 100644 index 0000000..b517eaa --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/UpdateUserImpl.java @@ -0,0 +1,85 @@ +package tech.pegase.z3.domain.usecase.users; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.valueobject.errors.InvalidField; +import tech.pegase.z3.domain.valueobject.errors.InvalidFields; + +@ApplicationScoped +public class UpdateUserImpl implements UpdateUser { + private final UserRepository userRepository; + private final BrandRepository brandRepository; + + @Inject + public UpdateUserImpl(UserRepository userRepository, BrandRepository brandRepository) { + this.userRepository = userRepository; + this.brandRepository = brandRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.WhereId inputCase -> run(inputCase); + }; + } + + private Output run(Input.WhereId inputCase) { + var id = inputCase.uuid(); + var firstName = inputCase.firstName(); + var lastName = inputCase.lastName(); + var email = inputCase.email(); + var brands = inputCase.brandsUuids(); + + if (firstName.isBlank()) { + return new Output.NotUpdated( + InvalidFields.of(InvalidField.of("firstName", "ne peut être vide"))); + } + + if (lastName.isBlank()) { + return new Output.NotUpdated( + InvalidFields.of(InvalidField.of("lastName", "ne peut être vide"))); + } + + var anyBrandIsMissing = + brands.stream() + .reduce(false, (v, k) -> v || !brandRepository.exists(k), Boolean::logicalOr); + if (anyBrandIsMissing) { + return new Output.NotUpdated( + InvalidFields.of(InvalidField.of("brands", "une des enseignes n'existe pas"))); + } + + // Find the userUuid we need to update + var optionalUser = userRepository.findByUuid(id); + + if (optionalUser.isEmpty()) { + return new Output.NoSuchUser(); + } + + var user = optionalUser.get(); + + user.setFirstName(firstName); + user.setLastName(lastName); + user.setEmail(email); + user.setBrands(brands); + + userRepository.persist(user); + + var userOutput = userOutputOf(user); + + return new Output.Updated(userOutput); + } + + private Output.UserOutput userOutputOf(User user) { + var uuid = user.getUuid(); + var firstName = user.getFirstName(); + var lastName = user.getLastName(); + var email = user.getEmail(); + var role = user.getRole(); + var brands = user.getBrands(); + + return new Output.UserOutput(uuid, firstName, lastName, email, role, brands); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/package-info.java new file mode 100644 index 0000000..758d185 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/usecase/users/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.usecase.users; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Color.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Color.java new file mode 100644 index 0000000..feebb0e --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Color.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.domain.valueobject; + +import jakarta.persistence.Embeddable; +import java.util.regex.Pattern; + +@Embeddable +public record Color(Integer value) { + + @SuppressWarnings("ConstantConditions") + public Color { + if (value == null) { + throw new IllegalStateException("Value cannot be null"); + } + if (value > 0xFFFFFF) { + throw new IllegalStateException("Color value '%s' out of range.".formatted(value)); + } + } + + public static Color of(Integer value) throws IllegalStateException { + return new Color(value); + } + + public static Color of(String hexColor) throws IllegalStateException { + assertHexColorCanBeParsed(hexColor); + return new Color(Integer.parseInt(hexColor.replaceFirst("#", ""), 16)); + } + + private static void assertHexColorCanBeParsed(String hexColor) throws IllegalStateException { + var hexColorPattern = Pattern.compile("(#)?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})"); + if (!hexColorPattern.matcher(hexColor).matches()) { + throw new IllegalStateException("Invalid hex color '%s'".formatted(hexColor)); + } + } + + @Override + public String toString() { + final var htmlColorLength = 7; + var stringBuilder = new StringBuilder(htmlColorLength); + + stringBuilder.append(Integer.toHexString(value)); + + while (stringBuilder.length() < htmlColorLength - 1) { + stringBuilder.insert(0, '0'); + } + + stringBuilder.insert(0, '#'); + + return stringBuilder.toString().toUpperCase(); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Email.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Email.java new file mode 100644 index 0000000..037641d --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Email.java @@ -0,0 +1,22 @@ +package tech.pegase.z3.domain.valueobject; + +import jakarta.persistence.Embeddable; +import org.apache.commons.validator.routines.EmailValidator; + +@Embeddable +public record Email(String value) { + + public Email { + if (!isValidEmail(value)) { + throw new IllegalStateException("Adresse e-mail incorrecte: '%s'".formatted(value)); + } + } + + public static Email of(String value) throws IllegalStateException { + return new Email(value); + } + + private boolean isValidEmail(String value) { + return EmailValidator.getInstance().isValid(value); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Fqdn.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Fqdn.java new file mode 100644 index 0000000..2ecf8bd --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Fqdn.java @@ -0,0 +1,98 @@ +package tech.pegase.z3.domain.valueobject; + +import jakarta.persistence.Embeddable; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Pattern; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; + +@Embeddable +public class Fqdn { + private String value; + + public Fqdn(String value) { + this.value = value; + valueToUri(value); + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + public Fqdn() { + this.value = null; + } + + public static Fqdn of(String value) throws IllegalStateException { + return new Fqdn(value); + } + + public String getValue() { + return value; + } + + public URI getFqdnUri() { + return valueToUri(value); + } + + private URI valueToUri(String value) throws IllegalStateException { + URI fqdnUri; + var failureMessage = "le fqdn doit être valide la valeur est '" + value + "'"; + try { + if (value == null || value.isBlank()) { + throw new IllegalStateException(failureMessage); + } + + fqdnUri = new URI("https://" + value); + if (!startsWithFqdn(value)) { + throw new IllegalStateException(failureMessage); + } + + var userInfo = fqdnUri.getUserInfo(); + var path = fqdnUri.getPath(); + var query = fqdnUri.getQuery(); + var fragment = fqdnUri.getFragment(); + var port = fqdnUri.getPort(); + if (userInfo != null + || (path != null && !path.isBlank()) + || port != -1 + || query != null + || fragment != null) { + throw new IllegalStateException(failureMessage); + } + var ignored = fqdnUri.toURL(); + + return fqdnUri; + } catch (URISyntaxException | IllegalArgumentException | MalformedURLException e) { + throw new IllegalStateException(failureMessage); + } + } + + private Boolean startsWithFqdn(String value) { + /* Regex to match a String that starts with a FQDN */ + final var regex = "^((?!-)[A-Za-z0-9-]{1,63}(? "valide"; + case INVALID -> "non valide"; + case UNKNOWN -> "non testé"; + }; + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Login.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Login.java new file mode 100644 index 0000000..bbfe281 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Login.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.valueobject; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record Login(String value) { + @SuppressWarnings("ConstantConditions") + public Login { + if (value == null || value.isBlank()) { + throw new IllegalStateException("Login value cannot be null or empty"); + } + if (value.length() < 3) { + throw new IllegalStateException("Login value must be at least 3 characters"); + } + } + + public static Login of(String value) throws IllegalStateException { + return new Login(value); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Message.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Message.java new file mode 100644 index 0000000..44455d0 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Message.java @@ -0,0 +1,17 @@ +package tech.pegase.z3.domain.valueobject; + +public record Message(Email to, String subject, String message) { + @SuppressWarnings("ConstantConditions") + public Message { + if (subject == null || subject.isBlank()) { + throw new IllegalStateException("Subject value cannot be null or empty"); + } + if (message == null || message.isBlank()) { + throw new IllegalStateException("Message value cannot be null or empty"); + } + } + + public static Message of(Email to, String subject, String message) { + return new Message(to, subject, message); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Password.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Password.java new file mode 100644 index 0000000..0363a94 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Password.java @@ -0,0 +1,39 @@ +package tech.pegase.z3.domain.valueobject; + +public record Password(String value) { + @SuppressWarnings("ConstantConditions") + public Password { + if (value == null || value.isBlank()) { + throw new IllegalStateException("le mot de passe ne peut être vide"); + } + if (value.length() < 8) { + throw new IllegalStateException("le mot de passe doit contenir au moins 8 caractères"); + } + if (!value.matches(containsMixedCase())) { + throw new IllegalStateException( + "le mot de passe doit contenir des majuscules et des minuscules"); + } + if (!value.matches(containsNumber())) { + throw new IllegalStateException("le mot de passe doit contenir des nombres"); + } + if (!value.matches(containsSpecialChar())) { + throw new IllegalStateException("le mot de passe doit contenir des caractères spéciaux"); + } + } + + public static Password of(String value) throws IllegalStateException { + return new Password(value); + } + + private String containsMixedCase() { + return "(?=.*[a-z])(?=.*[A-Z])[a-zA-Z0-9!-\\/:-@\\[-`\\{-~]{2,}"; + } + + private String containsNumber() { + return ".*[0-9]+.*"; + } + + private String containsSpecialChar() { + return ".*[!-\\/:-@\\[-`\\{-~]+.*"; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Role.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Role.java new file mode 100644 index 0000000..ee8b088 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Role.java @@ -0,0 +1,21 @@ +package tech.pegase.z3.domain.valueobject; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record Role(Role.AccessLevel value) implements Comparable { + + public static Role of(Role.AccessLevel value) throws IllegalStateException { + return new Role(value); + } + + @Override + public int compareTo(Role o) { + return value.compareTo(o.value()); + } + + public enum AccessLevel { + ADMINISTRATOR, + PUBLISHER + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Slug.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Slug.java new file mode 100644 index 0000000..ca270fc --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Slug.java @@ -0,0 +1,30 @@ +package tech.pegase.z3.domain.valueobject; + +import jakarta.persistence.Embeddable; +import java.util.regex.Pattern; + +@Embeddable +public record Slug(String value) { + + @SuppressWarnings("ConstantConditions") + public Slug { + if (value == null) { + throw new IllegalStateException("Value cannot be null"); + } + if (!isValidSlug(value)) { + throw new IllegalStateException("Invalid slug value '%s'".formatted(value)); + } + } + + public static Slug of(String value) throws IllegalStateException { + return new Slug(value); + } + + public static boolean isValidSlug(String value) { + if (value.isBlank()) { + return false; + } + var slugPattern = Pattern.compile("^[a-z0-9-]+$"); + return slugPattern.matcher(value).matches(); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/SubscriptionReport.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/SubscriptionReport.java new file mode 100644 index 0000000..f6644db --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/SubscriptionReport.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.domain.valueobject; + +import java.util.Set; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.User; + +public record SubscriptionReport(Set invalidLinks, Brand brand, User user) { + public SubscriptionReport { + var nonMatchingLinks = + invalidLinks.stream() + .anyMatch(v -> v.getBrand() == null || !v.getBrand().equals(brand.getUuid())); + if (nonMatchingLinks) { + throw new IllegalStateException("Some links do not match actual brandUuid"); + } + if (invalidLinks.isEmpty()) { + throw new IllegalStateException("Cannot create a report with no invalid links"); + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Target.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Target.java new file mode 100644 index 0000000..8f193b7 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/Target.java @@ -0,0 +1,59 @@ +package tech.pegase.z3.domain.valueobject; + +import jakarta.persistence.Embeddable; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; + +/** Has to be a valid http or https target. */ +@Embeddable +public record Target(String value) { + + @SuppressWarnings("ConstantConditions") + public Target { + if (value == null || value.isBlank()) { + throw new IllegalStateException("L'url ne peut être vide."); + } + assertIsValidUrl(value); + } + + public static Target of(String value) throws IllegalStateException { + return new Target(value); + } + + /** + * Assert target is valid using the recommended way by Java {@link java.net.URI + * parseServerAuthority}. + */ + private static URL getValidUrl(String url) throws IllegalStateException { + try { + var uri = new URI(url); + var parsedUrl = uri.parseServerAuthority().toURL(); + var scheme = parsedUrl.getProtocol(); + var userInfo = parsedUrl.getUserInfo(); + if (scheme == null || !scheme.equals("https")) { + throw new IllegalStateException( + "la cible n'utilise pas un protocole autorisé, une target avec https: est requise"); + } + if (userInfo != null && !userInfo.isBlank()) { + throw new IllegalStateException( + "Les informations utilisateur ne peuvent être acceptés dans une url"); + } + return parsedUrl; + } catch (URISyntaxException + | NullPointerException + | MalformedURLException + | IllegalArgumentException e) { + throw new IllegalStateException("la cible doit être une url valide"); + } + } + + public URL toUrl() { + return getValidUrl(value); + } + + private void assertIsValidUrl(String url) throws IllegalStateException { + getValidUrl(url); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Filtering.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Filtering.java new file mode 100644 index 0000000..68ad334 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Filtering.java @@ -0,0 +1,10 @@ +package tech.pegase.z3.domain.valueobject.concepts; + +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.shared.lib.ReadOnlyMap; + +public interface Filtering< + K extends FilteringField, V extends FilteringFieldValue<@Nullable VT>, VT> { + + ReadOnlyMap getFilters(); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/FilteringField.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/FilteringField.java new file mode 100644 index 0000000..3e8962e --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/FilteringField.java @@ -0,0 +1,6 @@ +package tech.pegase.z3.domain.valueobject.concepts; + +public interface FilteringField { + + String getFieldName(); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/FilteringFieldValue.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/FilteringFieldValue.java new file mode 100644 index 0000000..56f40e5 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/FilteringFieldValue.java @@ -0,0 +1,9 @@ +package tech.pegase.z3.domain.valueobject.concepts; + +import org.jspecify.annotations.NullUnmarked; + +@NullUnmarked +public interface FilteringFieldValue { + + U getFieldValue(); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Hash.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Hash.java new file mode 100644 index 0000000..b2459b1 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Hash.java @@ -0,0 +1,6 @@ +package tech.pegase.z3.domain.valueobject.concepts; + +public interface Hash { + + String getValue(); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Ordering.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Ordering.java new file mode 100644 index 0000000..8b11a6a --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Ordering.java @@ -0,0 +1,39 @@ +package tech.pegase.z3.domain.valueobject.concepts; + +public interface Ordering { + + /** + * The field used to order. + * + * @return The field used to marshall + */ + U getField(); + + /** + * The field name on which the search should be made. + * + * @return The field name used to make a search + */ + String getFieldName(); + + /** + * The direction used to marshall, `NATURAL` means no ordering. + * + * @return The wanted direction + */ + Direction getDirection(); + + enum Direction { + NATURAL, + ASC, + DESC; + + public static Direction of(String value) { + try { + return Direction.valueOf(value); + } catch (IllegalArgumentException e) { + throw new IllegalStateException(e); + } + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/OrderingField.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/OrderingField.java new file mode 100644 index 0000000..c00e2e8 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/OrderingField.java @@ -0,0 +1,6 @@ +package tech.pegase.z3.domain.valueobject.concepts; + +public interface OrderingField { + + String getFieldName(); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Searching.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Searching.java new file mode 100644 index 0000000..f958668 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/Searching.java @@ -0,0 +1,24 @@ +package tech.pegase.z3.domain.valueobject.concepts; + +public interface Searching { + + /** + * The field used to order. + * + * @return The field used to marshall + */ + U getField(); + + /** + * The keywords we should use to make a search. + * + * @return The keywords used to make a search + */ + String getSearchValue(); + + record SearchValue(String value) { + public static SearchValue of(String value) { + return new SearchValue(value); + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/SearchingField.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/SearchingField.java new file mode 100644 index 0000000..be61427 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/SearchingField.java @@ -0,0 +1,6 @@ +package tech.pegase.z3.domain.valueobject.concepts; + +public interface SearchingField { + + String getFieldName(); +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/package-info.java new file mode 100644 index 0000000..a674922 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/concepts/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.valueobject.concepts; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/errors/InvalidField.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/errors/InvalidField.java new file mode 100644 index 0000000..63ee8e4 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/errors/InvalidField.java @@ -0,0 +1,7 @@ +package tech.pegase.z3.domain.valueobject.errors; + +public record InvalidField(String field, String reason) { + public static InvalidField of(String field, String reason) { + return new InvalidField(field, reason); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/errors/InvalidFields.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/errors/InvalidFields.java new file mode 100644 index 0000000..f253ca5 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/errors/InvalidFields.java @@ -0,0 +1,15 @@ +package tech.pegase.z3.domain.valueobject.errors; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +public record InvalidFields(Set invalidFields) { + public static InvalidFields of(InvalidField... invalidFields) { + return of(Arrays.stream(invalidFields).collect(Collectors.toSet())); + } + + public static InvalidFields of(Set invalidFields) { + return new InvalidFields(invalidFields); + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/filterings/LinkFiltering.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/filterings/LinkFiltering.java new file mode 100644 index 0000000..a0520b5 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/filterings/LinkFiltering.java @@ -0,0 +1,77 @@ +package tech.pegase.z3.domain.valueobject.filterings; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.valueobject.concepts.Filtering; +import tech.pegase.z3.domain.valueobject.concepts.FilteringField; +import tech.pegase.z3.domain.valueobject.concepts.FilteringFieldValue; +import tech.pegase.z3.shared.lib.ReadOnlyMap; + +@NullMarked +public record LinkFiltering(ReadOnlyMap readOnlyFilterMap) + implements Filtering { + + public static LinkFiltering of(Map filters) { + var map = new HashMap(); + filters.forEach((key, value) -> map.put(Field.of(key), value != null ? Value.of(value) : null)); + return new LinkFiltering(new ReadOnlyMap<>(map)); + } + + public static LinkFiltering ofMap(Map filters) { + return new LinkFiltering(new ReadOnlyMap<>(filters)); + } + + public static LinkFiltering empty() { + return new LinkFiltering(new ReadOnlyMap<>(Map.of())); + } + + @Override + public ReadOnlyMap getFilters() { + return readOnlyFilterMap; + } + + public enum Field implements FilteringField { + BRAND, + CATALOG, + TAG, + IS_EXTERNAL, + IS_VALID; + + public static Field of(String value) { + try { + return Field.valueOf(value); + } catch (IllegalArgumentException e) { + throw new IllegalStateException(e); + } + } + + @Override + public String getFieldName() { + return switch (this) { + case BRAND -> "brand"; + case CATALOG -> "catalog"; + case TAG -> "tag"; + case IS_EXTERNAL -> "isExternal"; + case IS_VALID -> "validity"; + }; + } + } + + public record Value(@Nullable String value) implements FilteringFieldValue { + public static Value of(@Nullable String value) { + return new Value(value); + } + + public static Value of(@Nullable UUID value) { + return new Value(value != null ? value.toString() : null); + } + + @Override + public @Nullable String getFieldValue() { + return value; + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/hashes/PasswordHash.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/hashes/PasswordHash.java new file mode 100644 index 0000000..f0e8daf --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/hashes/PasswordHash.java @@ -0,0 +1,32 @@ +package tech.pegase.z3.domain.valueobject.hashes; + +import jakarta.persistence.Embeddable; +import tech.pegase.z3.domain.valueobject.concepts.Hash; + +@Embeddable +public record PasswordHash(String value) implements Hash { + + @SuppressWarnings("ConstantConditions") + public PasswordHash { + if (value == null || value.isBlank()) { + throw new IllegalStateException("Password hash value cannot be blank"); + } + if (!value.matches(containsHashedValue())) { + throw new IllegalStateException( + "Password hash value may look like $...$...$... and be a hash"); + } + } + + public static PasswordHash of(String value) throws IllegalStateException { + return new PasswordHash(value); + } + + @Override + public String getValue() { + return value(); + } + + private String containsHashedValue() { + return "^\\$.+\\$.+\\$.+\\$?"; + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/hashes/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/hashes/package-info.java new file mode 100644 index 0000000..9d14288 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/hashes/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.valueobject.hashes; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/orderings/LinkOrdering.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/orderings/LinkOrdering.java new file mode 100644 index 0000000..f65544a --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/orderings/LinkOrdering.java @@ -0,0 +1,56 @@ +package tech.pegase.z3.domain.valueobject.orderings; + +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.valueobject.concepts.Ordering; +import tech.pegase.z3.domain.valueobject.concepts.OrderingField; + +@NullMarked +public record LinkOrdering(LinkOrdering.Field field, LinkOrdering.Direction direction) + implements Ordering { + + public static LinkOrdering of(LinkOrdering.Field field, LinkOrdering.Direction direction) { + return new LinkOrdering(field, direction); + } + + @Override + public LinkOrdering.Field getField() { + return field; + } + + @Override + public String getFieldName() { + return field.getFieldName(); + } + + @Override + public LinkOrdering.Direction getDirection() { + return direction; + } + + public enum Field implements OrderingField { + SOURCE, + VISITS, + IS_EXTERNAL, + VALIDITY, + CREATED_AT; + + public static Field of(String value) { + try { + return Field.valueOf(value); + } catch (IllegalArgumentException e) { + throw new IllegalStateException(e); + } + } + + @Override + public String getFieldName() { + return switch (this) { + case SOURCE -> "source"; + case VISITS -> "visits"; + case IS_EXTERNAL -> "isExternal"; + case VALIDITY -> "validity"; + case CREATED_AT -> "createdAt"; + }; + } + } +} diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/package-info.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/package-info.java new file mode 100644 index 0000000..26d8a33 --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.domain.valueobject; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/searchings/LinkSearching.java b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/searchings/LinkSearching.java new file mode 100644 index 0000000..b46a91d --- /dev/null +++ b/z3-domain/src/main/java/tech/pegase/z3/domain/valueobject/searchings/LinkSearching.java @@ -0,0 +1,45 @@ +package tech.pegase.z3.domain.valueobject.searchings; + +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.valueobject.concepts.Searching; +import tech.pegase.z3.domain.valueobject.concepts.SearchingField; + +@NullMarked +public record LinkSearching(LinkSearching.Field field, LinkSearching.SearchValue value) + implements Searching { + + public static LinkSearching of(LinkSearching.Field field, LinkSearching.SearchValue value) { + return new LinkSearching(field, value); + } + + @Override + public LinkSearching.Field getField() { + return field; + } + + @Override + public String getSearchValue() { + return value.value(); + } + + public enum Field implements SearchingField { + SOURCE, + TARGET; + + public static Field of(String value) { + try { + return Field.valueOf(value); + } catch (IllegalArgumentException e) { + throw new IllegalStateException(e); + } + } + + @Override + public String getFieldName() { + return switch (this) { + case SOURCE -> "source.value"; + case TARGET -> "target.value"; + }; + } + } +} diff --git a/z3-domain/src/main/resources/META-INF/beans.xml b/z3-domain/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000..e69de29 diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/entity/BrandTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/BrandTest.java new file mode 100644 index 0000000..1391ca5 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/BrandTest.java @@ -0,0 +1,51 @@ +package tech.pegase.z3.domain.entity; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; + +public class BrandTest { + @Test + public void fieldsAreRequired() { + Assertions.assertDoesNotThrow(this::getFaked); + Assertions.assertThrows(IllegalStateException.class, () -> getFaked().setName("")); + } + + @Test + public void canGetId() { + // Arrange + var slug = Slug.of("slug"); + var brand = new Brand(Slug.of("slug"), "brand"); + + // Act + var output = brand.getSlug(); + + // Assert + Assertions.assertEquals(slug, output); + } + + @Test + public void canUseGettersAndSetters() { + // Assert + Assertions.assertDoesNotThrow( + () -> { + // Arrange + var brand = new Brand(Slug.of("slug"), "brand"); + + // Act + brand.getId(); + brand.getUuid(); + brand.getName(); + brand.getSlug(); + brand.setSlug(Slug.of("slug2")); + brand.setName("new brand"); + brand.assignFqdn(Fqdn.of("example.net"), fqdn -> true); + brand.getFqdns(); + }); + } + + private Brand getFaked() { + return new Brand(Slug.of("slug"), "name"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/entity/CatalogTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/CatalogTest.java new file mode 100644 index 0000000..e467af6 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/CatalogTest.java @@ -0,0 +1,49 @@ +package tech.pegase.z3.domain.entity; + +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.Slug; + +public class CatalogTest { + @Test + public void fieldsAreRequired() { + Assertions.assertDoesNotThrow(this::getFaked); + Assertions.assertThrows(IllegalStateException.class, () -> getFaked().setName("")); + } + + @Test + public void canGetId() { + // Arrange + var slug = Slug.of("slug"); + var catalog = new Catalog(slug, "catalog", UUID.randomUUID()); + + // Act + var output = catalog.getSlug(); + + // Assert + Assertions.assertEquals(slug, output); + } + + @Test + public void canUseGettersAndSetters() { + // Assert + Assertions.assertDoesNotThrow( + () -> { + // Arrange + var catalog = new Catalog(Slug.of("slug"), "catalog", UUID.randomUUID()); + + // Act + catalog.getId(); + catalog.getBrandUuid(); + catalog.getName(); + catalog.getSlug(); + catalog.setSlug(Slug.of("slug2")); + catalog.setName("new catalog"); + }); + } + + private Catalog getFaked() { + return new Catalog(Slug.of("slug"), "name", UUID.randomUUID()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/entity/CredentialTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/CredentialTest.java new file mode 100644 index 0000000..8610ce0 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/CredentialTest.java @@ -0,0 +1,56 @@ +package tech.pegase.z3.domain.entity; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +public class CredentialTest { + @Test + public void fieldsAreRequired() { + Assertions.assertDoesNotThrow(this::getFaked); + } + + @Test + public void canGetId() { + // Arrange + var login = getFakedLoginField(); + var reportingSubscription = new Credential(login, getFakedPasswordHashField()); + + // Act + var output = reportingSubscription.getLogin(); + + // Assert + Assertions.assertEquals(login, output); + } + + @Test + public void canUseGettersAndSetters() { + // Assert + Assertions.assertDoesNotThrow( + () -> { + // Arrange + var credential = getFaked(); + + // Act + credential.getId(); + credential.getUuid(); + credential.getLogin(); + credential.getPassword(); + credential.setPassword(getFakedPasswordHashField()); + credential.setLogin(Login.of("user.net")); + }); + } + + private Credential getFaked() { + return new Credential(getFakedLoginField(), getFakedPasswordHashField()); + } + + private Login getFakedLoginField() { + return Login.of("asdqwdqdqwdq"); + } + + private PasswordHash getFakedPasswordHashField() { + return PasswordHash.of("$argon2id$v=19$m=16,t=2,p=1$YXNkYXNkd3c$XdkPhIGM1WlYM65rZ4V9og"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/entity/LinkTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/LinkTest.java new file mode 100644 index 0000000..b3a0665 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/LinkTest.java @@ -0,0 +1,86 @@ +package tech.pegase.z3.domain.entity; + +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public class LinkTest { + @Test + public void fieldsAreRequired() { + Assertions.assertDoesNotThrow(this::getFaked); + } + + @Test + public void canGetId() { + // Arrange + var source = Source.of("source"); + var link = new Link(source, Target.of("https://example.com"), true, null, null, null); + + // Act + var output = link.getId(); + + // Assert + Assertions.assertEquals(source, output.value()); + } + + @Test + public void canIncrementVisits() { + // Arrange + var source = Source.of("source"); + var link = new Link(source, Target.of("https://example.com"), true, null, null, null); + var visits = link.getVisits(); + + // Act + link.incVisits(); + + // Assert + Assertions.assertEquals(0, visits); + Assertions.assertEquals(1, link.getVisits()); + } + + @Test + public void canCreateLinkWithItsExternalStatus() { + Assertions.assertDoesNotThrow( + () -> + new Link( + Source.of("source"), Target.of("https://example.com"), true, null, null, null)); + } + + @Test + public void canCreateLinkWithBrand() { + Assertions.assertDoesNotThrow( + () -> + new Link( + Source.of("source"), + Target.of("https://example.com"), + true, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID())); + } + + @Test + public void canSetAndGetLinkValidity() { + var link = getFaked(); + Assertions.assertEquals( + LinkValidity.unknown(), link.getValidity(), "Link should not be valid when created"); + Assertions.assertDoesNotThrow(() -> link.setValidity(LinkValidity.valid())); + Assertions.assertDoesNotThrow(() -> link.setTag(UUID.randomUUID())); + Assertions.assertDoesNotThrow(link::removeTag); + Assertions.assertEquals( + LinkValidity.valid(), link.getValidity(), "Link should be valid when set as valid"); + } + + private Link getFaked() { + return new Link( + Source.of("source"), + Target.of("https://example.com"), + true, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/entity/ReportingSubscriptionTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/ReportingSubscriptionTest.java new file mode 100644 index 0000000..f1c6e7f --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/ReportingSubscriptionTest.java @@ -0,0 +1,33 @@ +package tech.pegase.z3.domain.entity; + +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ReportingSubscriptionTest { + @Test + public void fieldsAreRequired() { + Assertions.assertDoesNotThrow(this::getFaked); + Assertions.assertDoesNotThrow(() -> getFaked().getId()); + Assertions.assertDoesNotThrow(() -> getFaked().getUser()); + Assertions.assertDoesNotThrow(() -> getFaked().getBrand()); + } + + @Test + public void canGetId() { + // Arrange + var userUuid = UUID.randomUUID(); + var brandUuid = UUID.randomUUID(); + var reportingSubscription = new ReportingSubscription(userUuid, brandUuid); + + // Act + var output = reportingSubscription.getUser(); + + // Assert + Assertions.assertEquals(userUuid, output); + } + + private ReportingSubscription getFaked() { + return new ReportingSubscription(UUID.randomUUID(), UUID.randomUUID()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/entity/TagTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/TagTest.java new file mode 100644 index 0000000..b2965bb --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/TagTest.java @@ -0,0 +1,31 @@ +package tech.pegase.z3.domain.entity; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.Color; + +public class TagTest { + @Test + public void fieldsAreRequired() { + Assertions.assertDoesNotThrow(this::getFaked); + Assertions.assertDoesNotThrow(() -> getFaked().getId()); + Assertions.assertThrows(IllegalStateException.class, () -> getFaked().setName("")); + } + + @Test + public void canGetId() { + // Arrange + var name = "tag"; + var tag = new Tag(name, Color.of("#fff")); + + // Act + var output = tag.getName(); + + // Assert + Assertions.assertEquals(name, output); + } + + private Tag getFaked() { + return new Tag("name", Color.of("#FFF")); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/entity/UserTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/UserTest.java new file mode 100644 index 0000000..a55551a --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/UserTest.java @@ -0,0 +1,122 @@ +package tech.pegase.z3.domain.entity; + +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.Role.AccessLevel; + +public class UserTest { + @Test + public void fieldsAreRequired() { + Assertions.assertDoesNotThrow(this::getFaked); + Assertions.assertThrows( + IllegalStateException.class, + () -> + new User( + "", + getFakedStringField("lastName"), + getFakedEmailField("mail@example.net"), + Role.of(AccessLevel.PUBLISHER), + getFakedUuidField())); + Assertions.assertThrows( + IllegalStateException.class, + () -> + new User( + getFakedStringField("firstName"), + "", + getFakedEmailField("email@example.net"), + Role.of(AccessLevel.PUBLISHER), + getFakedUuidField())); + Assertions.assertThrows( + IllegalStateException.class, + () -> { + var user = getFaked(); + user.setFirstName(""); + }); + Assertions.assertThrows( + IllegalStateException.class, + () -> { + var user = getFaked(); + user.setLastName(""); + }); + } + + @Test + public void canGetId() { + // Arrange + var firstname = "firstname"; + var user = + new User( + firstname, + "lastname", + Email.of("user@example.net"), + Role.of(AccessLevel.PUBLISHER), + UUID.randomUUID()); + + // Act + var output = user.getFirstName(); + + // Assert + Assertions.assertEquals(firstname, output); + } + + @Test + public void canUseGettersAndSetters() { + // Assert + Assertions.assertDoesNotThrow( + () -> { + // Arrange + var user = + new User( + "firstname", + "lastname", + Email.of("user@example.net"), + Role.of(AccessLevel.PUBLISHER), + UUID.randomUUID()); + + // Act + user.getId(); + user.getUuid(); + user.getRole(); + user.getBrands(); + user.getEmail(); + user.getCredentialUuid(); + user.getFirstName(); + user.getLastName(); + user.setBrands(Set.of()); + user.setBrands(Set.of(UUID.randomUUID())); + user.setEmail(Email.of("user2@example.net")); + user.setRole(Role.of(AccessLevel.ADMINISTRATOR)); + user.setLastName("firstname2"); + user.setFirstName("lastname2"); + }); + } + + private User getFaked() { + return new User( + getFakedStringField("firstName"), + getFakedStringField("lastName"), + getFakedEmailField("mail@example.net"), + Role.of(AccessLevel.PUBLISHER), + getFakedUuidField()); + } + + private UUID getFakedUuidField() { + return UUID.fromString("550e8400-e29b-41d4-a716-446655440000"); + } + + private Email getFakedEmailField(String value) { + return Email.of(value); + } + + private String getFakedStringField(String fieldName) { + return switch (fieldName) { + case "firstName" -> "firstName"; + case "lastName" -> "lastName"; + default -> throw new IllegalStateException("Unexpected value: '%s'".formatted(fieldName)); + }; + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/entity/converter/StringCryptoConverterTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/converter/StringCryptoConverterTest.java new file mode 100644 index 0000000..874ef69 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/entity/converter/StringCryptoConverterTest.java @@ -0,0 +1,101 @@ +package tech.pegase.z3.domain.entity.converter; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.converters.StringCryptoConverter; +import tech.pegase.z3.domain.port.service.EncryptionProvider; +import tech.pegase.z3.domain.port.service.EncryptionProvider.CryptException; + +public class StringCryptoConverterTest { + private final StringCryptoConverter stringCryptoConverter; + private final EncryptionProvider encryptionProvider; + + StringCryptoConverterTest() throws NoSuchFieldException, IllegalAccessException { + this.stringCryptoConverter = new StringCryptoConverter(); + this.encryptionProvider = Mockito.mock(EncryptionProvider.class); + + var field = StringCryptoConverter.class.getDeclaredField("encryptionProvider"); + field.setAccessible(true); + field.set(stringCryptoConverter, encryptionProvider); + } + + @Test + public void canBeTested() throws NoSuchFieldException, IllegalAccessException { + // Arrange + var encryptionProviderField = + StringCryptoConverter.class.getDeclaredField("encryptionProvider"); + encryptionProviderField.setAccessible(true); + var encryptionProviderStaticField = + StringCryptoConverter.class.getDeclaredField("encryptionProvider"); + encryptionProviderStaticField.setAccessible(true); + + // Act + stringCryptoConverter.init(); + + // Assert + Assertions.assertNotNull(encryptionProviderField.get(stringCryptoConverter)); + Assertions.assertNotNull(encryptionProviderStaticField.get(stringCryptoConverter)); + } + + @Test + public void canConvertToDatabaseColumn() throws CryptException { + // Arrange + var fieldToConvert = "someValue"; + var fieldConverted = "encryptedValue"; + Mockito.when(encryptionProvider.encrypt(Mockito.any(String.class))).thenReturn(fieldConverted); + + // Act + var output = stringCryptoConverter.convertToDatabaseColumn(fieldToConvert); + + // Assert + Assertions.assertEquals(fieldConverted, output); + } + + @Test + public void canFailToConvertToDatabaseColumn() { + // ASSERT + Assertions.assertThrows( + RuntimeException.class, + () -> { + // Arrange + var fieldToConvert = "someValue"; + Mockito.doThrow(CryptException.class) + .when(encryptionProvider) + .encrypt(Mockito.any(String.class)); + + // Act + stringCryptoConverter.convertToDatabaseColumn(fieldToConvert); + }); + } + + @Test + public void canConvertToEntityAttribute() throws CryptException { + // Arrange + var fieldToConvert = "encryptedValue"; + var fieldConverted = "someValue"; + Mockito.when(encryptionProvider.decrypt(Mockito.any(String.class))).thenReturn(fieldConverted); + + // Act + var output = stringCryptoConverter.convertToEntityAttribute(fieldToConvert); + + // Assert + Assertions.assertEquals(fieldConverted, output); + } + + @Test + public void canFailToConvertToEntityAttribute() throws CryptException { + // ASSERT + Assertions.assertThrows( + RuntimeException.class, + () -> { + // ARRANGE + var fieldToConvert = "encryptedValue"; + Mockito.doThrow(CryptException.class) + .when(encryptionProvider) + .decrypt(Mockito.any(String.class)); + // ACT + stringCryptoConverter.convertToEntityAttribute(fieldToConvert); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/service/DomainNameServiceTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/service/DomainNameServiceTest.java new file mode 100644 index 0000000..a322e9d --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/service/DomainNameServiceTest.java @@ -0,0 +1,89 @@ +package tech.pegase.z3.domain.service; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.valueobject.Fqdn; + +public class DomainNameServiceTest { + private final BrandRepository brandRepository; + private final DomainNameService domainNameService; + + DomainNameServiceTest() { + this.brandRepository = Mockito.mock(BrandRepository.class); + this.domainNameService = new DomainNameServiceImpl(brandRepository); + } + + @Test + public void fqdnCanBeAssignedToBrandWhenNotOwnedByAnyOne() { + // GIVEN + var brandUuid = UUID.randomUUID(); + var brandFqdn1 = Fqdn.of("example.com"); + var brand = Mockito.mock(Brand.class); + + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + + Mockito.when(brandRepository.findByFqdn(brandFqdn1)).thenReturn(Optional.empty()); + + // WHEN + var output = domainNameService.canFqdnBeAssignedToBrand(brandFqdn1, brand); + + // THEN + Mockito.verify(brandRepository).findByFqdn(brandFqdn1); + + Assertions.assertTrue(output); + } + + @Test + public void fqdnCanBeAssignedToBrandWhenAlreadyOwnedByTheBrand() { + // GIVEN + var brandUuid = UUID.randomUUID(); + var brandFqdn1 = Fqdn.of("example.com"); + var brandFqdnSet = Set.of(brandFqdn1); + var brand = Mockito.mock(Brand.class); + + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + Mockito.when(brand.getFqdns()).thenReturn(brandFqdnSet); + + Mockito.when(brandRepository.findByFqdn(brandFqdn1)).thenReturn(Optional.of(brand)); + + // WHEN + var output = domainNameService.canFqdnBeAssignedToBrand(brandFqdn1, brand); + + // THEN + Mockito.verify(brandRepository).findByFqdn(brandFqdn1); + + Assertions.assertTrue(output); + } + + @Test + public void fqdnCannotBeAssignedToBrandWhenAlreadyOwnedByAnotherBrand() { + // GIVEN + var brandUuid = UUID.randomUUID(); + var brandFqdn1 = Fqdn.of("example.com"); + var brandFqdnSet = Set.of(brandFqdn1); + var brand = Mockito.mock(Brand.class); + var anotherBrandUuid = UUID.randomUUID(); + var anotherBrand = Mockito.mock(Brand.class); + + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + Mockito.when(brand.getFqdns()).thenReturn(brandFqdnSet); + + Mockito.when(anotherBrand.getUuid()).thenReturn(anotherBrandUuid); + + Mockito.when(brandRepository.findByFqdn(brandFqdn1)).thenReturn(Optional.of(anotherBrand)); + + // WHEN + var output = domainNameService.canFqdnBeAssignedToBrand(brandFqdn1, brand); + + // THEN + Mockito.verify(brandRepository).findByFqdn(brandFqdn1); + + Assertions.assertFalse(output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/service/VerifyLinkServiceTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/service/VerifyLinkServiceTest.java new file mode 100644 index 0000000..533ff98 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/service/VerifyLinkServiceTest.java @@ -0,0 +1,138 @@ +package tech.pegase.z3.domain.service; + +import io.smallrye.mutiny.TimeoutException; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import java.net.URL; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.jboss.logging.Logger; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.service.UrlChecker; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public class VerifyLinkServiceTest { + private final VerifyLinkService verifyLinkService; + private final LinkRepository linkRepository; + private final UrlChecker urlChecker; + private final Logger logger; + + public VerifyLinkServiceTest() throws Exception { + this.linkRepository = Mockito.mock(LinkRepository.class); + this.urlChecker = Mockito.mock(UrlChecker.class); + this.verifyLinkService = new VerifyLinkServiceImpl(linkRepository, urlChecker); + + // Mock logger and set it accessible for test in order to silent logger while testing + this.logger = Mockito.mock(Logger.class); + var field = FieldUtils.getDeclaredField(VerifyLinkServiceImpl.class, "logger", true); + field.trySetAccessible(); + FieldUtils.removeFinalModifier(field); + field.set(verifyLinkService, logger); + } + + @Test + public void canVerifyLink() { + // Arrange + var link = getNewLink(); + var verifyResult = Uni.createFrom().item(true); + Mockito.when(urlChecker.verify(Mockito.any(URL.class))).thenReturn(verifyResult); + Mockito.doNothing().when(linkRepository).update(Mockito.any(Link.class)); + Mockito.doNothing().when(logger).debug(Mockito.any(Link.class)); + + // Act + verifyLinkService.verifyAndPersistValidity(link); + + // Assert + Mockito.verify(urlChecker).verify(Mockito.any(URL.class)); + Mockito.verify(linkRepository).update(Mockito.any(Link.class)); + } + + @Test + public void canVerifyLinkWhenInvalidUrl() { + // Arrange + var link = getNewLink(); + var verifyResult = Uni.createFrom().item(false); + Mockito.when(urlChecker.verify(Mockito.any(URL.class))).thenReturn(verifyResult); + Mockito.doNothing().when(linkRepository).update(Mockito.any(Link.class)); + Mockito.doNothing().when(logger).debug(Mockito.any(Link.class)); + + // Act + verifyLinkService.verifyAndPersistValidity(link); + + // Assert + Mockito.verify(urlChecker).verify(Mockito.any(URL.class)); + Mockito.verify(linkRepository).update(Mockito.any(Link.class)); + } + + @Test + public void canHandleErrorOnVerifyLink() { + // Arrange + var link = getNewLink(); + var originalLinkValidity = link.getValidity(); + var verifyResult = + Uni.createFrom() + .item(true) + .chain( + Unchecked.function( + v -> { + throw new CompletionException("failed", new RuntimeException()); + })); + Mockito.when(urlChecker.verify(Mockito.any(URL.class))).thenReturn(verifyResult); + Mockito.doNothing().when(linkRepository).update(Mockito.any(Link.class)); + Mockito.doNothing().when(logger).warn(Mockito.any(String.class), Mockito.any(Throwable.class)); + + // Act + verifyLinkService.verifyAndPersistValidity(link); + + // Assert + Mockito.verify(urlChecker).verify(Mockito.any(URL.class)); + Mockito.verify(linkRepository).update(Mockito.any(Link.class)); + Assertions.assertEquals(originalLinkValidity, link.getValidity()); + } + + @Test + public void canHandleTimeoutErrorOnVerifyLink() { + // Arrange + var link = getNewLink(); + link.setValidity(LinkValidity.unknown()); + var verifyResult = + Uni.createFrom() + .item(true) + .chain( + Unchecked.function( + v -> { + throw new TimeoutException(); + })); + Mockito.when(urlChecker.verify(Mockito.any(URL.class))).thenReturn(verifyResult); + Mockito.doNothing().when(linkRepository).update(Mockito.any(Link.class)); + Mockito.doNothing().when(logger).warn(Mockito.any(String.class)); + + // Act + verifyLinkService.verifyAndPersistValidity(link); + + // Assert + Mockito.verify(urlChecker).verify(Mockito.any(URL.class)); + Mockito.verify(linkRepository).update(Mockito.any(Link.class)); + Assertions.assertEquals( + LinkValidity.invalid(), + link.getValidity(), + "Link should be invalid if a timeout occurs while checking"); + } + + private Link getNewLink() { + return new Link( + Source.of("a_new_link"), + Target.of("https://example.net"), + true, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUserTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUserTest.java new file mode 100644 index 0000000..0eec0d1 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/auth/AuthenticateUserTest.java @@ -0,0 +1,131 @@ +package tech.pegase.z3.domain.usecase.auth; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.port.service.JwtGenerator; +import tech.pegase.z3.domain.usecase.auth.AuthenticateUser.Input; +import tech.pegase.z3.domain.usecase.auth.AuthenticateUser.Output; +import tech.pegase.z3.domain.valueobject.JwtToken; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +public class AuthenticateUserTest { + private final JwtGenerator jwtGenerator; + private final UserRepository userRepository; + private final CredentialRepository credentialRepository; + private final HashProvider hashProvider; + private final AuthenticateUser authenticateUser; + + AuthenticateUserTest() { + this.jwtGenerator = Mockito.mock(JwtGenerator.class); + this.userRepository = Mockito.mock(UserRepository.class); + this.credentialRepository = Mockito.mock(CredentialRepository.class); + this.hashProvider = Mockito.mock(HashProvider.class); + this.authenticateUser = + new AuthenticateUserImpl(userRepository, credentialRepository, jwtGenerator, hashProvider); + } + + @Test + public void canAuthenticateUser() { + // GIVEN + var login = "user.net"; + var password = "P4ssw0rd."; + var passwordHash = new PasswordHash("$pass$word$hash"); + var credentialUuid = UUID.randomUUID(); + var credential = Mockito.mock(Credential.class); + var user = Mockito.mock(User.class); + var jwtToken = new JwtToken("jwtToken"); + + Mockito.when(credential.getUuid()).thenReturn(credentialUuid); + Mockito.when(credential.getPassword()).thenReturn(passwordHash); + + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.of(credential)); + Mockito.when(userRepository.findByCredentialUuid(Mockito.any(UUID.class))) + .thenReturn(Optional.of(user)); + Mockito.when(hashProvider.areEquals(passwordHash, password)).thenReturn(true); + Mockito.when(jwtGenerator.generateJwt(user)).thenReturn(jwtToken); + + // WHEN + var output = authenticateUser.run(new Input.Authenticate(login, password)); + + // THEN + Mockito.verify(jwtGenerator).generateJwt(user); + Assertions.assertInstanceOf(Output.Valid.class, output); + } + + @Test + public void cannotAuthenticateUserIfCredentialsIsNotFound() { + // GIVEN + var login = "user.net"; + var password = "P4ssw0rd."; + + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.empty()); + + // WHEN + var output = authenticateUser.run(new Input.Authenticate(login, password)); + + // THEN + Mockito.verifyNoInteractions(jwtGenerator); + Assertions.assertInstanceOf(Output.Invalid.class, output); + } + + @Test + public void cannotAuthenticateIfUserIsNotFound() { + // GIVEN + var login = "user.net"; + var password = "P4ssw0rd."; + var passwordHash = new PasswordHash("$pass$word$hash"); + var credentialUuid = UUID.randomUUID(); + var credential = Mockito.mock(Credential.class); + + Mockito.when(credential.getUuid()).thenReturn(credentialUuid); + Mockito.when(credential.getPassword()).thenReturn(passwordHash); + + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.of(credential)); + + // WHEN + var output = authenticateUser.run(new Input.Authenticate(login, password)); + + // THEN + Mockito.verifyNoInteractions(jwtGenerator); + Assertions.assertInstanceOf(Output.Invalid.class, output); + } + + @Test + public void cannotAuthenticateIfUserIfPasswordDoesNotMatch() { + // GIVEN + var login = "user.net"; + var password = "P4ssw0rd."; + var passwordHash = new PasswordHash("$pass$word$hash"); + var credentialUuid = UUID.randomUUID(); + var credential = Mockito.mock(Credential.class); + var user = Mockito.mock(User.class); + + Mockito.when(credential.getUuid()).thenReturn(credentialUuid); + Mockito.when(credential.getPassword()).thenReturn(passwordHash); + + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.of(credential)); + Mockito.when(userRepository.findByCredentialUuid(Mockito.any(UUID.class))) + .thenReturn(Optional.of(user)); + Mockito.when(hashProvider.areEquals(passwordHash, password)).thenReturn(false); + + // WHEN + var output = authenticateUser.run(new Input.Authenticate(login, password)); + + // THEN + Mockito.verifyNoInteractions(jwtGenerator); + Assertions.assertInstanceOf(Output.Invalid.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/CreateBrandTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/CreateBrandTest.java new file mode 100644 index 0000000..239ad35 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/CreateBrandTest.java @@ -0,0 +1,176 @@ +package tech.pegase.z3.domain.usecase.brands; + +import io.quarkus.arc.impl.Reflections; +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.service.DomainNameService; +import tech.pegase.z3.domain.service.DomainNameServiceImpl; +import tech.pegase.z3.domain.usecase.brands.CreateBrand.Input; +import tech.pegase.z3.domain.usecase.brands.CreateBrand.Input.With; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; + +public class CreateBrandTest { + private BrandRepository brandRepository; + private Slugifyier slugifyierService; + private CreateBrand createBrandUseCase; + private DomainNameService domainNameService; + + @BeforeEach + public void setup() + throws IllegalAccessException, InvocationTargetException, InstantiationException { + brandRepository = Mockito.mock(BrandRepository.class); + slugifyierService = Mockito.mock(Slugifyier.class); + var domainNameServiceConstructor = + Reflections.findConstructor(DomainNameServiceImpl.class, BrandRepository.class); + domainNameServiceConstructor.setAccessible(true); + domainNameService = + Mockito.spy((DomainNameService) domainNameServiceConstructor.newInstance(brandRepository)); + + createBrandUseCase = new CreateBrandImpl(slugifyierService, brandRepository, domainNameService); + } + + @Test + public void testCreateBrandWithNameShouldSucceed() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var name = "My Catalog 2000"; + var fqdn = Fqdn.of("example.net"); + var fqdnSet = Set.of(fqdn); + + Mockito.when(slugifyierService.slugify(Mockito.anyString())).thenReturn(slug); + Mockito.when( + domainNameService.canFqdnBeAssignedToBrand( + Mockito.any(Fqdn.class), Mockito.any(Brand.class))) + .thenReturn(true); + Mockito.when(brandRepository.findBySlug(slug)).thenReturn(Optional.empty()); + Mockito.doNothing().when(brandRepository).persist(Mockito.any(Brand.class)); + + // Act + var output = createBrandUseCase.run(new Input.With(name, fqdnSet)); + + // Assert + Mockito.verify(brandRepository, Mockito.times(1)).persist(Mockito.any(Brand.class)); + Mockito.verify(domainNameService) + .canFqdnBeAssignedToBrand(Mockito.any(Fqdn.class), Mockito.any(Brand.class)); + + Assertions.assertInstanceOf( + CreateBrand.Output.Created.class, output, "Given a name, a brand should be created"); + var createdOutput = (CreateBrand.Output.Created) output; + Assertions.assertEquals( + slug, + createdOutput.brand().slug(), + "Create brand use case should return the same slug as input"); + Assertions.assertEquals( + name, + createdOutput.brand().name(), + "Create brand use case should return the same name as input"); + } + + @Test + public void createBrandWithNameWhenSlugAlreadyExistsShouldFail() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var name = "My Catalog 2000"; + var fqdnSet = Set.of(Fqdn.of("example.net")); + var brand = new Brand(slug, name); + + Mockito.when(slugifyierService.slugify(Mockito.anyString())).thenReturn(slug); + Mockito.when(brandRepository.findBySlug(Mockito.any(Slug.class))) + .thenReturn(Optional.of(brand)); + + // Act + var output = createBrandUseCase.run(new With(name, fqdnSet)); + + // Assert + Assertions.assertInstanceOf( + CreateBrand.Output.NotCreated.class, + output, + "Given a name, a brand should not be created when slug is already used"); + } + + @Test + public void createBrandWithEmptyNameShouldFail() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var fqdnSet = Set.of(Fqdn.of("example.net")); + Mockito.when(slugifyierService.slugify(Mockito.anyString())).thenReturn(slug); + + // Act + var output = createBrandUseCase.run(new With("", fqdnSet)); + + // Assert + Assertions.assertInstanceOf( + CreateBrand.Output.NotCreated.class, + output, + "Given a name, a brand should not be created when given name is empty"); + } + + @Test + public void createBrandWithEmptySluggedNameShouldFail() { + // Arrange + var fqdnSet = Set.of(Fqdn.of("example.net")); + Mockito.when(slugifyierService.slugify(Mockito.anyString())) + .thenThrow(IllegalArgumentException.class); + + // Act + var output = createBrandUseCase.run(new With("--", fqdnSet)); + + // Assert + Assertions.assertInstanceOf( + CreateBrand.Output.NotCreated.class, + output, + "Given a name, a brand should not be created when slugged name is empty"); + } + + @Test + public void cannotCreateBrandWithAnAlreadyExistingDomainName() { + // GIVEN + var slug = new Slug("my-catalog-2000"); + var name = "My Catalog 2000"; + var fqdn = Fqdn.of("example.net"); + var fqdnSet = Set.of(fqdn); + + var existingBrandUuid = UUID.randomUUID(); + var existingBrandFqdnSet = Set.of(Fqdn.of("example.net")); + var existingBrand = Mockito.mock(Brand.class); + + Mockito.when(existingBrand.getUuid()).thenReturn(existingBrandUuid); + Mockito.when(existingBrand.getFqdns()).thenReturn(existingBrandFqdnSet); + + Mockito.when(slugifyierService.slugify(Mockito.anyString())).thenReturn(slug); + + Mockito.when(brandRepository.findBySlug(Mockito.any(Slug.class))).thenReturn(Optional.empty()); + Mockito.doNothing().when(brandRepository).persist(Mockito.any(Brand.class)); + Mockito.doNothing().when(brandRepository).persist(Mockito.any(Brand.class)); + Mockito.when(brandRepository.findByFqdn(fqdn)).thenReturn(Optional.of(existingBrand)); + + // WHEN + var output = createBrandUseCase.run(new Input.With(name, fqdnSet)); + + // THEN + Mockito.verify(domainNameService) + .canFqdnBeAssignedToBrand(Mockito.any(Fqdn.class), Mockito.any(Brand.class)); + + Assertions.assertInstanceOf( + CreateBrand.Output.NotCreated.class, + output, + "A Brand should not be created with an already existing domain name"); + var failureOutput = (CreateBrand.Output.NotCreated) output; + var failureReasons = failureOutput.reasons().invalidFields(); + var firstFailure = failureReasons.iterator().next(); + Assertions.assertEquals( + "Les noms de domaines suivants sont déja utilisés : example.net", + firstFailure.reason(), + "Cannot reuse domain names for other brands"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/GetBrandTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/GetBrandTest.java new file mode 100644 index 0000000..05ee5f7 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/GetBrandTest.java @@ -0,0 +1,123 @@ +package tech.pegase.z3.domain.usecase.brands; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.usecase.brands.GetBrand.Input; +import tech.pegase.z3.domain.usecase.brands.GetBrand.Output; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; + +public class GetBrandTest { + private BrandRepository brandRepository; + private GetBrandImpl getBrandUseCase; + + @BeforeEach + public void setup() { + brandRepository = Mockito.mock(BrandRepository.class); + getBrandUseCase = new GetBrandImpl(brandRepository); + } + + @Test + public void getBrandUsingSlugCanBeFound() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var name = "My Brand 2000"; + var brand = new Brand(slug, name); + + var domainNameFqdn = Fqdn.of("example.net"); + brand.assignFqdn(domainNameFqdn, (fqdn -> true)); + + Mockito.when(brandRepository.findBySlug(slug)).thenReturn(Optional.of(brand)); + + // Act + var output = getBrandUseCase.run(new Input.ForSlug(slug)); + + // Assert + Mockito.verify(brandRepository).findBySlug(slug); + Assertions.assertInstanceOf( + Output.Found.class, output, "Given a slug, a brand should be found if it exists"); + var getBrandOutput = (Output.Found) output; + Assertions.assertEquals( + name, + getBrandOutput.brand().name(), + "Resolve brand should return the name matching the given slug"); + Assertions.assertEquals( + String.join(",", Set.of(domainNameFqdn.getValue())), + String.join(",", getBrandOutput.brand().fqdnSet()), + "Resolve brand should return the actualFqdn set matching the brand's actualFqdn"); + } + + @Test + public void getBrandUsingSlugCanBeMissing() { + // Arrange + var slug = new Slug("my-catalog-2000"); + + Mockito.when(brandRepository.findBySlug(slug)).thenReturn(Optional.empty()); + + // Act + var output = getBrandUseCase.run(new Input.ForSlug(slug)); + + // Assert + Mockito.verify(brandRepository).findBySlug(slug); + Assertions.assertInstanceOf( + Output.NoSuchBrand.class, + output, + "Given a slug, a brand should not be found if it does not exist"); + } + + @Test + public void getBrandUsingUuidCanBeFound() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var name = "My Brand 2000"; + var brand = new Brand(slug, name); + var uuid = brand.getUuid(); + + var domainNameFqdn = Fqdn.of("example.net"); + brand.assignFqdn(domainNameFqdn, (fqdn -> true)); + + Mockito.when(brandRepository.findByUuid(uuid)).thenReturn(Optional.of(brand)); + + // Act + var output = getBrandUseCase.run(new Input.ForUuid(uuid)); + + // Assert + Mockito.verify(brandRepository).findByUuid(uuid); + Assertions.assertInstanceOf( + Output.Found.class, output, "Given an uuid, a brand should be found if it exists"); + var getBrandOutput = (Output.Found) output; + Assertions.assertEquals( + name, + getBrandOutput.brand().name(), + "Resolve brand should return the name matching the given uuid"); + Assertions.assertEquals( + String.join(",", Set.of(domainNameFqdn.getValue())), + String.join(",", getBrandOutput.brand().fqdnSet()), + "Resolve brand should return the actualFqdn set matching the brand's actualFqdn"); + } + + @Test + public void getBrandUsingUuidCanBeMissing() { + // Arrange + var uuid = UUID.randomUUID(); + + Mockito.when(brandRepository.findByUuid(uuid)).thenReturn(Optional.empty()); + + // Act + var output = getBrandUseCase.run(new Input.ForUuid(uuid)); + + // Assert + Mockito.verify(brandRepository).findByUuid(uuid); + Assertions.assertInstanceOf( + Output.NoSuchBrand.class, + output, + "Given an Uuid, a brand should not be found if it does not exist"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/ListBrandsTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/ListBrandsTest.java new file mode 100644 index 0000000..2c1ffd8 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/ListBrandsTest.java @@ -0,0 +1,56 @@ +package tech.pegase.z3.domain.usecase.brands; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.usecase.brands.ListBrands.Output.PaginatedBrands; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public class ListBrandsTest { + private BrandRepository brandRepository; + private ListBrands listBrandsUseCase; + + @BeforeEach + public void setup() { + brandRepository = Mockito.mock(BrandRepository.class); + listBrandsUseCase = new ListBrandsImpl(brandRepository); + } + + @Test + public void canGetBrandListWithPagination() { + // Arrange + var fqdn = Fqdn.of("example.net"); + + var brandWithoutDomainName = new Brand(new Slug("qwerty"), "qwerty"); + var brandWithDomainName = new Brand(new Slug("azerty"), "azerty"); + brandWithDomainName.assignFqdn(fqdn, (fqdnToAssign) -> true); + + var linkEntityList = List.of(brandWithoutDomainName, brandWithDomainName); + + var pagination = Pagination.fromPageNumberAndPageSize(1, 20); + var paginatedData = + PaginatedData.wrap(linkEntityList, 50, pagination.offset(), linkEntityList.size()); + + Mockito.when(brandRepository.findAll(Mockito.any(Pagination.class))).thenReturn(paginatedData); + + // Act + var output = listBrandsUseCase.run(new ListBrands.Input.WithPagination(pagination)); + + // Assert + Mockito.verify(brandRepository).findAll(pagination); + Assertions.assertInstanceOf( + PaginatedBrands.class, output, "List brands should output paginated brands"); + + var paginatedBrands = (PaginatedBrands) output; + var outputFirstItem = paginatedBrands.brandList().items().getFirst(); + Assertions.assertNotNull( + outputFirstItem, "List brands should contain the brand the repository outputs"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/UpdateBrandTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/UpdateBrandTest.java new file mode 100644 index 0000000..8ce39e6 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/brands/UpdateBrandTest.java @@ -0,0 +1,192 @@ +package tech.pegase.z3.domain.usecase.brands; + +import io.quarkus.arc.impl.Reflections; +import java.lang.reflect.InvocationTargetException; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.service.DomainNameService; +import tech.pegase.z3.domain.service.DomainNameServiceImpl; +import tech.pegase.z3.domain.usecase.brands.UpdateBrand.Input.ForSlug; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; + +public class UpdateBrandTest { + private Slugifyier slugifyierService; + private BrandRepository brandRepository; + private UpdateBrand updateBrandUseCase; + private DomainNameService domainNameService; + + @BeforeEach + public void setup() + throws InvocationTargetException, InstantiationException, IllegalAccessException { + brandRepository = Mockito.mock(BrandRepository.class); + slugifyierService = Mockito.mock(Slugifyier.class); + var domainNameServiceConstructor = + Reflections.findConstructor(DomainNameServiceImpl.class, BrandRepository.class); + domainNameServiceConstructor.setAccessible(true); + domainNameService = + Mockito.spy((DomainNameService) domainNameServiceConstructor.newInstance(brandRepository)); + updateBrandUseCase = new UpdateBrandImpl(slugifyierService, brandRepository, domainNameService); + } + + @Test + public void updateBrandForSlugShouldPersistWhenSlugExists() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var name = "My Catalog 2000"; + var fqdnSet = Set.of(Fqdn.of("example.net")); + var brand = new Brand(slug, name); + + Mockito.when(brandRepository.findBySlug(Mockito.any(Slug.class))) + .thenReturn(Optional.of(brand)); + Mockito.when(slugifyierService.slugify(Mockito.any(String.class))).thenReturn(slug); + + // Act + var output = updateBrandUseCase.run(new ForSlug(slug, name, fqdnSet)); + + // Assert + Mockito.verify(brandRepository, Mockito.times(2)).findBySlug(Mockito.any(Slug.class)); + Mockito.verify(brandRepository, Mockito.times(1)).update(Mockito.any(Brand.class)); + Mockito.verify(slugifyierService).slugify(Mockito.any(String.class)); + Mockito.verify(domainNameService) + .canFqdnBeAssignedToBrand(Mockito.any(Fqdn.class), Mockito.any(Brand.class)); + + Assertions.assertInstanceOf( + UpdateBrand.Output.Updated.class, + output, + "Update brand use case should output the updated brand"); + + var updatedOutput = (UpdateBrand.Output.Updated) output; + Assertions.assertEquals( + name, + updatedOutput.brand().name(), + "Update brand use case should return the same name as in input"); + } + + @Test + public void updateBrandUsingSlugCanBeMissing() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var name = "My Catalog 2000"; + var fqdnSet = Set.of(Fqdn.of("example.net")); + + Mockito.doAnswer(i -> Optional.empty()).when(brandRepository).findBySlug(slug); + + // Act + var output = updateBrandUseCase.run(new ForSlug(slug, name, fqdnSet)); + + // Assert + Mockito.verify(brandRepository).findBySlug(Mockito.any(Slug.class)); + Assertions.assertInstanceOf( + UpdateBrand.Output.NoSuchBrand.class, + output, + "Given a slug, a brand cannot be updated if it does not exist"); + } + + @Test + public void cannotUpdateBrandIfNameIsEmpty() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var name = ""; + var fqdnSet = Set.of(Fqdn.of("example.net")); + + // Act + var output = updateBrandUseCase.run(new ForSlug(slug, name, fqdnSet)); + + // Assert + Mockito.verifyNoInteractions(brandRepository); + Assertions.assertInstanceOf(UpdateBrand.Output.NotUpdated.class, output); + } + + @Test + public void cannotUpdateBrandIfNameCannotBeSlugged() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var fqdnSet = Set.of(Fqdn.of("example.net")); + var name = "___"; + var brand = new Brand(slug, name); + + Mockito.when(brandRepository.findBySlug(Mockito.any(Slug.class))) + .thenReturn(Optional.of(brand)); + Mockito.doThrow(IllegalArgumentException.class) + .when(slugifyierService) + .slugify(Mockito.any(String.class)); + + // Act + var output = updateBrandUseCase.run(new ForSlug(slug, name, fqdnSet)); + + // Assert + Mockito.verify(brandRepository).findBySlug(Mockito.any(Slug.class)); + Mockito.verifyNoMoreInteractions(brandRepository); + Assertions.assertInstanceOf(UpdateBrand.Output.NotUpdated.class, output); + } + + @Test + public void cannotUpdateBrandIfSlugIsAlreadyUsed() { + // Arrange + var slug = new Slug("my-catalog-2000"); + var name = "My Catalog 2000"; + var fqdnSet = Set.of(Fqdn.of("example.net")); + var brand = new Brand(slug, name); + var newSlug = Slug.of("new-slug"); + var anotherBrand = new Brand(newSlug, "My Brand 2000"); + + Mockito.when(brandRepository.findBySlug(slug)).thenReturn(Optional.of(brand)); + Mockito.when(slugifyierService.slugify(Mockito.any(String.class))).thenReturn(newSlug); + Mockito.when(brandRepository.findBySlug(newSlug)).thenReturn(Optional.of(anotherBrand)); + + // Act + var output = updateBrandUseCase.run(new ForSlug(slug, name, fqdnSet)); + + // Assert + Mockito.verify(brandRepository).findBySlug(slug); + Mockito.verify(brandRepository).findBySlug(newSlug); + Mockito.verifyNoMoreInteractions(brandRepository); + Assertions.assertInstanceOf(UpdateBrand.Output.NotUpdated.class, output); + } + + @Test + public void cannotUpdateBrandIfFqdnIsAlreadyUsed() { + // GIVEN + var slug = new Slug("my-catalog-2000"); + var name = "My Catalog 2000"; + var brand = new Brand(slug, name); + var fqdn = Fqdn.of("example.net"); + var fqdnSet = Set.of(fqdn); + + var existingBrandUuid = UUID.randomUUID(); + var existingBrandFqdnSet = Set.of(Fqdn.of("example.net")); + var existingBrand = Mockito.mock(Brand.class); + + Mockito.when(existingBrand.getUuid()).thenReturn(existingBrandUuid); + Mockito.when(existingBrand.getFqdns()).thenReturn(existingBrandFqdnSet); + + Mockito.when(brandRepository.findBySlug(Mockito.any(Slug.class))) + .thenReturn(Optional.of(brand)); + Mockito.when(slugifyierService.slugify(Mockito.any(String.class))).thenReturn(slug); + Mockito.when(brandRepository.findByFqdn(fqdn)).thenReturn(Optional.of(existingBrand)); + + // Act + var output = updateBrandUseCase.run(new ForSlug(slug, name, fqdnSet)); + + // Assert + Mockito.verify(brandRepository, Mockito.times(2)).findBySlug(Mockito.any(Slug.class)); + Mockito.verify(slugifyierService).slugify(Mockito.any(String.class)); + Mockito.verify(domainNameService) + .canFqdnBeAssignedToBrand(Mockito.any(Fqdn.class), Mockito.any(Brand.class)); + Mockito.verify(brandRepository, Mockito.never()).update(Mockito.any(Brand.class)); + + Assertions.assertInstanceOf( + UpdateBrand.Output.NotUpdated.class, + output, + "Cannot update a brand with a Fqdn that is already used"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalogTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalogTest.java new file mode 100644 index 0000000..9c9c0fe --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/CreateCatalogTest.java @@ -0,0 +1,118 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.usecase.catalogs.CreateCatalog.Input; +import tech.pegase.z3.domain.usecase.catalogs.CreateCatalog.Output; +import tech.pegase.z3.domain.valueobject.Slug; + +public class CreateCatalogTest { + private final Slugifyier slugifyierService; + private final CatalogRepository catalogRepository; + private final BrandRepository brandRepository; + private final CreateCatalog createCatalog; + + CreateCatalogTest() { + this.brandRepository = Mockito.mock(BrandRepository.class); + this.slugifyierService = Mockito.mock(Slugifyier.class); + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.createCatalog = + new CreateCatalogImpl(slugifyierService, catalogRepository, brandRepository); + } + + @Test + public void canCreateCatalog() { + // GIVEN + var name = "catalog name"; + var brandUuid = UUID.randomUUID(); + var newCatalogSlug = Slug.of("catalog-name"); + + Mockito.when(slugifyierService.slugify(name)).thenReturn(newCatalogSlug); + Mockito.when(catalogRepository.exists(newCatalogSlug)).thenReturn(false); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(catalogRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + Mockito.doNothing().when(catalogRepository).persist(Mockito.any(Catalog.class)); + + // WHEN + var output = createCatalog.run(new Input.With(name, brandUuid)); + + // THEN + Mockito.verify(catalogRepository).persist(Mockito.any(Catalog.class)); + Assertions.assertInstanceOf(Output.Created.class, output); + } + + @Test + public void cannotCreateCatalogWithEmptyName() { + // GIVEN + var name = ""; + var brandUuid = UUID.randomUUID(); + + // WHEN + var output = createCatalog.run(new Input.With(name, brandUuid)); + + // THEN + Mockito.verifyNoInteractions(catalogRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateCatalogWithUnSluggableName() { + // GIVEN + var name = "___"; + var brandUuid = UUID.randomUUID(); + Mockito.doThrow(IllegalArgumentException.class).when(slugifyierService).slugify(name); + + // WHEN + var output = createCatalog.run(new Input.With(name, brandUuid)); + + // THEN + Mockito.verifyNoInteractions(catalogRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateCatalogIfNameIsAlreadyUsed() { + // GIVEN + var name = "catalog name"; + var brandUuid = UUID.randomUUID(); + var newCatalogSlug = Slug.of("catalog-name"); + + Mockito.when(slugifyierService.slugify(name)).thenReturn(newCatalogSlug); + Mockito.when(catalogRepository.exists(newCatalogSlug)).thenReturn(true); + + // WHEN + var output = createCatalog.run(new Input.With(name, brandUuid)); + + // THEN + Mockito.verify(catalogRepository).exists(Mockito.any(Slug.class)); + Mockito.verifyNoMoreInteractions(catalogRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateCatalogForNonExistingBrand() { + // GIVEN + var name = "catalog name"; + var brandUuid = UUID.randomUUID(); + var newCatalogSlug = Slug.of("catalog-name"); + + Mockito.when(slugifyierService.slugify(name)).thenReturn(newCatalogSlug); + Mockito.when(catalogRepository.exists(newCatalogSlug)).thenReturn(false); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(false); + + // WHEN + var output = createCatalog.run(new Input.With(name, brandUuid)); + + // THEN + Mockito.verify(brandRepository).exists(Mockito.any(UUID.class)); + Mockito.verify(catalogRepository).exists(Mockito.any(Slug.class)); + Mockito.verifyNoMoreInteractions(catalogRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalogTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalogTest.java new file mode 100644 index 0000000..3f8e833 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/GetCatalogTest.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog.Input; +import tech.pegase.z3.domain.usecase.catalogs.GetCatalog.Output; +import tech.pegase.z3.domain.valueobject.Slug; + +public class GetCatalogTest { + private final CatalogRepository catalogRepository; + private final GetCatalog getCatalog; + + GetCatalogTest() { + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.getCatalog = new GetCatalogImpl(catalogRepository); + } + + @Test + public void canGetCatalog() { + // GIVEN + var catalogSlug = Slug.of("catalog-slug"); + var catalog = Mockito.mock(Catalog.class); + + Mockito.when(catalogRepository.findBySlug(catalogSlug)).thenReturn(Optional.of(catalog)); + + // WHEN + var output = getCatalog.run(new Input.ForSlug(catalogSlug)); + + // THEN + Assertions.assertInstanceOf(Output.Found.class, output); + } + + @Test + public void cannotGetNonExistingCatalog() { + // GIVEN + var catalogSlug = Slug.of("catalog-slug"); + + Mockito.when(catalogRepository.findBySlug(catalogSlug)).thenReturn(Optional.empty()); + + // WHEN + var output = getCatalog.run(new Input.ForSlug(catalogSlug)); + + // THEN + Assertions.assertInstanceOf(Output.NoSuchCatalog.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogsTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogsTest.java new file mode 100644 index 0000000..8d4e9e6 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/ListCatalogsTest.java @@ -0,0 +1,88 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.usecase.catalogs.ListCatalogs.Output.PaginatedCatalogs; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public class ListCatalogsTest { + private CatalogRepository catalogRepository; + private ListCatalogs listCatalogsUseCase; + + @BeforeEach + public void setup() { + catalogRepository = Mockito.mock(CatalogRepository.class); + listCatalogsUseCase = new ListCatalogsImpl(catalogRepository); + } + + @Test + public void canGetBrandListWithPagination() { + // Arrange + var pagination = Pagination.fromPageNumberAndPageSize(1, 20); + var paginatedData = getFakePaginatedData(pagination); + + Mockito.when(catalogRepository.findAll(Mockito.any(Pagination.class))) + .thenReturn(paginatedData); + + // Act + var output = listCatalogsUseCase.run(new ListCatalogs.Input.WithPagination(pagination)); + + // Assert + Mockito.verify(catalogRepository).findAll(Mockito.any(Pagination.class)); + Assertions.assertInstanceOf( + PaginatedCatalogs.class, output, "List catalogs should output paginated catalogs"); + + var paginatedCatalogs = (PaginatedCatalogs) output; + Assertions.assertNotNull(paginatedCatalogs.catalogList().items().getFirst()); + Assertions.assertEquals( + paginatedData.items().getFirst().getBrandUuid(), + paginatedCatalogs.catalogList().items().getFirst().brandUuid()); + } + + @Test + public void canGetBrandListWithPaginationForBrand() { + // Arrange + var brandUuid = UUID.randomUUID(); + var pagination = Pagination.fromPageNumberAndPageSize(1, 20); + var paginatedData = getFakePaginatedData(pagination); + + Mockito.when( + catalogRepository.findAllForBrand( + Mockito.any(UUID.class), Mockito.any(Pagination.class))) + .thenReturn(paginatedData); + + // Act + var output = + listCatalogsUseCase.run( + new ListCatalogs.Input.WithPaginationForBrand(brandUuid, pagination)); + + // Assert + Mockito.verify(catalogRepository) + .findAllForBrand(Mockito.any(UUID.class), Mockito.any(Pagination.class)); + Assertions.assertInstanceOf( + PaginatedCatalogs.class, output, "List catalogs should output paginated catalogs"); + + var paginatedCatalogs = (PaginatedCatalogs) output; + Assertions.assertNotNull(paginatedCatalogs.catalogList().items().getFirst()); + Assertions.assertEquals( + paginatedData.items().getFirst().getBrandUuid(), + paginatedCatalogs.catalogList().items().getFirst().brandUuid()); + } + + private PaginatedData getFakePaginatedData(Pagination pagination) { + var entityList = + List.of( + new Catalog(Slug.of("qwerty"), "qwerty", UUID.randomUUID()), + new Catalog(Slug.of("azerty"), "azerty", UUID.randomUUID()), + new Catalog(Slug.of("dvorak"), "dvorak", UUID.randomUUID())); + return PaginatedData.wrap(entityList, 50, pagination.offset(), entityList.size()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalogTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalogTest.java new file mode 100644 index 0000000..ca0ff5d --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/RemoveCatalogTest.java @@ -0,0 +1,76 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.usecase.catalogs.RemoveCatalog.Input; +import tech.pegase.z3.domain.usecase.catalogs.RemoveCatalog.Output; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public class RemoveCatalogTest { + private final CatalogRepository catalogRepository; + private final LinkRepository linkRepository; + private final RemoveCatalog removeCatalog; + + RemoveCatalogTest() { + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.linkRepository = Mockito.mock(LinkRepository.class); + this.removeCatalog = new RemoveCatalogImpl(catalogRepository, linkRepository); + } + + @Test + public void canRemoveCatalog() { + // GIVEN + var slug = Slug.of("slug"); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var link = Mockito.mock(Link.class); + var paginatedLinks = PaginatedData.wrap(List.of(link), 50, 0, 1); + + Mockito.when(catalog.getUuid()).thenReturn(catalogUuid); + + Mockito.when(catalogRepository.findBySlug(slug)).thenReturn(Optional.of(catalog)); + Mockito.when( + linkRepository.findAllForCatalog( + Mockito.any(Pagination.class), Mockito.any(UUID.class))) + .thenReturn(paginatedLinks) + .thenReturn(PaginatedData.empty()); + Mockito.doNothing().when(linkRepository).update(Mockito.any(Link.class)); + Mockito.when(catalogRepository.remove(slug)).thenReturn(true); + + // WHEN + var output = removeCatalog.run(new Input.ForSlug(slug)); + + // THEN + Mockito.verify(catalogRepository).remove(slug); + Assertions.assertInstanceOf(Output.Removed.class, output); + } + + @Test + public void cannotRemoveNonExistingCatalog() { + // GIVEN + var slug = Slug.of("slug"); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + + Mockito.when(catalog.getUuid()).thenReturn(catalogUuid); + + Mockito.when(catalogRepository.findBySlug(slug)).thenReturn(Optional.empty()); + + // WHEN + var output = removeCatalog.run(new Input.ForSlug(slug)); + + // THEN + Mockito.verify(catalogRepository, Mockito.never()).remove(slug); + Assertions.assertInstanceOf(Output.NoSuchCatalog.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalogTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalogTest.java new file mode 100644 index 0000000..8ebc886 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/catalogs/UpdateCatalogTest.java @@ -0,0 +1,129 @@ +package tech.pegase.z3.domain.usecase.catalogs; + +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.usecase.catalogs.UpdateCatalog.Input; +import tech.pegase.z3.domain.usecase.catalogs.UpdateCatalog.Output; +import tech.pegase.z3.domain.valueobject.Slug; + +public class UpdateCatalogTest { + private final Slugifyier slugifyierService; + private final CatalogRepository catalogRepository; + private final UpdateCatalog updateCatalog; + + UpdateCatalogTest() { + this.slugifyierService = Mockito.mock(Slugifyier.class); + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.updateCatalog = new UpdateCatalogImpl(slugifyierService, catalogRepository); + } + + @Test + public void canUpdateCatalog() { + // GIVEN + var slug = Slug.of("slug"); + var name = "name"; + var newSlug = Slug.of(name); + var catalog = Mockito.mock(Catalog.class); + + Mockito.doNothing().when(catalog).setName(Mockito.anyString()); + Mockito.doNothing().when(catalog).setSlug(newSlug); + + Mockito.when(catalogRepository.findBySlug(slug)).thenReturn(Optional.of(catalog)); + Mockito.when(slugifyierService.slugify(name)).thenReturn(newSlug); + Mockito.when(catalogRepository.exists(newSlug)).thenReturn(false); + Mockito.doNothing().when(catalogRepository).update(catalog); + + // WHEN + var output = updateCatalog.run(new Input.ForSlug(slug, name)); + + // THEN + Mockito.verify(catalogRepository).update(catalog); + Assertions.assertInstanceOf(Output.Updated.class, output); + } + + @Test + public void cannotUpdateForNonExistingCatalog() { + // GIVEN + var slug = Slug.of("slug"); + var name = "name"; + var newSlug = Slug.of(name); + var catalog = Mockito.mock(Catalog.class); + + Mockito.doNothing().when(catalog).setName(Mockito.anyString()); + Mockito.doNothing().when(catalog).setSlug(newSlug); + + Mockito.when(catalogRepository.findBySlug(slug)).thenReturn(Optional.empty()); + + // WHEN + var output = updateCatalog.run(new Input.ForSlug(slug, name)); + + // THEN + Mockito.verify(catalogRepository, Mockito.never()).update(catalog); + Assertions.assertInstanceOf(Output.NoSuchCatalog.class, output); + } + + @Test + public void cannotUpdateCatalogIfNewNameIsBlank() { + // GIVEN + var slug = Slug.of("slug"); + var name = ""; + var catalog = Mockito.mock(Catalog.class); + + // WHEN + var output = updateCatalog.run(new Input.ForSlug(slug, name)); + + // THEN + Mockito.verify(catalogRepository, Mockito.never()).update(catalog); + Assertions.assertInstanceOf(Output.NotUpdated.class, output); + } + + @Test + public void cannotUpdateCatalogIfNewSlugCannotBeGenerated() { + // GIVEN + var slug = Slug.of("slug"); + var name = "name"; + var newSlug = Slug.of(name); + var catalog = Mockito.mock(Catalog.class); + + Mockito.doNothing().when(catalog).setName(Mockito.anyString()); + Mockito.doNothing().when(catalog).setSlug(newSlug); + + Mockito.when(catalogRepository.findBySlug(slug)).thenReturn(Optional.of(catalog)); + Mockito.doThrow(IllegalArgumentException.class).when(slugifyierService).slugify(name); + + // WHEN + var output = updateCatalog.run(new Input.ForSlug(slug, name)); + + // THEN + Mockito.verify(catalogRepository, Mockito.never()).update(catalog); + Assertions.assertInstanceOf(Output.NotUpdated.class, output); + } + + @Test + public void cannotUpdateCatalogIfNewSlugAlreadyExists() { + // GIVEN + var slug = Slug.of("slug"); + var name = "name"; + var newSlug = Slug.of(name); + var catalog = Mockito.mock(Catalog.class); + + Mockito.doNothing().when(catalog).setName(Mockito.anyString()); + Mockito.doNothing().when(catalog).setSlug(newSlug); + + Mockito.when(catalogRepository.findBySlug(slug)).thenReturn(Optional.of(catalog)); + Mockito.when(slugifyierService.slugify(name)).thenReturn(newSlug); + Mockito.when(catalogRepository.exists(newSlug)).thenReturn(true); + + // WHEN + var output = updateCatalog.run(new Input.ForSlug(slug, name)); + + // THEN + Mockito.verify(catalogRepository, Mockito.never()).update(catalog); + Assertions.assertInstanceOf(Output.NotUpdated.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/CreateLinkTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/CreateLinkTest.java new file mode 100644 index 0000000..f760935 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/CreateLinkTest.java @@ -0,0 +1,276 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.port.service.RandomSourceGenerator; +import tech.pegase.z3.domain.service.VerifyLinkService; +import tech.pegase.z3.domain.usecase.links.CreateLink.Input; +import tech.pegase.z3.domain.usecase.links.CreateLink.Input.CommonLinkInput; +import tech.pegase.z3.domain.usecase.links.CreateLink.Output; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public class CreateLinkTest { + + private RandomSourceGenerator randomSourceGenerator; + private LinkRepository linkRepository; + private BrandRepository brandRepository; + private TagRepository tagRepository; + private CatalogRepository catalogRepository; + private CreateLink createLink; + private VerifyLinkService verifyLinkService; + + CreateLinkTest() { + randomSourceGenerator = Mockito.mock(RandomSourceGenerator.class); + linkRepository = Mockito.mock(LinkRepository.class); + brandRepository = Mockito.mock(BrandRepository.class); + tagRepository = Mockito.mock(TagRepository.class); + catalogRepository = Mockito.mock(CatalogRepository.class); + verifyLinkService = Mockito.mock(VerifyLinkService.class); + createLink = + new CreateLinkImpl( + randomSourceGenerator, + linkRepository, + brandRepository, + catalogRepository, + tagRepository, + verifyLinkService); + } + + @Test + public void canCreateLinkWithSource() { + // GIVEN + var source = Source.of("source"); + var target = Target.of("https://target"); + var isExternal = false; + var brandUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var tagUuid = UUID.randomUUID(); + var tag = Mockito.mock(Tag.class); + var tagColor = Color.of("#fff"); + + var commonLinkInput = new CommonLinkInput(target, isExternal, brandUuid, tagUuid, catalogUuid); + + Mockito.when(tag.getColor()).thenReturn(tagColor); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.when(tagRepository.findByUuid(tagUuid)).thenReturn(Optional.of(tag)); + Mockito.when(linkRepository.exists(source)).thenReturn(false); + Mockito.doNothing().when(linkRepository).persist(Mockito.any(Link.class)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // WHEN + var output = createLink.run(new Input.WithSourceAndUrl(source, commonLinkInput)); + + // THEN + Mockito.verify(linkRepository).persist(Mockito.any(Link.class)); + Mockito.verify(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.Created.class, output); + } + + @Test + public void canCreateLinkWithSourceAndNoForeign() { + // GIVEN + var source = Source.of("source"); + var target = Target.of("https://target"); + var isExternal = false; + + var commonLinkInput = new CommonLinkInput(target, isExternal, null, null, null); + + Mockito.when(linkRepository.exists(source)).thenReturn(false); + Mockito.doNothing().when(linkRepository).persist(Mockito.any(Link.class)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // WHEN + var output = createLink.run(new Input.WithSourceAndUrl(source, commonLinkInput)); + + // THEN + Mockito.verify(linkRepository).persist(Mockito.any(Link.class)); + Mockito.verify(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.Created.class, output); + } + + @Test + public void canCreateLinkWithoutSource() { + // GIVEN + var source = Source.of("source"); + var target = Target.of("https://target"); + var isExternal = false; + var brandUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var tagUuid = UUID.randomUUID(); + var tag = Mockito.mock(Tag.class); + var tagColor = Color.of("#fff"); + + var commonLinkInput = new CommonLinkInput(target, isExternal, brandUuid, tagUuid, catalogUuid); + + Mockito.when(tag.getColor()).thenReturn(tagColor); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.when(tagRepository.findByUuid(tagUuid)).thenReturn(Optional.of(tag)); + Mockito.when(linkRepository.exists(source)).thenReturn(true).thenReturn(false); + Mockito.doNothing().when(linkRepository).persist(Mockito.any(Link.class)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + Mockito.when(randomSourceGenerator.generate()).thenReturn(Source.of("source")); + + // WHEN + var output = createLink.run(new Input.WithUrl(commonLinkInput)); + + // THEN + Mockito.verify(linkRepository).persist(Mockito.any(Link.class)); + Mockito.verify(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.Created.class, output); + } + + @Test + public void cannotCreateLinkWithoutUniqueSource() { + // GIVEN + var source = Source.of("source"); + var target = Target.of("https://target"); + var isExternal = false; + var brandUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var tagUuid = UUID.randomUUID(); + + var commonLinkInput = new CommonLinkInput(target, isExternal, brandUuid, tagUuid, catalogUuid); + + Mockito.when(linkRepository.exists(source)).thenReturn(true); + Mockito.when(randomSourceGenerator.generate()).thenReturn(Source.of("source")); + + // WHEN + var output = createLink.run(new Input.WithUrl(commonLinkInput)); + + // THEN + Mockito.verify(linkRepository, Mockito.never()).persist(Mockito.any(Link.class)); + Mockito.verify(verifyLinkService, Mockito.never()) + .verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateLinkIfCatalogDoesNotExist() { + // GIVEN + var source = Source.of("source"); + var target = Target.of("https://target"); + var isExternal = false; + var brandUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var tagUuid = UUID.randomUUID(); + var tag = Mockito.mock(Tag.class); + + var commonLinkInput = new CommonLinkInput(target, isExternal, brandUuid, tagUuid, catalogUuid); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.empty()); + Mockito.when(tagRepository.findByUuid(tagUuid)).thenReturn(Optional.of(tag)); + + // WHEN + var output = createLink.run(new Input.WithSourceAndUrl(source, commonLinkInput)); + + // THEN + Mockito.verify(linkRepository, Mockito.never()).persist(Mockito.any(Link.class)); + Mockito.verify(verifyLinkService, Mockito.never()) + .verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateLinkIfTagDoesNotExist() { + // GIVEN + var source = Source.of("source"); + var target = Target.of("https://target"); + var isExternal = false; + var brandUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var tagUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + + var commonLinkInput = new CommonLinkInput(target, isExternal, brandUuid, tagUuid, catalogUuid); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.when(tagRepository.findByUuid(tagUuid)).thenReturn(Optional.empty()); + + // WHEN + var output = createLink.run(new Input.WithSourceAndUrl(source, commonLinkInput)); + + // THEN + Mockito.verify(linkRepository, Mockito.never()).persist(Mockito.any(Link.class)); + Mockito.verify(verifyLinkService, Mockito.never()) + .verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateLinkIfBrandDoesNotExist() { + // GIVEN + var source = Source.of("source"); + var target = Target.of("https://target"); + var isExternal = false; + var brandUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var tagUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var tag = Mockito.mock(Tag.class); + + var commonLinkInput = new CommonLinkInput(target, isExternal, brandUuid, tagUuid, catalogUuid); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(false); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.when(tagRepository.findByUuid(tagUuid)).thenReturn(Optional.of(tag)); + + // WHEN + var output = createLink.run(new Input.WithSourceAndUrl(source, commonLinkInput)); + + // THEN + Mockito.verify(linkRepository, Mockito.never()).persist(Mockito.any(Link.class)); + Mockito.verify(verifyLinkService, Mockito.never()) + .verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateLinkIfBSourceAlreadyExists() { + // GIVEN + var source = Source.of("source"); + var target = Target.of("https://target"); + var isExternal = false; + var brandUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var tagUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var tag = Mockito.mock(Tag.class); + + var commonLinkInput = new CommonLinkInput(target, isExternal, brandUuid, tagUuid, catalogUuid); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.when(tagRepository.findByUuid(tagUuid)).thenReturn(Optional.of(tag)); + Mockito.when(linkRepository.exists(source)).thenReturn(true); + + // WHEN + var output = createLink.run(new Input.WithSourceAndUrl(source, commonLinkInput)); + + // THEN + Mockito.verify(linkRepository, Mockito.never()).persist(Mockito.any(Link.class)); + Mockito.verify(verifyLinkService, Mockito.never()) + .verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ExportLinkTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ExportLinkTest.java new file mode 100644 index 0000000..153734a --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ExportLinkTest.java @@ -0,0 +1,260 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.usecase.links.ExportLinks.Input; +import tech.pegase.z3.domain.usecase.links.ExportLinks.Output; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public class ExportLinkTest { + private final LinkRepository linkRepository; + private final BrandRepository brandRepository; + private final CatalogRepository catalogRepository; + private final ExportLinks exportLinks; + + ExportLinkTest() { + this.linkRepository = Mockito.mock(LinkRepository.class); + this.brandRepository = Mockito.mock(BrandRepository.class); + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.exportLinks = new ExportLinksImpl(linkRepository, brandRepository, catalogRepository); + } + + // -- FOR BRAND -- + + @Test + public void canExportLinksPageForBrand() { + // GIVEN + var brandUuid = UUID.randomUUID(); + var brandSlug = new Slug("slug"); + var brandFqdn1 = Fqdn.of("example.net"); + var brandFqdnSet = Set.of(brandFqdn1); + var brand = Mockito.mock(Brand.class); + var page = 0; + var perPage = 50; + var linkResult = Mockito.mock(Link.class); + var links = List.of(linkResult); + var paginatedData = PaginatedData.wrap(links, links.size(), 0, links.size()); + + Mockito.when(brand.getFqdns()).thenReturn(brandFqdnSet); + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + + Mockito.when(brandRepository.findByUuid(brandUuid)).thenReturn(Optional.of(brand)); + Mockito.when(brandRepository.findBySlug(brandSlug)).thenReturn(Optional.of(brand)); + Mockito.when( + linkRepository.findAllForBrand(Mockito.any(Pagination.class), Mockito.any(UUID.class))) + .thenReturn(paginatedData); + + // WHEN + var output = + exportLinks.run(new Input.PageForBrand(brandSlug, new Input.Pagination(page, perPage))); + + // THEN + Assertions.assertInstanceOf(Output.Page.class, output); + var outputPage = (Output.Page) output; + Assertions.assertEquals(links.size(), outputPage.rows().size()); + } + + @Test + public void canExportEmptyLinksPageForBrand() { + // GIVEN + var brandUuid = UUID.randomUUID(); + var brandSlug = new Slug("slug"); + var brand = Mockito.mock(Brand.class); + var page = 0; + var perPage = 50; + var links = List.of(); + var paginatedData = PaginatedData.wrap(links, 0, 0, 0); + + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + + Mockito.when(brandRepository.findBySlug(brandSlug)).thenReturn(Optional.of(brand)); + Mockito.when( + linkRepository.findAllForBrand(Mockito.any(Pagination.class), Mockito.any(UUID.class))) + .thenReturn(paginatedData); + + // WHEN + var output = + exportLinks.run(new Input.PageForBrand(brandSlug, new Input.Pagination(page, perPage))); + + // THEN + Assertions.assertInstanceOf(Output.NoMoreToExport.class, output); + } + + @Test + public void canExportLinksPageForNonExistingBrand() { + // GIVEN + var brandSlug = new Slug("slug"); + var page = 0; + var perPage = 50; + + Mockito.when(brandRepository.findBySlug(brandSlug)).thenReturn(Optional.empty()); + + // WHEN + var output = + exportLinks.run(new Input.PageForBrand(brandSlug, new Input.Pagination(page, perPage))); + + // THEN + Assertions.assertInstanceOf(Output.Failed.class, output); + } + + // -- FOR CATALOG -- + + @Test + public void canExportLinksPageForCatalog() { + // GIVEN + var catalogUuid = UUID.randomUUID(); + var catalogSlug = new Slug("slug"); + var catalog = Mockito.mock(Catalog.class); + var page = 0; + var perPage = 50; + var linkResult = Mockito.mock(Link.class); + var links = List.of(linkResult); + var paginatedData = PaginatedData.wrap(links, links.size(), 0, links.size()); + + Mockito.when(catalog.getUuid()).thenReturn(catalogUuid); + + Mockito.when(catalogRepository.findBySlug(catalogSlug)).thenReturn(Optional.of(catalog)); + Mockito.when( + linkRepository.findAllForCatalog( + Mockito.any(Pagination.class), Mockito.any(UUID.class))) + .thenReturn(paginatedData); + + // WHEN + var output = + exportLinks.run(new Input.PageForCatalog(catalogSlug, new Input.Pagination(page, perPage))); + + // THEN + Assertions.assertInstanceOf(Output.Page.class, output); + var outputPage = (Output.Page) output; + Assertions.assertEquals(links.size(), outputPage.rows().size()); + } + + @Test + public void canExportEmptyLinksPageForCatalog() { + // GIVEN + var catalogUuid = UUID.randomUUID(); + var catalogSlug = new Slug("slug"); + var catalog = Mockito.mock(Catalog.class); + var page = 0; + var perPage = 50; + var links = List.of(); + var paginatedData = PaginatedData.wrap(links, 0, 0, 0); + + Mockito.when(catalog.getUuid()).thenReturn(catalogUuid); + + Mockito.when(catalogRepository.findBySlug(catalogSlug)).thenReturn(Optional.of(catalog)); + Mockito.when( + linkRepository.findAllForCatalog( + Mockito.any(Pagination.class), Mockito.any(UUID.class))) + .thenReturn(paginatedData); + + // WHEN + var output = + exportLinks.run(new Input.PageForCatalog(catalogSlug, new Input.Pagination(page, perPage))); + + // THEN + Assertions.assertInstanceOf(Output.NoMoreToExport.class, output); + } + + @Test + public void canExportLinksPageForNonExistingCatalog() { + // GIVEN + var catalogSlug = new Slug("slug"); + var page = 0; + var perPage = 50; + + Mockito.when(catalogRepository.findBySlug(catalogSlug)).thenReturn(Optional.empty()); + + // WHEN + var output = + exportLinks.run(new Input.PageForCatalog(catalogSlug, new Input.Pagination(page, perPage))); + + // THEN + Assertions.assertInstanceOf(Output.Failed.class, output); + } + + // -- FOR NON BRANDED -- + + @Test + public void canExportLinksPageForNonBranded() { + // GIVEN + var page = 0; + var perPage = 50; + var linkResult = Mockito.mock(Link.class); + var links = List.of(linkResult); + var paginatedData = PaginatedData.wrap(links, links.size(), 0, links.size()); + + Mockito.when(linkRepository.findAllForBrand(Mockito.any(Pagination.class), Mockito.isNull())) + .thenReturn(paginatedData); + + // WHEN + var output = exportLinks.run(new Input.PageForNonBranded(new Input.Pagination(page, perPage))); + + // THEN + Assertions.assertInstanceOf(Output.Page.class, output); + var outputPage = (Output.Page) output; + Assertions.assertEquals(links.size(), outputPage.rows().size()); + } + + @Test + public void canExportEmptyLinksPageForNonBranded() { + // GIVEN + var page = 0; + var perPage = 50; + var links = List.of(); + var paginatedData = PaginatedData.wrap(links, 0, 0, 0); + + Mockito.when(linkRepository.findAllForBrand(Mockito.any(Pagination.class), Mockito.isNull())) + .thenReturn(paginatedData); + + // WHEN + var output = exportLinks.run(new Input.PageForNonBranded(new Input.Pagination(page, perPage))); + + // THEN + Assertions.assertInstanceOf(Output.NoMoreToExport.class, output); + } + + @Test + public void cannotCreateInputPaginationWithInvalidPageNumber() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var page = -1; + var perPage = 1; + + // WHEN + new Input.Pagination(page, perPage); + }); + } + + @Test + public void cannotCreateInputPaginationWithInvalidPerPageNumber() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var page = 0; + var perPage = 0; + + // WHEN + new Input.Pagination(page, perPage); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/GetLinkTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/GetLinkTest.java new file mode 100644 index 0000000..42a8cf2 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/GetLinkTest.java @@ -0,0 +1,163 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.links.GetLink.Input; +import tech.pegase.z3.domain.usecase.links.GetLink.Output; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public class GetLinkTest { + private final LinkRepository linkRepository; + private final CatalogRepository catalogRepository; + private final TagRepository tagRepository; + private final GetLink getLink; + + GetLinkTest() { + this.linkRepository = Mockito.mock(LinkRepository.class); + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.tagRepository = Mockito.mock(TagRepository.class); + this.getLink = new GetLinkImpl(linkRepository, catalogRepository, tagRepository); + } + + @Test + public void canGetLinkWithTagAndCatalog() { + // GIVEN + var link = getNewLink(); + var tag = getNewTag(); + var catalog = getNewCatalog(); + link.setTag(tag.getUuid()); + link.setCatalog(catalog.getUuid()); + var source = link.getSource(); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + + Mockito.when(tagRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(tag)); + Mockito.when(catalogRepository.findByUuid(Mockito.any(UUID.class))) + .thenReturn(Optional.of(catalog)); + + // WHEN + var output = getLink.run(new Input.ForSource(source)); + + // THEN + Assertions.assertInstanceOf(Output.Found.class, output); + var found = (Output.Found) output; + Assertions.assertNotNull(found.link().tag()); + Assertions.assertNotNull(found.link().catalog()); + } + + @Test + public void canGetLinkWithTagAndWithoutCatalog() { + // GIVEN + var link = getNewLink(); + var tag = getNewTag(); + link.setTag(tag.getUuid()); + link.setCatalog(null); + var source = link.getSource(); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + + Mockito.when(tagRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(tag)); + + // WHEN + var output = getLink.run(new Input.ForSource(source)); + + // THEN + Assertions.assertInstanceOf(Output.Found.class, output); + var found = (Output.Found) output; + Assertions.assertNotNull(found.link().tag()); + Assertions.assertNull(found.link().catalog()); + } + + @Test + public void canGetLinkWithoutTagAndWithCatalog() { + // GIVEN + var link = getNewLink(); + var catalog = getNewCatalog(); + link.setTag(null); + link.setCatalog(catalog.getUuid()); + var source = link.getSource(); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + + Mockito.when(catalogRepository.findByUuid(Mockito.any(UUID.class))) + .thenReturn(Optional.of(catalog)); + + // WHEN + var output = getLink.run(new Input.ForSource(source)); + + // THEN + Assertions.assertInstanceOf(Output.Found.class, output); + var found = (Output.Found) output; + Assertions.assertNull(found.link().tag()); + Assertions.assertNotNull(found.link().catalog()); + } + + @Test + public void canGetLinkWithoutTagAndCatalog() { + // GIVEN + var link = getNewLink(); + link.setTag(null); + link.setCatalog(null); + var source = link.getSource(); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + + // WHEN + var output = getLink.run(new Input.ForSource(source)); + + // THEN + Assertions.assertInstanceOf(Output.Found.class, output); + var found = (Output.Found) output; + Assertions.assertNull(found.link().tag()); + Assertions.assertNull(found.link().catalog()); + } + + @Test + public void cannotGetLinkIfDoesNotExists() { + // GIVEN + var link = getNewLink(); + var tag = getNewTag(); + var catalog = getNewCatalog(); + link.setTag(tag.getUuid()); + link.setCatalog(catalog.getUuid()); + var source = link.getSource(); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.empty()); + + // WHEN + var output = getLink.run(new Input.ForSource(source)); + + // THEN + Assertions.assertInstanceOf(Output.NoSuchSource.class, output); + } + + private Link getNewLink() { + return new Link( + Source.of("a_new_link"), + Target.of("https://example.net"), + true, + UUID.randomUUID(), + UUID.randomUUID(), + UUID.randomUUID()); + } + + private Tag getNewTag() { + return new Tag("Tag name", Color.of("#fff")); + } + + private Catalog getNewCatalog() { + return new Catalog(Slug.of("super-tag"), "Super tag", UUID.randomUUID()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ImportLinksTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ImportLinksTest.java new file mode 100644 index 0000000..726d634 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ImportLinksTest.java @@ -0,0 +1,417 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.port.service.RandomSourceGenerator; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Input; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Input.ImportLine; +import tech.pegase.z3.domain.usecase.links.ImportLinks.Output; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.valueobject.Source; + +public class ImportLinksTest { + private final RandomSourceGenerator randomSourceGenerator; + private final BrandRepository brandRepository; + private final CatalogRepository catalogRepository; + private final LinkRepository linkRepository; + private final TagRepository tagRepository; + private final Slugifyier slugifyier; + private final ImportLinks importLinks; + + ImportLinksTest() { + this.randomSourceGenerator = Mockito.mock(RandomSourceGenerator.class); + this.linkRepository = Mockito.mock(LinkRepository.class); + this.brandRepository = Mockito.mock(BrandRepository.class); + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.tagRepository = Mockito.mock(TagRepository.class); + this.slugifyier = Mockito.mock(Slugifyier.class); + this.importLinks = + new ImportLinksImpl( + randomSourceGenerator, + slugifyier, + linkRepository, + tagRepository, + brandRepository, + catalogRepository); + } + + @Test + public void canImportLinksForCatalog() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var importLine = new ImportLine("source", "https://example.net", false); + var lines = List.of(importLine); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("slug")); + + Mockito.when(catalog.getBrandUuid()).thenReturn(UUID.randomUUID()); + Mockito.when(catalog.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.doNothing().when(linkRepository).updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForCatalog(catalogUuid, tagUuid, lines)); + + // THEN + Mockito.verify(randomSourceGenerator, Mockito.never()).generate(); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(1, importResults.importedLinks()); + Assertions.assertEquals(0, importResults.failedImports().size()); + } + + @Test + public void canImportLinksForCatalogWhileIgnoringTheSameLinkAppearingMultipleTimesOnImport() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var importLine = new ImportLine("source", "https://example.net", false); + var importLine2 = new ImportLine("source", "https://example.net", false); + var lines = List.of(importLine, importLine2); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("slug")); + + Mockito.when(catalog.getBrandUuid()).thenReturn(UUID.randomUUID()); + Mockito.when(catalog.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.doNothing().when(linkRepository).updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForCatalog(catalogUuid, tagUuid, lines)); + + // THEN + Mockito.verify(randomSourceGenerator, Mockito.never()).generate(); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(1, importResults.importedLinks()); + Assertions.assertEquals(1, importResults.failedImports().size()); + } + + @Test + public void canImportLinksForCatalogWithEmptySource() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var importLine = new ImportLine("", "https://example.net", false); + var lines = List.of(importLine); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("slug")); + + Mockito.when(catalog.getBrandUuid()).thenReturn(UUID.randomUUID()); + Mockito.when(catalog.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.doNothing().when(linkRepository).updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForCatalog(catalogUuid, tagUuid, lines)); + + // THEN + Mockito.verify(slugifyier, Mockito.never()).slugify(Mockito.any(String.class)); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(1, importResults.importedLinks()); + Assertions.assertEquals(0, importResults.failedImports().size()); + } + + @Test + public void canFailToImportLinksForCatalog() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var importLine = new ImportLine("source", "https://example.net", false); + var lines = List.of(importLine); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("slug")); + + Mockito.when(catalog.getBrandUuid()).thenReturn(UUID.randomUUID()); + Mockito.when(catalog.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + Mockito.doThrow(IllegalStateException.class) + .when(linkRepository) + .updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForCatalog(catalogUuid, tagUuid, lines)); + + // THEN + Mockito.verify(randomSourceGenerator, Mockito.never()).generate(); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(0, importResults.importedLinks()); + Assertions.assertEquals(1, importResults.failedImports().size()); + } + + @Test + public void cannotImportLinksForNonExistingCatalog() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var catalogUuid = UUID.randomUUID(); + var importLine = new ImportLine("source", "https://example.net", false); + var lines = List.of(importLine); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(catalogRepository.exists(catalogUuid)).thenReturn(false); + + // WHEN + var output = importLinks.run(new Input.ImportForCatalog(catalogUuid, tagUuid, lines)); + + // THEN + Mockito.verifyNoInteractions(linkRepository); + Assertions.assertInstanceOf(Output.NoSuchCatalog.class, output); + } + + @Test + public void canImportLinksForBrand() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var importLine = new ImportLine("source", "https://example.net", false); + var lines = List.of(importLine); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("slug")); + + Mockito.when(brand.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.doNothing().when(linkRepository).updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForBrand(brandUuid, tagUuid, lines)); + + // THEN + Mockito.verify(randomSourceGenerator, Mockito.never()).generate(); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(1, importResults.importedLinks()); + Assertions.assertEquals(0, importResults.failedImports().size()); + } + + @Test + public void canImportLinksForBrandAndUpsertOnExistingLinks() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var source = "source"; + var url = "https://example.net"; + var isExternal = false; + var link = Mockito.mock(Link.class); + var importLine = new ImportLine(source, url, isExternal); + var lines = List.of(importLine); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("source")); + + Mockito.when(link.getBrand()).thenReturn(brandUuid); + Mockito.when(linkRepository.findBySource(Mockito.any(Source.class))) + .thenReturn(Optional.of(link)); + + Mockito.when(brand.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.doNothing().when(linkRepository).updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForBrand(brandUuid, tagUuid, lines)); + + // THEN + Mockito.verify(randomSourceGenerator, Mockito.never()).generate(); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(1, importResults.importedLinks()); + Assertions.assertEquals(0, importResults.failedImports().size()); + } + + @Test + public void canImportLinksForBrandWhileIgnoringTheSameLinkAppearingMultipleTimesOnImport() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var source = "source"; + var url = "https://example.net"; + var isExternal = false; + var link = Mockito.mock(Link.class); + var importLine = new ImportLine(source, url, isExternal); + var importLine2 = new ImportLine(source, url, isExternal); + var lines = List.of(importLine, importLine2); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("source")); + + Mockito.when(link.getBrand()).thenReturn(brandUuid); + Mockito.when(linkRepository.findBySource(Mockito.any(Source.class))) + .thenReturn(Optional.of(link)); + + Mockito.when(brand.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.doNothing().when(linkRepository).updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForBrand(brandUuid, tagUuid, lines)); + + // THEN + Mockito.verify(randomSourceGenerator, Mockito.never()).generate(); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(1, importResults.importedLinks()); + Assertions.assertEquals(1, importResults.failedImports().size()); + } + + @Test + public void canImportLinksForBrandWithEmptySource() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var importLine = new ImportLine("", "https://example.net", false); + var lines = List.of(importLine); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("slug")); + + Mockito.when(brand.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.doNothing().when(linkRepository).updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForBrand(brandUuid, tagUuid, lines)); + + // THEN + Mockito.verify(slugifyier, Mockito.never()).slugify(Mockito.any(String.class)); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(1, importResults.importedLinks()); + Assertions.assertEquals(0, importResults.failedImports().size()); + } + + @Test + public void canFailToImportLinksForBrand() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var importLine = new ImportLine("source", "https://example.net", false); + var lines = List.of(importLine); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("slug")); + + Mockito.when(brand.getUuid()).thenReturn(UUID.randomUUID()); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.doThrow(IllegalStateException.class) + .when(linkRepository) + .updateOrPersist(Mockito.any(Link.class)); + + // WHEN + var output = importLinks.run(new Input.ImportForBrand(brandUuid, tagUuid, lines)); + + // THEN + Mockito.verify(randomSourceGenerator, Mockito.never()).generate(); + Mockito.verify(linkRepository).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(0, importResults.importedLinks()); + Assertions.assertEquals(1, importResults.failedImports().size()); + } + + @Test + public void canFailToUpsertLinksForBrandIfLinkBelongsToAnotherBrand() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var brandUuid = UUID.randomUUID(); + var anotherBrandUuid = UUID.randomUUID(); + var source = "source"; + var url = "https://example.net"; + var isExternal = false; + var link = Mockito.mock(Link.class); + var importLine = new ImportLine(source, url, isExternal); + var lines = List.of(importLine); + + Mockito.when(slugifyier.slugify(Mockito.any(String.class))).thenReturn(Slug.of("slug")); + Mockito.when(randomSourceGenerator.generate()).thenReturn(new Source("slug")); + + Mockito.when(link.getBrand()).thenReturn(anotherBrandUuid); + Mockito.when(linkRepository.findBySource(Mockito.any(Source.class))) + .thenReturn(Optional.of(link)); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + + // WHEN + var output = importLinks.run(new Input.ImportForBrand(brandUuid, tagUuid, lines)); + + // THEN + Mockito.verify(linkRepository, Mockito.never()).updateOrPersist(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.ImportResults.class, output); + var importResults = (Output.ImportResults) output; + Assertions.assertEquals(0, importResults.importedLinks()); + Assertions.assertEquals(1, importResults.failedImports().size()); + } + + @Test + public void cannotImportLinksForNonExistingBrand() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var brandUuid = UUID.randomUUID(); + var importLine = new ImportLine("source", "https://example.net", false); + var lines = List.of(importLine); + + Mockito.when(tagRepository.exists(tagUuid)).thenReturn(true); + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(false); + + // WHEN + var output = importLinks.run(new Input.ImportForBrand(brandUuid, tagUuid, lines)); + + // THEN + Mockito.verifyNoInteractions(linkRepository); + Assertions.assertInstanceOf(Output.NoSuchBrand.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ListLinksTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ListLinksTest.java new file mode 100644 index 0000000..6c22fbb --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/ListLinksTest.java @@ -0,0 +1,109 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.links.ListLinks.Input; +import tech.pegase.z3.domain.usecase.links.ListLinks.Output; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.domain.valueobject.concepts.Ordering; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering.Field; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; +import tech.pegase.z3.shared.valueobject.Source; + +public class ListLinksTest { + private final LinkRepository linkRepository; + private final CatalogRepository catalogRepository; + private final TagRepository tagRepository; + private final ListLinks listLinks; + + ListLinksTest() { + this.linkRepository = Mockito.mock(LinkRepository.class); + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.tagRepository = Mockito.mock(TagRepository.class); + listLinks = new ListLinksImpl(linkRepository, catalogRepository, tagRepository); + } + + @Test + public void canListLinksWithAllOptions() { + // GIVEN + var pagination = Pagination.none(); + var filters = LinkFiltering.empty(); + var ordering = LinkOrdering.of(Field.SOURCE, Ordering.Direction.ASC); + var source = Source.of("source"); + var target = Target.of("https://target.com"); + var brandUuid = UUID.randomUUID(); + var searching = + LinkSearching.of(LinkSearching.Field.SOURCE, LinkSearching.SearchValue.of("search value")); + var link = Mockito.mock(Link.class); + + var paginatedLinks = PaginatedData.wrap(List.of(link), 50, 0, 1); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(brandUuid); + + Mockito.when(linkRepository.findAll(pagination, filters, ordering, searching)) + .thenReturn(paginatedLinks); + + // WHEN + var output = + listLinks.run( + new Input.WithPaginationAndSearchingAllSorted( + pagination, filters, ordering, searching)); + + // THEN + Assertions.assertInstanceOf(Output.PaginatedLinks.class, output); + } + + @Test + public void canListLinksWithPaginationSorted() { + // GIVEN + var pagination = Pagination.none(); + var filters = LinkFiltering.empty(); + var ordering = LinkOrdering.of(Field.SOURCE, Ordering.Direction.ASC); + var source = Source.of("source"); + var target = Target.of("https://target.com"); + var brandUuid = UUID.randomUUID(); + var link = Mockito.mock(Link.class); + var tagUuid = UUID.randomUUID(); + var tagColor = Color.of("#fff"); + var tag = Mockito.mock(Tag.class); + var catalogUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + + var paginatedLinks = PaginatedData.wrap(List.of(link), 50, 0, 1); + + Mockito.when(tag.getColor()).thenReturn(tagColor); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(brandUuid); + Mockito.when(link.getTag()).thenReturn(tagUuid); + Mockito.when(link.getCatalog()).thenReturn(catalogUuid); + + Mockito.when(linkRepository.findAll(pagination, filters, ordering)).thenReturn(paginatedLinks); + Mockito.when(tagRepository.findByUuid(tagUuid)).thenReturn(Optional.of(tag)); + Mockito.when(catalogRepository.findByUuid(catalogUuid)).thenReturn(Optional.of(catalog)); + + // WHEN + var output = listLinks.run(new Input.WithPaginationSorted(pagination, filters, ordering)); + + // THEN + Assertions.assertInstanceOf(Output.PaginatedLinks.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/RedirectLinkTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/RedirectLinkTest.java new file mode 100644 index 0000000..d55db85 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/RedirectLinkTest.java @@ -0,0 +1,214 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.service.VerifyLinkService; +import tech.pegase.z3.domain.usecase.links.RedirectLink.Input; +import tech.pegase.z3.domain.usecase.links.RedirectLink.Output; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public class RedirectLinkTest { + private LinkRepository linkRepository; + private BrandRepository brandRepository; + private VerifyLinkService verifyLinkService; + private RedirectLink redirectLinkUseCase; + + @BeforeEach + public void setup() { + linkRepository = Mockito.mock(LinkRepository.class); + brandRepository = Mockito.mock(BrandRepository.class); + verifyLinkService = Mockito.mock(VerifyLinkService.class); + redirectLinkUseCase = new RedirectLinkImpl(linkRepository, brandRepository, verifyLinkService); + } + + @Test + public void canRedirectLinkWithBrandUsingBrandFqdn() { + // Arrange + var source = Source.of("some-source"); + var target = Target.of("https://example.net"); + var defaultFqdn = Fqdn.of("z3-default.net"); + var currentFqdn = Fqdn.of("brand-fqdn.net"); + var link = Mockito.mock(Link.class); + + var brandName = "some Brand"; + var brandFqdn = Fqdn.of("brand-fqdn.net"); + var brandFqdnSet = Set.of(brandFqdn); + var linkBrandUuid = UUID.randomUUID(); + var linkBrand = Mockito.mock(Brand.class); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(linkBrandUuid); + + Mockito.when(linkBrand.getFqdns()).thenReturn(brandFqdnSet); + Mockito.when(linkBrand.getName()).thenReturn(brandName); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.when(brandRepository.findByUuid(linkBrandUuid)).thenReturn(Optional.of(linkBrand)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // Act + var output = redirectLinkUseCase.run(new Input.ResolveWith(defaultFqdn, currentFqdn, source)); + + // Assert + Mockito.verify(linkRepository).findBySource(source); + Mockito.verify(brandRepository).findByUuid(linkBrandUuid); + Assertions.assertInstanceOf(Output.Found.class, output); + var redirectedOutput = (Output.Found) output; + Assertions.assertEquals(target, redirectedOutput.link().target()); + } + + @Test + public void canRedirectLinkWithBrandUsingDefaultFqdn() { + // Arrange + var source = Source.of("some-source"); + var target = Target.of("https://example.net"); + var defaultFqdn = Fqdn.of("z3-default.net"); + var currentFqdn = Fqdn.of("z3-default.net"); + var link = Mockito.mock(Link.class); + + var brandName = "some Brand"; + var brandFqdn = Fqdn.of("brand-fqdn.net"); + var brandFqdnSet = Set.of(brandFqdn); + var linkBrandUuid = UUID.randomUUID(); + var linkBrand = Mockito.mock(Brand.class); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(linkBrandUuid); + + Mockito.when(linkBrand.getFqdns()).thenReturn(brandFqdnSet); + Mockito.when(linkBrand.getName()).thenReturn(brandName); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.when(brandRepository.findByUuid(linkBrandUuid)).thenReturn(Optional.of(linkBrand)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // Act + var output = redirectLinkUseCase.run(new Input.ResolveWith(defaultFqdn, currentFqdn, source)); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Mockito.verify(brandRepository).findByUuid(linkBrandUuid); + Assertions.assertInstanceOf(Output.Found.class, output); + var redirectedOutput = (Output.Found) output; + Assertions.assertEquals(target, redirectedOutput.link().target()); + } + + @Test + public void canRedirectLinkWithoutBrandUsingDefaultFqdn() { + // Arrange + var source = Source.of("some-source"); + var target = Target.of("https://example.net"); + var defaultFqdn = Fqdn.of("z3-default.net"); + var currentFqdn = Fqdn.of("z3-default.net"); + var link = Mockito.mock(Link.class); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(null); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // Act + var output = redirectLinkUseCase.run(new Input.ResolveWith(defaultFqdn, currentFqdn, source)); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Mockito.verify(brandRepository, Mockito.never()).findByUuid(Mockito.any(UUID.class)); + Assertions.assertInstanceOf(Output.Found.class, output); + var redirectedOutput = (Output.Found) output; + Assertions.assertEquals(target, redirectedOutput.link().target()); + } + + @Test + public void cannotRedirectNonExistingLink() { + // Arrange + var source = Source.of("some-source"); + var defaultFqdn = Fqdn.of("z3-default.net"); + var currentFqdn = Fqdn.of("brand-fqdn.net"); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.empty()); + + // Act + var output = redirectLinkUseCase.run(new Input.ResolveWith(defaultFqdn, currentFqdn, source)); + + // Assert + Mockito.verify(linkRepository).findBySource(source); + Mockito.verify(verifyLinkService, Mockito.never()) + .verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf(Output.NoSuchSource.class, output); + } + + @Test + public void cannotRedirectLinkWithBrandUsingAnyOtherFqdn() { + // Arrange + var source = Source.of("some-source"); + var target = Target.of("https://example.net"); + var defaultFqdn = Fqdn.of("z3-default.net"); + var currentFqdn = Fqdn.of("any-other-fqdn.net"); + var link = Mockito.mock(Link.class); + + var brandName = "some Brand"; + var brandFqdn = Fqdn.of("brand-fqdn.net"); + var brandFqdnSet = Set.of(brandFqdn); + var linkBrandUuid = UUID.randomUUID(); + var linkBrand = Mockito.mock(Brand.class); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(linkBrandUuid); + + Mockito.when(linkBrand.getFqdns()).thenReturn(brandFqdnSet); + Mockito.when(linkBrand.getName()).thenReturn(brandName); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.when(brandRepository.findByUuid(linkBrandUuid)).thenReturn(Optional.of(linkBrand)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // Act + var output = redirectLinkUseCase.run(new Input.ResolveWith(defaultFqdn, currentFqdn, source)); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Mockito.verify(brandRepository).findByUuid(linkBrandUuid); + Assertions.assertInstanceOf(Output.WrongFqdn.class, output); + } + + @Test + public void cannotRedirectLinkWithoutBrandUsingAnyOtherFqdnThanDefault() { + // Arrange + var source = Source.of("some-source"); + var target = Target.of("https://example.net"); + var defaultFqdn = Fqdn.of("z3-default.net"); + var currentFqdn = Fqdn.of("any-other-fqdn.net"); + var link = Mockito.mock(Link.class); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(null); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // Act + var output = redirectLinkUseCase.run(new Input.ResolveWith(defaultFqdn, currentFqdn, source)); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Mockito.verify(brandRepository, Mockito.never()).findByUuid(Mockito.any(UUID.class)); + Assertions.assertInstanceOf(Output.WrongFqdn.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/UpdateLinkTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/UpdateLinkTest.java new file mode 100644 index 0000000..fc29767 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/UpdateLinkTest.java @@ -0,0 +1,219 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.service.VerifyLinkService; +import tech.pegase.z3.domain.usecase.links.UpdateLink.Input; +import tech.pegase.z3.domain.usecase.links.UpdateLink.Output; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.shared.valueobject.Source; + +public class UpdateLinkTest { + private LinkRepository linkRepository; + private TagRepository tagRepository; + private CatalogRepository catalogRepository; + private UpdateLink updateLinkUseCase; + private VerifyLinkService verifyLinkService; + + @BeforeEach + public void setup() { + linkRepository = Mockito.mock(LinkRepository.class); + tagRepository = Mockito.mock(TagRepository.class); + catalogRepository = Mockito.mock(CatalogRepository.class); + verifyLinkService = Mockito.mock(VerifyLinkService.class); + updateLinkUseCase = + new UpdateLinkImpl(linkRepository, tagRepository, catalogRepository, verifyLinkService); + } + + @Test + public void updateLinkForSourceShouldPersistWhenSourceExists() { + // Arrange + var link = getNewLink(); + var tag = getNewTag(); + var catalog = getNewCatalog(); + var source = link.getSource(); + var target = link.getTarget(); + var isExternal = link.isExternal(); + var tagUuid = tag.getUuid(); + var catalogSlug = catalog.getSlug(); + Mockito.when(linkRepository.findBySource(Mockito.any(Source.class))) + .thenReturn(Optional.of(link)); + Mockito.when(tagRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(tag)); + Mockito.when(catalogRepository.findBySlug(Mockito.any(Slug.class))) + .thenReturn(Optional.of(catalog)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // Act + var output = + updateLinkUseCase.run( + new Input.ForSource(source, target, isExternal, tagUuid, catalogSlug)); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Mockito.verify(tagRepository).findByUuid(Mockito.any(UUID.class)); + Mockito.verify(catalogRepository).findBySlug(Mockito.any(Slug.class)); + Mockito.verify(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf( + Output.Updated.class, output, "Update link use case should output the updated source"); + + var updatedOutput = (Output.Updated) output; + Assertions.assertEquals( + source, + updatedOutput.link().source(), + "Update link use case should return the same source as in input"); + Assertions.assertEquals( + target, + updatedOutput.link().target(), + "Update link use case should return the same source as in input"); + Assertions.assertEquals( + isExternal, + updatedOutput.link().isExternal(), + "Update link use case should return the same isExternal as in input"); + Assertions.assertEquals( + tagUuid, + Objects.requireNonNull(updatedOutput.link().tag()).uuid(), + "Update link use case should return the same tagUuid as in input"); + } + + @Test + public void canUpdateLinkWithoutTagAndCatalog() { + // Arrange + var link = getNewLink(); + var source = link.getSource(); + var target = link.getTarget(); + var isExternal = link.isExternal(); + Mockito.when(linkRepository.findBySource(Mockito.any(Source.class))) + .thenReturn(Optional.of(link)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + + // Act + var output = updateLinkUseCase.run(new Input.ForSource(source, target, isExternal, null, null)); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Mockito.verify(verifyLinkService).verifyAndPersistValidity(Mockito.any(Link.class)); + Assertions.assertInstanceOf( + Output.Updated.class, output, "Update link use case should output the updated source"); + + var updatedOutput = (Output.Updated) output; + Assertions.assertEquals( + source, + updatedOutput.link().source(), + "Update link use case should return the same source as in input"); + Assertions.assertEquals( + target, + updatedOutput.link().target(), + "Update link use case should return the same source as in input"); + Assertions.assertEquals( + isExternal, + updatedOutput.link().isExternal(), + "Update link use case should return the same isExternal as in input"); + } + + @Test + public void updateLinkUsingSourceCanBeMissing() { + // Arrange + var link = getNewLink(); + var source = link.getSource(); + var isExternal = link.isExternal(); + var tagUuid = link.getTag(); + var catalog = getNewCatalog(); + var newTarget = new Target("https://example.fr/my/new/path?foo=bar"); + + Mockito.doAnswer(i -> Optional.empty()).when(linkRepository).findBySource(source); + + // Act + var output = + updateLinkUseCase.run( + new Input.ForSource(source, newTarget, isExternal, tagUuid, catalog.getSlug())); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Assertions.assertInstanceOf( + Output.NoSuchSource.class, + output, + "Given a source, a link cannot be updated if it does not exist"); + } + + @Test + public void cannotUpdateLinkIfTagDoesNotExists() { + // Arrange + var link = getNewLink(); + var source = link.getSource(); + var isExternal = link.isExternal(); + var tagUuid = link.getTag(); + var catalog = getNewCatalog(); + var newTarget = new Target("https://example.fr/my/new/path?foo=bar"); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.when(tagRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.empty()); + + // Act + var output = + updateLinkUseCase.run( + new Input.ForSource(source, newTarget, isExternal, tagUuid, catalog.getSlug())); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Mockito.verify(linkRepository, Mockito.never()).update(link); + Assertions.assertInstanceOf(Output.NotUpdated.class, output); + } + + @Test + public void cannotUpdateLinkIfCatalogDoesNotExists() { + // Arrange + var link = getNewLink(); + var tag = getNewTag(); + var source = link.getSource(); + var isExternal = link.isExternal(); + var tagUuid = link.getTag(); + var catalog = getNewCatalog(); + var newTarget = new Target("https://example.fr/my/new/path?foo=bar"); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.when(tagRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(tag)); + Mockito.when(catalogRepository.findByUuid(Mockito.any(UUID.class))) + .thenReturn(Optional.empty()); + + // Act + var output = + updateLinkUseCase.run( + new Input.ForSource(source, newTarget, isExternal, tagUuid, catalog.getSlug())); + + // Assert + Mockito.verify(linkRepository).findBySource(Mockito.any(Source.class)); + Mockito.verify(linkRepository, Mockito.never()).update(link); + Assertions.assertInstanceOf(Output.NotUpdated.class, output); + } + + private Tag getNewTag() { + return new Tag("Super tag", Color.of("#fff")); + } + + private Catalog getNewCatalog() { + return new Catalog(Slug.of("super-tag"), "Super tag", UUID.randomUUID()); + } + + private Link getNewLink() { + return new Link( + new Source("a_new_link"), + new Target("https://example.net"), + true, + null, + getNewTag().getUuid(), + getNewTag().getUuid()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/VerifyLinkTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/VerifyLinkTest.java new file mode 100644 index 0000000..07b0923 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/links/VerifyLinkTest.java @@ -0,0 +1,77 @@ +package tech.pegase.z3.domain.usecase.links; + +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.service.VerifyLinkService; +import tech.pegase.z3.domain.usecase.links.VerifyLink.Input; +import tech.pegase.z3.domain.usecase.links.VerifyLink.Output; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.shared.valueobject.Source; + +public class VerifyLinkTest { + private final LinkRepository linkRepository; + private final VerifyLinkService verifyLinkService; + private final VerifyLink verifyLink; + + VerifyLinkTest() { + this.linkRepository = Mockito.mock(LinkRepository.class); + this.verifyLinkService = Mockito.mock(VerifyLinkService.class); + this.verifyLink = new VerifyLinkImpl(linkRepository, verifyLinkService); + } + + @Test + public void canVerifyLinkGetValidity() { + // GIVEN + var source = new Source("link-test"); + var link = Mockito.mock(Link.class); + + Mockito.when(link.getValidity()).thenReturn(LinkValidity.valid()); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(link); + + // WHEN + var output = verifyLink.run(new Input.Verify(source)); + + // THEN + Mockito.verify(verifyLinkService).verifyAndPersistValidity(link); + Assertions.assertInstanceOf(Output.TargetIsValid.class, output); + } + + @Test + public void canVerifyLinkIsInvalid() { + // GIVEN + var source = new Source("link-test"); + var link = Mockito.mock(Link.class); + + Mockito.when(link.getValidity()).thenReturn(LinkValidity.invalid()); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.of(link)); + Mockito.doNothing().when(verifyLinkService).verifyAndPersistValidity(link); + + // WHEN + var output = verifyLink.run(new Input.Verify(source)); + + // THEN + Mockito.verify(verifyLinkService).verifyAndPersistValidity(link); + Assertions.assertInstanceOf(Output.TargetIsNotValid.class, output); + } + + @Test + public void cannotVerifyLinkIsValidIfItDoesNotExists() { + // GIVEN + var source = new Source("link-test"); + + Mockito.when(linkRepository.findBySource(source)).thenReturn(Optional.empty()); + + // WHEN + var output = verifyLink.run(new Input.Verify(source)); + + // THEN + Assertions.assertInstanceOf(Output.NoSuchLink.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptionsTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptionsTest.java new file mode 100644 index 0000000..28a168e --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/GetReportingSubscriptionsTest.java @@ -0,0 +1,69 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.ReportingSubscriptionRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.usecase.reporting.GetReportingSubscriptions.Input; +import tech.pegase.z3.domain.usecase.reporting.GetReportingSubscriptions.Output; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.Role.AccessLevel; + +public class GetReportingSubscriptionsTest { + private final UserRepository userRepository; + private final ReportingSubscriptionRepository reportingSubscriptionRepository; + private final GetReportingSubscriptions getReportingSubscriptions; + + public GetReportingSubscriptionsTest() { + this.reportingSubscriptionRepository = Mockito.mock(ReportingSubscriptionRepository.class); + this.userRepository = Mockito.mock(UserRepository.class); + this.getReportingSubscriptions = + new GetReportingSubscriptionsImpl(reportingSubscriptionRepository, userRepository); + } + + @Test + public void canGetReportingSubscriptionsForUser() { + // Arrange + var user = getNewUser(); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.when(reportingSubscriptionRepository.getAllSubscriptions(Mockito.any(UUID.class))) + .thenReturn(Set.of()); + + // Act + var output = getReportingSubscriptions.run(new Input.GetSubscriptionsForUser(user.getUuid())); + + // Assert + Mockito.verify(userRepository).findByUuid(Mockito.any(UUID.class)); + Mockito.verify(reportingSubscriptionRepository).getAllSubscriptions(Mockito.any(UUID.class)); + Assertions.assertInstanceOf(Output.ReportSubscriptionsForUser.class, output); + } + + @Test + public void cannotGetReportingSubscriptionsForNonExistingUser() { + // Arrange + var user = getNewUser(); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.empty()); + + // Act + var output = getReportingSubscriptions.run(new Input.GetSubscriptionsForUser(user.getUuid())); + + // Assert + Mockito.verify(userRepository).findByUuid(Mockito.any(UUID.class)); + Assertions.assertInstanceOf(Output.NoSuchUser.class, output); + } + + private User getNewUser() { + return new User( + "firstName", + "lastName", + Email.of("user@example.net"), + Role.of(AccessLevel.PUBLISHER), + UUID.randomUUID()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/SendReportsTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/SendReportsTest.java new file mode 100644 index 0000000..c4ef796 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/SendReportsTest.java @@ -0,0 +1,238 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.apache.commons.lang3.reflect.FieldUtils; +import org.jboss.logging.Logger; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.ReportingSubscription; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.ReportingSubscriptionRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.MessageSender; +import tech.pegase.z3.domain.usecase.reporting.SendReports.Input; +import tech.pegase.z3.domain.usecase.reporting.SendReports.Output; +import tech.pegase.z3.domain.valueobject.Message; +import tech.pegase.z3.domain.valueobject.Target; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; +import tech.pegase.z3.shared.valueobject.Source; + +public class SendReportsTest { + private final Logger logger; + private final ReportingSubscriptionRepository reportingSubscriptionRepository; + private final LinkRepository linkRepository; + private final UserRepository userRepository; + private final BrandRepository brandRepository; + private final MessageSender messageSender; + private final SendReports sendReports; + + SendReportsTest() throws IllegalAccessException { + this.reportingSubscriptionRepository = Mockito.mock(ReportingSubscriptionRepository.class); + this.linkRepository = Mockito.mock(LinkRepository.class); + this.userRepository = Mockito.mock(UserRepository.class); + this.brandRepository = Mockito.mock(BrandRepository.class); + this.messageSender = Mockito.mock(MessageSender.class); + this.sendReports = + new SendReportsImpl( + reportingSubscriptionRepository, + linkRepository, + userRepository, + brandRepository, + messageSender); + + // Mock logger and set it accessible for test in order to silent logger while testing + this.logger = Mockito.mock(Logger.class); + var field = FieldUtils.getDeclaredField(SendReportsImpl.class, "logger", true); + field.trySetAccessible(); + FieldUtils.removeFinalModifier(field); + field.set(sendReports, logger); + } + + @Test + public void canSendReports() { + // GIVEN + var sender = "z3.reports@example.net"; + var userUuid = UUID.randomUUID(); + var user = Mockito.mock(User.class); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var source = new Source("source"); + var target = new Target("https://link.example.net"); + var link = Mockito.mock(Link.class); + + var reportingSubscription = new ReportingSubscription(userUuid, brandUuid); + var paginatedLinks = PaginatedData.wrap(List.of(link), 50, 0, 1); + var messageSendResult = new MessageSender.Status.Success(); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(brandUuid); + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + Mockito.when(user.getUuid()).thenReturn(userUuid); + + Mockito.when(reportingSubscriptionRepository.getAllSubscriptions()) + .thenReturn(Set.of(reportingSubscription)); + + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(paginatedLinks) + .thenReturn(PaginatedData.empty()); + + Mockito.when(userRepository.findByUuid(userUuid)).thenReturn(Optional.of(user)); + Mockito.when(brandRepository.findByUuid(brandUuid)).thenReturn(Optional.of(brand)); + Mockito.when(messageSender.send(Mockito.any(Message.class))).thenReturn(messageSendResult); + + // WHEN + var output = sendReports.run(new Input.SendReportsForInvalidLinks(sender)); + + // THEN + Mockito.verify(messageSender).send(Mockito.any(Message.class)); + Assertions.assertInstanceOf(Output.AllReportSentWithSuccess.class, output); + } + + @Test + public void canSendReportsWithSomeFailures() { + // GIVEN + var sender = "z3.reports@example.net"; + var userUuid = UUID.randomUUID(); + var user = Mockito.mock(User.class); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var source = + new Source( + "sourcesourcesourcesourcesourcesourcesourcesourcesourcesourcesourcesourcesourcesourcesourcesource"); + var target = new Target("https://link.example.net"); + var link = Mockito.mock(Link.class); + + var reportingSubscription = new ReportingSubscription(userUuid, brandUuid); + var paginatedLinks = PaginatedData.wrap(List.of(link), 50, 0, 1); + var messageSendResult = new MessageSender.Status.Failed(); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(brandUuid); + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + Mockito.when(user.getUuid()).thenReturn(userUuid); + + Mockito.when(reportingSubscriptionRepository.getAllSubscriptions()) + .thenReturn(Set.of(reportingSubscription)); + + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(paginatedLinks) + .thenReturn(PaginatedData.empty()); + + Mockito.when(userRepository.findByUuid(userUuid)).thenReturn(Optional.of(user)); + Mockito.when(brandRepository.findByUuid(brandUuid)).thenReturn(Optional.of(brand)); + Mockito.when(messageSender.send(Mockito.any(Message.class))).thenReturn(messageSendResult); + + // WHEN + var output = sendReports.run(new Input.SendReportsForInvalidLinks(sender)); + + // THEN + Mockito.verify(messageSender).send(Mockito.any(Message.class)); + Assertions.assertInstanceOf(Output.ReportsFailedFor.class, output); + var failures = (Output.ReportsFailedFor) output; + Assertions.assertEquals( + "Failed sending report to user '%s'".formatted(user.getUuid()), + failures.reasonsPerUser().get(userUuid)); + } + + @Test + public void canIgnoreSendingReportsIfNoLinksAreConcerned() { + // GIVEN + var sender = "z3.reports@example.net"; + var userUuid = UUID.randomUUID(); + var user = Mockito.mock(User.class); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var source = new Source("source"); + var target = new Target("https://link.example.net"); + var link = Mockito.mock(Link.class); + + var reportingSubscription = new ReportingSubscription(userUuid, brandUuid); + var messageSendResult = new MessageSender.Status.Success(); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(brandUuid); + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + Mockito.when(user.getUuid()).thenReturn(userUuid); + + Mockito.when(reportingSubscriptionRepository.getAllSubscriptions()) + .thenReturn(Set.of(reportingSubscription)); + + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(PaginatedData.empty()); + + Mockito.when(userRepository.findByUuid(userUuid)).thenReturn(Optional.of(user)); + Mockito.when(brandRepository.findByUuid(brandUuid)).thenReturn(Optional.of(brand)); + Mockito.when(messageSender.send(Mockito.any(Message.class))).thenReturn(messageSendResult); + + // WHEN + var output = sendReports.run(new Input.SendReportsForInvalidLinks(sender)); + + // THEN + Mockito.verifyNoInteractions(messageSender); + Assertions.assertInstanceOf(Output.AllReportSentWithSuccess.class, output); + } + + @Test + public void canContinueSendingReportsIfUserOrBrandCannotBeFound() { + // GIVEN + var sender = "z3.reports@example.net"; + var userUuid = UUID.randomUUID(); + var user = Mockito.mock(User.class); + var brandUuid = UUID.randomUUID(); + var brand = Mockito.mock(Brand.class); + var source = new Source("source"); + var target = new Target("https://link.example.net"); + var link = Mockito.mock(Link.class); + + var paginatedLinks = PaginatedData.wrap(List.of(link), 50, 0, 1); + var reportingSubscription = new ReportingSubscription(userUuid, brandUuid); + var messageSendResult = new MessageSender.Status.Success(); + + Mockito.doNothing() + .when(logger) + .warn(Mockito.anyString(), Mockito.any(NoSuchElementException.class)); + Mockito.doNothing().when(logger).warn(Mockito.anyString()); + + Mockito.when(link.getSource()).thenReturn(source); + Mockito.when(link.getTarget()).thenReturn(target); + Mockito.when(link.getBrand()).thenReturn(brandUuid); + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + Mockito.when(user.getUuid()).thenReturn(userUuid); + + Mockito.when(reportingSubscriptionRepository.getAllSubscriptions()) + .thenReturn(Set.of(reportingSubscription)); + + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(paginatedLinks) + .thenReturn(PaginatedData.empty()); + + Mockito.when(userRepository.findByUuid(userUuid)).thenReturn(Optional.of(user)); + Mockito.when(brandRepository.findByUuid(brandUuid)).thenReturn(Optional.empty()); + Mockito.when(messageSender.send(Mockito.any(Message.class))).thenReturn(messageSendResult); + + // WHEN + var output = sendReports.run(new Input.SendReportsForInvalidLinks(sender)); + + // THEN + Mockito.verifyNoInteractions(messageSender); + Assertions.assertInstanceOf(Output.ReportsFailedFor.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptionsTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptionsTest.java new file mode 100644 index 0000000..6fb8bf8 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/reporting/UpdateUserReportingSubscriptionsTest.java @@ -0,0 +1,130 @@ +package tech.pegase.z3.domain.usecase.reporting; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.ReportingSubscriptionRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.usecase.reporting.UpdateUserReportingSubscriptions.Input; +import tech.pegase.z3.domain.usecase.reporting.UpdateUserReportingSubscriptions.Output; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.Role.AccessLevel; +import tech.pegase.z3.domain.valueobject.Slug; + +public class UpdateUserReportingSubscriptionsTest { + private final UserRepository userRepository; + private final BrandRepository brandRepository; + private final ReportingSubscriptionRepository reportingSubscriptionRepository; + private final UpdateUserReportingSubscriptions updateUserReportingSubscriptions; + + UpdateUserReportingSubscriptionsTest() { + this.userRepository = Mockito.mock(UserRepository.class); + this.brandRepository = Mockito.mock(BrandRepository.class); + this.reportingSubscriptionRepository = Mockito.mock(ReportingSubscriptionRepository.class); + this.updateUserReportingSubscriptions = + new UpdateUserReportingSubscriptionsImpl( + userRepository, brandRepository, reportingSubscriptionRepository); + } + + @Test + public void canUpdateUserReportSubscriptionsForUser() { + // Arrange + var user = getNewUser(); + var brands = Set.of(getNewBrand(), getNewBrand()); + user.setBrands(brands.stream().map(Brand::getUuid).collect(Collectors.toSet())); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(true); + Mockito.doNothing() + .when(reportingSubscriptionRepository) + .removeAllUserSubscriptions(Mockito.any(UUID.class)); + Mockito.doNothing().when(reportingSubscriptionRepository).setSubscriptions(Mockito.anySet()); + + // Act + var output = + updateUserReportingSubscriptions.run( + new Input.UpdateSubscriptionForUser( + user.getUuid(), brands.stream().map(Brand::getUuid).collect(Collectors.toSet()))); + + // Assert + Mockito.verify(reportingSubscriptionRepository) + .removeAllUserSubscriptions(Mockito.any(UUID.class)); + Mockito.verify(reportingSubscriptionRepository).setSubscriptions(Mockito.anySet()); + Assertions.assertInstanceOf(Output.SubscriptionsUpdated.class, output); + } + + @Test + public void cannotUpdateUserReportSubscriptionsForUserWhenSomeBrandsDoesNotBelongToUser() { + // Arrange + var user = getNewUser(); + var brands = Set.of(getNewBrand(), getNewBrand()); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(true); + + // Act + var output = + updateUserReportingSubscriptions.run( + new Input.UpdateSubscriptionForUser( + user.getUuid(), brands.stream().map(Brand::getUuid).collect(Collectors.toSet()))); + + // Assert + Assertions.assertInstanceOf(Output.UserDoNotBelongToTheseBrands.class, output); + } + + @Test + public void cannotUpdateUserReportSubscriptionsForUserWhenSomeBrandsDoesNotExist() { + // Arrange + var user = getNewUser(); + var brands = Set.of(getNewBrand(), getNewBrand()); + user.setBrands(brands.stream().map(Brand::getUuid).collect(Collectors.toSet())); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + + // Act + var output = + updateUserReportingSubscriptions.run( + new Input.UpdateSubscriptionForUser( + user.getUuid(), brands.stream().map(Brand::getUuid).collect(Collectors.toSet()))); + + // Assert + Assertions.assertInstanceOf(Output.TheseBrandsDoNotExist.class, output); + } + + @Test + public void canUpdateUserReportSubscriptionsForNonExistingUser() { + // Arrange + var user = getNewUser(); + var brands = Set.of(getNewBrand(), getNewBrand()); + user.setBrands(brands.stream().map(Brand::getUuid).collect(Collectors.toSet())); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.empty()); + + // Act + var output = + updateUserReportingSubscriptions.run( + new Input.UpdateSubscriptionForUser( + user.getUuid(), brands.stream().map(Brand::getUuid).collect(Collectors.toSet()))); + + // Assert + Assertions.assertInstanceOf(Output.NoSuchUser.class, output); + } + + private Brand getNewBrand() { + return new Brand(new Slug("my-catalog-2000"), "My Brand 2000"); + } + + private User getNewUser() { + return new User( + "firstName", + "lastName", + Email.of("user@example.net"), + Role.of(AccessLevel.PUBLISHER), + UUID.randomUUID()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatisticsTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatisticsTest.java new file mode 100644 index 0000000..2874910 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/statistics/GetGlobalStatisticsTest.java @@ -0,0 +1,91 @@ +package tech.pegase.z3.domain.usecase.statistics; + +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Input.GetStatistics; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Input.GetStatisticsForBrand; +import tech.pegase.z3.domain.usecase.statistics.GetGlobalStatistics.Output; + +public class GetGlobalStatisticsTest { + private final LinkRepository linkRepository; + private final BrandRepository brandRepository; + private final GetGlobalStatistics useCase; + + public GetGlobalStatisticsTest() { + this.linkRepository = Mockito.mock(LinkRepository.class); + this.brandRepository = Mockito.mock(BrandRepository.class); + this.useCase = new GetGlobalStatisticsImpl(linkRepository, brandRepository); + } + + @Test + public void canGetGlobalStatistics() { + // Arrange + Mockito.when(linkRepository.countTotalVisits()).thenReturn(11L); + Mockito.when(linkRepository.countTotalInvalids()).thenReturn(22L); + Mockito.when(linkRepository.countTotalVisits(null)).thenReturn(33L); + Mockito.when(linkRepository.countTotalExternal()).thenReturn(44L); + + // Assert + var output = useCase.run(new GetStatistics()); + + // Assert + Mockito.verify(linkRepository, Mockito.times(1)).countTotalVisits(); + Mockito.verify(linkRepository, Mockito.times(1)).countTotalInvalids(); + Mockito.verify(linkRepository, Mockito.times(1)).countTotalVisits(null); + Mockito.verify(linkRepository, Mockito.times(1)).countTotalExternal(); + Mockito.verifyNoMoreInteractions(linkRepository); + + Assertions.assertInstanceOf(Output.GeneralStatistics.class, output); + var generalStatistics = (Output.GeneralStatistics) output; + Assertions.assertEquals(11L, generalStatistics.statistics().getFirst().value()); + Assertions.assertEquals(22L, generalStatistics.statistics().get(1).value()); + Assertions.assertEquals(33L, generalStatistics.statistics().get(2).value()); + Assertions.assertEquals(44L, generalStatistics.statistics().get(3).value()); + Assertions.assertEquals(44L, generalStatistics.statistics().get(3).value()); + } + + @Test + public void canGetGlobalStatisticsForBrand() { + // Arrange + var brandUuid = UUID.randomUUID(); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(linkRepository.countTotalVisits(brandUuid)).thenReturn(11L); + Mockito.when(linkRepository.countTotalInvalids(brandUuid)).thenReturn(22L); + Mockito.when(linkRepository.countTotalExternal(brandUuid)).thenReturn(33L); + + // Assert + var output = useCase.run(new GetStatisticsForBrand(brandUuid)); + + // Assert + Mockito.verify(brandRepository, Mockito.times(1)).exists(brandUuid); + Mockito.verify(linkRepository, Mockito.times(1)).countTotalVisits(brandUuid); + Mockito.verify(linkRepository, Mockito.times(1)).countTotalInvalids(brandUuid); + Mockito.verify(linkRepository, Mockito.times(1)).countTotalExternal(brandUuid); + Mockito.verifyNoMoreInteractions(linkRepository); + + Assertions.assertInstanceOf(Output.GeneralStatistics.class, output); + var generalStatistics = (Output.GeneralStatistics) output; + Assertions.assertEquals(11L, generalStatistics.statistics().getFirst().value()); + Assertions.assertEquals(22L, generalStatistics.statistics().get(1).value()); + Assertions.assertEquals(33L, generalStatistics.statistics().get(2).value()); + } + + @Test + public void cannotGetGlobalStatisticsForNonExistingBrand() { + // Arrange + var brandUuid = UUID.randomUUID(); + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + + // Assert + var output = useCase.run(new GetStatisticsForBrand(brandUuid)); + + // Assert + Mockito.verify(brandRepository).exists(Mockito.any(UUID.class)); + Assertions.assertInstanceOf(Output.NoSuchBrand.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/statistics/GetLinkStatisticsTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/statistics/GetLinkStatisticsTest.java new file mode 100644 index 0000000..9d3545d --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/statistics/GetLinkStatisticsTest.java @@ -0,0 +1,264 @@ +package tech.pegase.z3.domain.usecase.statistics; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Input; +import tech.pegase.z3.domain.usecase.statistics.GetLinksStatistics.Output; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public class GetLinkStatisticsTest { + private final LinkRepository linkRepository; + private final BrandRepository brandRepository; + private final CatalogRepository catalogRepository; + private final TagRepository tagRepository; + private final GetLinksStatistics getLinksStatistics; + + GetLinkStatisticsTest() { + this.linkRepository = Mockito.mock(LinkRepository.class); + this.brandRepository = Mockito.mock(BrandRepository.class); + this.catalogRepository = Mockito.mock(CatalogRepository.class); + this.tagRepository = Mockito.mock(TagRepository.class); + this.getLinksStatistics = + new GetLinksStatisticsImpl( + linkRepository, brandRepository, catalogRepository, tagRepository); + } + + @Test + public void canGetLinkStatisticsForBrands() { + // GIVEN + var brand = Mockito.mock(Brand.class); + var brand1Uuid = UUID.randomUUID(); + var brand2Uuid = UUID.randomUUID(); + var brandName = "Brand name"; + var link1Result = Mockito.mock(Link.class); + var link2Result = Mockito.mock(Link.class); + var links = List.of(link1Result, link2Result); + var paginatedData = PaginatedData.wrap(links, links.size(), 0, links.size()); + + Mockito.when(link1Result.getVisits()).thenReturn(111); + Mockito.when(link1Result.getBrand()).thenReturn(brand1Uuid); + Mockito.when(link2Result.getVisits()).thenReturn(222); + Mockito.when(link2Result.getBrand()).thenReturn(brand2Uuid); + + Mockito.when(brand.getName()).thenReturn(brandName); + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(paginatedData) + .thenReturn(PaginatedData.empty()); + + // WHEN + var output = getLinksStatistics.run(new Input.ForBrands()); + + // THEN + Assertions.assertInstanceOf(Output.Statistics.class, output); + var linksStatistics = (Output.Statistics) output; + Assertions.assertEquals(links.size(), linksStatistics.visitsPerEntity().size()); + Assertions.assertEquals(111, linksStatistics.visitsPerEntity().get(brand1Uuid).totalVisits()); + Assertions.assertEquals(222, linksStatistics.visitsPerEntity().get(brand2Uuid).totalVisits()); + } + + @Test + public void canGetLinkStatisticsForBrandsWithEmptyLinks() { + // GIVEN + var brand = Mockito.mock(Brand.class); + var brandName = "Brand name"; + + Mockito.when(brand.getName()).thenReturn(brandName); + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(PaginatedData.empty()); + + // WHEN + var output = getLinksStatistics.run(new Input.ForBrands()); + + // THEN + Assertions.assertInstanceOf(Output.Statistics.class, output); + var linksStatistics = (Output.Statistics) output; + Assertions.assertEquals(0, linksStatistics.visitsPerEntity().size()); + } + + @Test + public void canGetLinkStatisticsForCatalog() { + // GIVEN + var brandUuid = UUID.randomUUID(); + var catalog = Mockito.mock(Catalog.class); + var catalog1Uuid = UUID.randomUUID(); + var catalog2Uuid = UUID.randomUUID(); + var catalogName = "Catalog name"; + var link1Result = Mockito.mock(Link.class); + var link2Result = Mockito.mock(Link.class); + var links = List.of(link1Result, link2Result); + var paginatedData = PaginatedData.wrap(links, links.size(), 0, links.size()); + + Mockito.when(link1Result.getVisits()).thenReturn(111); + Mockito.when(link1Result.getCatalog()).thenReturn(catalog1Uuid); + Mockito.when(link2Result.getVisits()).thenReturn(222); + Mockito.when(link2Result.getCatalog()).thenReturn(catalog2Uuid); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(catalog.getName()).thenReturn(catalogName); + Mockito.when(catalogRepository.find(Mockito.any())).thenReturn(Optional.of(catalog)); + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(paginatedData) + .thenReturn(PaginatedData.empty()); + + // WHEN + var output = getLinksStatistics.run(new Input.ForCatalogs(brandUuid)); + + // THEN + Assertions.assertInstanceOf(Output.Statistics.class, output); + var linksStatistics = (Output.Statistics) output; + Assertions.assertEquals(links.size(), linksStatistics.visitsPerEntity().size()); + Assertions.assertEquals(111, linksStatistics.visitsPerEntity().get(catalog1Uuid).totalVisits()); + Assertions.assertEquals(222, linksStatistics.visitsPerEntity().get(catalog2Uuid).totalVisits()); + } + + @Test + public void cannotGetLinkStatisticsForCatalogIfBrandDoesNotExist() { + // GIVEN + var brandUuid = UUID.randomUUID(); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(false); + + // WHEN + var output = getLinksStatistics.run(new Input.ForCatalogs(brandUuid)); + + // THEN + Assertions.assertInstanceOf(Output.NoSuchBrand.class, output); + } + + @Test + public void canGetLinkStatisticsForTag() { + // GIVEN + var brandUuid = UUID.randomUUID(); + var tag = Mockito.mock(Tag.class); + var tag1Uuid = UUID.randomUUID(); + var tag2Uuid = UUID.randomUUID(); + var tagName = "Tag name"; + var link1Result = Mockito.mock(Link.class); + var link2Result = Mockito.mock(Link.class); + var links = List.of(link1Result, link2Result); + var paginatedData = PaginatedData.wrap(links, links.size(), 0, links.size()); + + Mockito.when(link1Result.getVisits()).thenReturn(111); + Mockito.when(link1Result.getTag()).thenReturn(tag1Uuid); + Mockito.when(link2Result.getVisits()).thenReturn(222); + Mockito.when(link2Result.getTag()).thenReturn(tag2Uuid); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(tag.getName()).thenReturn(tagName); + Mockito.when(tagRepository.find(Mockito.any())).thenReturn(Optional.of(tag)); + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(paginatedData) + .thenReturn(PaginatedData.empty()); + + // WHEN + var output = getLinksStatistics.run(new Input.ForTags(brandUuid)); + + // THEN + Assertions.assertInstanceOf(Output.Statistics.class, output); + var linksStatistics = (Output.Statistics) output; + Assertions.assertEquals(links.size(), linksStatistics.visitsPerEntity().size()); + Assertions.assertEquals(111, linksStatistics.visitsPerEntity().get(tag1Uuid).totalVisits()); + Assertions.assertEquals(222, linksStatistics.visitsPerEntity().get(tag2Uuid).totalVisits()); + } + + @Test + public void canGetLinkStatisticsForTagWitNullValues() { + // GIVEN + var brandUuid = UUID.randomUUID(); + var tag = Mockito.mock(Tag.class); + var tag1Uuid = UUID.randomUUID(); + var tag2Uuid = UUID.randomUUID(); + var tagName = "Tag name"; + var link1Result = Mockito.mock(Link.class); + var link2Result = Mockito.mock(Link.class); + var links = List.of(link1Result, link2Result); + var paginatedData = PaginatedData.wrap(links, links.size(), 0, links.size()); + + Mockito.when(link1Result.getVisits()).thenReturn(111); + Mockito.when(link1Result.getTag()).thenReturn(null); + Mockito.when(link2Result.getVisits()).thenReturn(222); + Mockito.when(link2Result.getTag()).thenReturn(tag2Uuid); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(true); + Mockito.when(tag.getName()).thenReturn(tagName); + Mockito.when(tagRepository.find(Mockito.any())).thenReturn(Optional.of(tag)); + Mockito.when( + linkRepository.findAll(Mockito.any(Pagination.class), Mockito.any(LinkFiltering.class))) + .thenReturn(paginatedData) + .thenReturn(PaginatedData.empty()); + + // WHEN + var output = getLinksStatistics.run(new Input.ForTags(brandUuid)); + + // THEN + Assertions.assertInstanceOf(Output.Statistics.class, output); + var linksStatistics = (Output.Statistics) output; + Assertions.assertEquals(links.size() - 1, linksStatistics.visitsPerEntity().size()); + Assertions.assertNull(linksStatistics.visitsPerEntity().get(tag1Uuid)); + Assertions.assertEquals(222, linksStatistics.visitsPerEntity().get(tag2Uuid).totalVisits()); + } + + @Test + public void cannotGetLinkStatisticsForTagIfBrandDoesNotExist() { + // GIVEN + var brandUuid = UUID.randomUUID(); + + Mockito.when(brandRepository.exists(brandUuid)).thenReturn(false); + + // WHEN + var output = getLinksStatistics.run(new Input.ForTags(brandUuid)); + + // THEN + Assertions.assertInstanceOf(Output.NoSuchBrand.class, output); + } + + @Test + public void cannotCountOfWithInvalidName() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var brandUuid = UUID.randomUUID(); + var name = ""; + var visits = 0; + + // WHEN + new GetLinksStatisticsImpl.CountOf(brandUuid, name, visits); + }); + } + + @Test + public void cannotCountOfWithInvalidVisitsAmount() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var brandUuid = UUID.randomUUID(); + var name = "brand"; + var visits = -1; + + // WHEN + new GetLinksStatisticsImpl.CountOf(brandUuid, name, visits); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/CreateTagImplTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/CreateTagImplTest.java new file mode 100644 index 0000000..eeba08a --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/CreateTagImplTest.java @@ -0,0 +1,85 @@ +package tech.pegase.z3.domain.usecase.tags; + +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.CreateTag.Input; +import tech.pegase.z3.domain.usecase.tags.CreateTag.Output; +import tech.pegase.z3.domain.valueobject.Color; + +public class CreateTagImplTest { + private final TagRepository tagRepository; + private final CreateTagImpl createUseCase; + + @Inject + CreateTagImplTest() { + this.tagRepository = Mockito.mock(TagRepository.class); + this.createUseCase = new CreateTagImpl(tagRepository); + } + + @Test + public void canCreateTag() { + // GIVEN + var tagName = "Tag name"; + var tagColor = Color.of("#fff"); + + Mockito.when(tagRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + Mockito.when(tagRepository.findByName(Mockito.any(String.class))).thenReturn(Optional.empty()); + Mockito.doNothing().when(tagRepository).persist(Mockito.any(Tag.class)); + + // WHEN + var output = createUseCase.run(new Input.With(tagName, tagColor)); + + // THEN + Mockito.verify(tagRepository).persist(Mockito.any(Tag.class)); + + Assertions.assertInstanceOf( + Output.Created.class, output, "Given an name and color, a tag should be created"); + var createdOutput = (Output.Created) output; + Assertions.assertEquals( + tagName, + createdOutput.value().name(), + "Create tag use case should return the same name as input"); + Assertions.assertEquals( + tagColor, + createdOutput.value().color(), + "Create tag use case should return the same color as input"); + } + + @Test + public void cannotCreateTagIfNameIsEmpty() { + // GIVEN + var tagName = ""; + var tagColor = Color.of("#fff"); + + // WHEN + var output = createUseCase.run(new Input.With(tagName, tagColor)); + + // THEN + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateTagIfNameIsAlreadyTaken() { + // GIVEN + var tagName = "Tag name"; + var tagColor = Color.of("#fff"); + + Mockito.when(tagRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + Mockito.when(tagRepository.findByName(Mockito.any(String.class))) + .thenReturn(Optional.of(Mockito.mock(Tag.class))); + + // WHEN + var output = createUseCase.run(new Input.With(tagName, tagColor)); + + // THEN + Mockito.verify(tagRepository).findByName(Mockito.eq(tagName)); + Mockito.verifyNoMoreInteractions(tagRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/GetTagTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/GetTagTest.java new file mode 100644 index 0000000..a52e143 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/GetTagTest.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.domain.usecase.tags; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.GetTag.Input; +import tech.pegase.z3.domain.usecase.tags.GetTag.Output; + +public class GetTagTest { + private final TagRepository tagRepository; + private final GetTag getTag; + + public GetTagTest() { + this.tagRepository = Mockito.mock(TagRepository.class); + this.getTag = new GetTagImpl(tagRepository); + } + + @Test + public void canGetTag() { + // GIVEN + var tagUuid = UUID.randomUUID(); + var tag = Mockito.mock(Tag.class); + + Mockito.when(tagRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(tag)); + + // WHEN + var output = getTag.run(new Input.ForId(tagUuid)); + + // THEN + Assertions.assertInstanceOf(Output.Found.class, output); + } + + @Test + public void cannotGetNonExistingTag() { + // GIVEN + var tagUuid = UUID.randomUUID(); + + Mockito.when(tagRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.empty()); + + // WHEN + var output = getTag.run(new Input.ForId(tagUuid)); + + // THEN + Assertions.assertInstanceOf(Output.NoSuchTag.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/ListTagsTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/ListTagsTest.java new file mode 100644 index 0000000..3729c8f --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/ListTagsTest.java @@ -0,0 +1,59 @@ +package tech.pegase.z3.domain.usecase.tags; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.ListTags.Output.PaginatedTags; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public class ListTagsTest { + private TagRepository tagRepository; + private ListTags listTagsUseCase; + + @BeforeEach + public void setup() { + tagRepository = Mockito.mock(TagRepository.class); + listTagsUseCase = new ListTagsImpl(tagRepository); + } + + @Test + public void canGetBrandListWithPagination() { + // Arrange + var pagination = Pagination.fromPageNumberAndPageSize(1, 20); + var paginatedData = getFakePaginatedData(pagination); + + Mockito.when(tagRepository.findAll(Mockito.any(Pagination.class))).thenReturn(paginatedData); + + // Act + var output = listTagsUseCase.run(new ListTags.Input.WithPagination(pagination)); + + // Assert + Mockito.verify(tagRepository).findAll(Mockito.any(Pagination.class)); + Assertions.assertInstanceOf( + PaginatedTags.class, output, "List tags should output paginated tags"); + + var paginatedTags = (PaginatedTags) output; + Assertions.assertNotNull( + paginatedTags.tagList().items().getFirst(), + "List tags should contain the tag the repository outputs"); + Assertions.assertEquals( + paginatedData.items().getFirst().getColor(), + paginatedTags.tagList().items().getFirst().color(), + "List tags should contain the tag the repository outputs"); + } + + private PaginatedData getFakePaginatedData(Pagination pagination) { + var entityList = + List.of( + new Tag("qwerty", Color.of("#fff")), + new Tag("azerty", Color.of("#fff")), + new Tag( "dvorak", Color.of("#fff"))); + return PaginatedData.wrap(entityList, 50, pagination.offset(), entityList.size()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/RemoveTagImplTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/RemoveTagImplTest.java new file mode 100644 index 0000000..8fd9341 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/RemoveTagImplTest.java @@ -0,0 +1,76 @@ +package tech.pegase.z3.domain.usecase.tags; + +import jakarta.inject.Inject; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.RemoveTag.Input.WhereUuid; +import tech.pegase.z3.domain.usecase.tags.RemoveTag.Output.NoSuchTag; +import tech.pegase.z3.domain.usecase.tags.RemoveTag.Output.Removed; +import tech.pegase.z3.domain.valueobject.Color; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public class RemoveTagImplTest { + private final TagRepository tagRepository; + private final LinkRepository linkRepository; + private final RemoveTagImpl removeUseCase; + + @Inject + RemoveTagImplTest() { + this.tagRepository = Mockito.mock(TagRepository.class); + this.linkRepository = Mockito.mock(LinkRepository.class); + this.removeUseCase = new RemoveTagImpl(tagRepository, linkRepository); + } + + @Test + public void canDeleteTag() { + // Arrange + var link = Mockito.mock(Link.class); + var tag = getNewTag(); + var uuid = tag.getUuid(); + Mockito.when( + linkRepository.findAllForTag(Mockito.any(Pagination.class), Mockito.any(UUID.class))) + .thenReturn(PaginatedData.wrap(List.of(link), 1, 0, 0)) + .thenReturn(PaginatedData.empty()); + Mockito.when(tagRepository.exists(Mockito.any(UUID.class))).thenReturn(true); + Mockito.when(tagRepository.remove(Mockito.any(UUID.class))).thenReturn(true); + + // Act + var output = removeUseCase.run(new WhereUuid(uuid)); + + // Assert + Mockito.verify(tagRepository).exists(Mockito.any(UUID.class)); + Mockito.verify(tagRepository).remove(Mockito.any(UUID.class)); + + Assertions.assertInstanceOf( + Removed.class, output, "Given a uuid, a tag should be removed if it exists"); + } + + @Test + public void cannotDeleteTagIfItDoesNotExists() { + // Arrange + var tag = getNewTag(); + var uuid = tag.getUuid(); + Mockito.doAnswer(i -> false).when(tagRepository).exists(Mockito.any(UUID.class)); + + // Act + var output = removeUseCase.run(new WhereUuid(uuid)); + + // Assert + Mockito.verify(tagRepository).exists(Mockito.any(UUID.class)); + + Assertions.assertInstanceOf( + NoSuchTag.class, output, "Given a uuid, a tag should not be removed if it does not exist"); + } + + private Tag getNewTag() { + return new Tag("Tag name", Color.of("#fff")); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/UpdateTagImplTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/UpdateTagImplTest.java new file mode 100644 index 0000000..2b7ae65 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/tags/UpdateTagImplTest.java @@ -0,0 +1,116 @@ +package tech.pegase.z3.domain.usecase.tags; + +import jakarta.inject.Inject; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.domain.usecase.tags.UpdateTag.Input.WhereUuid; +import tech.pegase.z3.domain.valueobject.Color; + +public class UpdateTagImplTest { + private final TagRepository tagRepository; + private final UpdateTagImpl updateUseCase; + + @Inject + UpdateTagImplTest() { + this.tagRepository = Mockito.mock(TagRepository.class); + this.updateUseCase = new UpdateTagImpl(tagRepository); + } + + @Test + public void canUpdateTag() { + // Arrange + var tag = getNewTag(); + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + Mockito.doAnswer(i -> Optional.of(tag)).when(tagRepository).findByUuid(Mockito.any(UUID.class)); + + // Act + var output = updateUseCase.run(new WhereUuid(uuid, name, color)); + + // Assert + Mockito.verify(tagRepository).findByUuid(Mockito.any(UUID.class)); + + Assertions.assertInstanceOf( + UpdateTag.Output.Updated.class, + output, + "Given a slug, name and color, a tag should be updated"); + var createdOutput = (UpdateTag.Output.Updated) output; + Assertions.assertEquals( + name, + createdOutput.value().name(), + "Update tag use case should return the same name as input"); + Assertions.assertEquals( + color, + createdOutput.value().color(), + "Update tag use case should return the same color as input"); + } + + @Test + public void cannotUpdateTagIfNameIsEmpty() { + // Arrange + var tag = getNewTag(); + var uuid = tag.getUuid(); + var color = tag.getColor(); + + // Act + var output = updateUseCase.run(new WhereUuid(uuid, "", color)); + + // Assert + Assertions.assertInstanceOf( + UpdateTag.Output.NotUpdated.class, + output, + "Given a slug, name and color, a tag should not be updated if name is empty"); + } + + @Test + public void cannotUpdateTagIfItCannotBeFetched() { + // Arrange + var tag = getNewTag(); + var uuid = tag.getUuid(); + var name = tag.getName(); + var color = tag.getColor(); + Mockito.doAnswer(i -> Optional.empty()).when(tagRepository).findByUuid(Mockito.any(UUID.class)); + + // Act + var output = updateUseCase.run(new WhereUuid(uuid, name, color)); + + // Assert + Mockito.verify(tagRepository).findByUuid(Mockito.any(UUID.class)); + + Assertions.assertInstanceOf( + UpdateTag.Output.NotUpdated.class, + output, + "Given a slug, name and color, a tag should not be updated if cannot fetch the tag"); + } + + @Test + public void cannotUpdateTagIfNameIsAlreadyUsed() { + // Arrange + var tag = new Tag("Tag name", Color.of("#fff")); + var uuid = tag.getUuid(); + var name = "similarTag"; + var color = tag.getColor(); + var similarTag = new Tag("similarTag", Color.of("#fff")); + Mockito.when(tagRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(tag)); + Mockito.when(tagRepository.findByName(name)).thenReturn(Optional.of(similarTag)); + + // Act + var output = updateUseCase.run(new WhereUuid(uuid, name, color)); + + // Assert + Mockito.verify(tagRepository).findByUuid(Mockito.any(UUID.class)); + Mockito.verify(tagRepository).findByName(name); + Mockito.verifyNoMoreInteractions(tagRepository); + Assertions.assertInstanceOf(UpdateTag.Output.NotUpdated.class, output); + } + + private Tag getNewTag() { + return new Tag("Tag name", Color.of("#fff")); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ChangeUserPasswordTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ChangeUserPasswordTest.java new file mode 100644 index 0000000..9e21b4e --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ChangeUserPasswordTest.java @@ -0,0 +1,158 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.usecase.users.ChangeUserPassword.Input; +import tech.pegase.z3.domain.usecase.users.ChangeUserPassword.Output; +import tech.pegase.z3.domain.valueobject.Password; +import tech.pegase.z3.domain.valueobject.concepts.Hash; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +public class ChangeUserPasswordTest { + private final UserRepository userRepository; + private final CredentialRepository credentialRepository; + private final HashProvider hashProvider; + private final ChangeUserPassword changeUserPassword; + + public ChangeUserPasswordTest() { + this.userRepository = Mockito.mock(UserRepository.class); + this.credentialRepository = Mockito.mock(CredentialRepository.class); + this.hashProvider = Mockito.mock(HashProvider.class); + this.changeUserPassword = + new ChangeUserPasswordImpl(userRepository, credentialRepository, hashProvider); + } + + @Test + public void canChangeUserPassword() { + // Given + var userUuid = UUID.randomUUID(); + var credentialUuid = UUID.randomUUID(); + var user = Mockito.mock(User.class); + var credential = Mockito.mock(Credential.class); + var currentPassword = Password.of("P4$$word......."); + var newPassword = Password.of("NewP4$$word......."); + var curentPasswordHash = PasswordHash.of("$p$a$ssword"); + var newPasswordHash = PasswordHash.of("$newp$a$ssword"); + var input = new Input.WhereId(userUuid, currentPassword, newPassword); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.when(credentialRepository.findById(Mockito.any(UUID.class))) + .thenReturn(Optional.of(credential)); + Mockito.when(user.getCredentialUuid()).thenReturn(credentialUuid); + Mockito.when(hashProvider.areEquals(Mockito.any(Hash.class), Mockito.any(String.class))) + .thenReturn(true); + Mockito.when(credential.getPassword()).thenReturn(curentPasswordHash); + Mockito.when(hashProvider.hash(PasswordHash.class, newPassword.value())) + .thenReturn(newPasswordHash); + + // When + var output = changeUserPassword.run(input); + + // Then + Mockito.verify(hashProvider).hash(PasswordHash.class, newPassword.value()); + Mockito.verify(credential).setPassword(Mockito.any(PasswordHash.class)); + Mockito.verify(credentialRepository).update(Mockito.any(Credential.class)); + Assertions.assertInstanceOf(Output.Changed.class, output, "password should be changed"); + } + + @Test + public void cannotChangeUserPasswordIfUserDoesNotExist() { + // Given + var userUuid = UUID.randomUUID(); + var currentPassword = Password.of("P4$$word......."); + var newPassword = Password.of("NewP4$$word......."); + var input = new Input.WhereId(userUuid, currentPassword, newPassword); + + // When + var output = changeUserPassword.run(input); + + // Then + Assertions.assertInstanceOf( + Output.NoSuchUser.class, + output, + "password should not be changed if the user does not exists"); + } + + @Test + public void cannotChangeUserPasswordIfCredentialDoesNotExist() { + // Given + var userUuid = UUID.randomUUID(); + var user = Mockito.mock(User.class); + var currentPassword = Password.of("P4$$word......."); + var newPassword = Password.of("NewP4$$word......."); + var input = new Input.WhereId(userUuid, currentPassword, newPassword); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.when(credentialRepository.findById(Mockito.any(UUID.class))) + .thenReturn(Optional.empty()); + + // Then + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // When + changeUserPassword.run(input); + }); + } + + @Test + public void cannotChangeUserPasswordIfNewPasswordEqualsTheCurrentPassword() { + // Given + var userUuid = UUID.randomUUID(); + var credentialUuid = UUID.randomUUID(); + var user = Mockito.mock(User.class); + var credential = Mockito.mock(Credential.class); + var currentPassword = Password.of("P4$$word......."); + var newPassword = Password.of("P4$$word......."); + var input = new Input.WhereId(userUuid, currentPassword, newPassword); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.when(credentialRepository.findById(Mockito.any(UUID.class))) + .thenReturn(Optional.of(credential)); + Mockito.when(user.getCredentialUuid()).thenReturn(credentialUuid); + + // When + var output = changeUserPassword.run(input); + + // Then + Assertions.assertInstanceOf( + Output.NotChanged.class, + output, + "password should not be changed if new password is not different thant the actual"); + } + + @Test + public void cannotChangeUserPasswordIfCurrentPasswordDoesNotMatch() { + // Given + var userUuid = UUID.randomUUID(); + var credentialUuid = UUID.randomUUID(); + var user = Mockito.mock(User.class); + var credential = Mockito.mock(Credential.class); + var currentPassword = Password.of("P4$$word......."); + var newPassword = Password.of("NewP4$$word......."); + var curentPasswordHash = PasswordHash.of("$p$a$ssword"); + var input = new Input.WhereId(userUuid, currentPassword, newPassword); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.when(credentialRepository.findById(Mockito.any(UUID.class))) + .thenReturn(Optional.of(credential)); + Mockito.when(user.getCredentialUuid()).thenReturn(credentialUuid); + Mockito.when(hashProvider.areEquals(Mockito.any(Hash.class), Mockito.any(String.class))) + .thenReturn(false); + Mockito.when(credential.getPassword()).thenReturn(curentPasswordHash); + + // When + var output = changeUserPassword.run(input); + + // Then + Mockito.verify(hashProvider).areEquals(Mockito.any(Hash.class), Mockito.any(String.class)); + Assertions.assertInstanceOf( + Output.NotChanged.class, + output, + "password should not be changed if current password is invalid"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/CreateUserTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/CreateUserTest.java new file mode 100644 index 0000000..ac23fc6 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/CreateUserTest.java @@ -0,0 +1,354 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.port.service.MessageSender; +import tech.pegase.z3.domain.port.service.PasswordGenerator; +import tech.pegase.z3.domain.usecase.users.CreateUser.Input; +import tech.pegase.z3.domain.usecase.users.CreateUser.Output; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.Message; +import tech.pegase.z3.domain.valueobject.Role; +import tech.pegase.z3.domain.valueobject.Role.AccessLevel; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +public class CreateUserTest { + private final PasswordGenerator passwordGenerator; + private final MessageSender messageSender; + private final UserRepository userRepository; + private final BrandRepository brandRepository; + private final CredentialRepository credentialRepository; + private final HashProvider hashProvider; + private final CreateUser createUser; + + CreateUserTest() { + this.passwordGenerator = Mockito.mock(PasswordGenerator.class); + this.messageSender = Mockito.mock(MessageSender.class); + this.userRepository = Mockito.mock(UserRepository.class); + this.brandRepository = Mockito.mock(BrandRepository.class); + this.credentialRepository = Mockito.mock(CredentialRepository.class); + this.hashProvider = Mockito.mock(HashProvider.class); + + createUser = + new CreateUserImpl( + passwordGenerator, + messageSender, + userRepository, + brandRepository, + credentialRepository, + hashProvider); + } + + @Test + public void canCreateUserByAnAdmin() { + // GIVEN + var authUserUuid = UUID.randomUUID(); + var authUserRole = Role.of(AccessLevel.ADMINISTRATOR); + var authUser = Mockito.mock(User.class); + Mockito.when(authUser.getRole()).thenReturn(authUserRole); + + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + var passwordHash = PasswordHash.of("$Su$pass$word"); + var messageStatus = new MessageSender.Status.Success(); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))) + .thenReturn(Optional.of(authUser)); + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + Mockito.when(userRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.empty()); + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(true); + Mockito.when(hashProvider.hash(PasswordHash.class, "password")).thenReturn(passwordHash); + Mockito.doNothing().when(credentialRepository).persist(Mockito.any(Credential.class)); + Mockito.doNothing().when(userRepository).persist(Mockito.any(User.class)); + Mockito.when(messageSender.send(Mockito.any(Message.class))).thenReturn(messageStatus); + + // WHEN + var output = + createUser.run( + new Input.NewUserBy( + authUserUuid, firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(messageSender).send(Mockito.any(Message.class)); + Assertions.assertInstanceOf(Output.Created.class, output); + } + + @Test + public void canCreateUser() { + // GIVEN + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + var passwordHash = PasswordHash.of("$Su$pass$word"); + var messageStatus = new MessageSender.Status.Success(); + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + Mockito.when(userRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.empty()); + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(true); + Mockito.when(hashProvider.hash(PasswordHash.class, "password")).thenReturn(passwordHash); + Mockito.doNothing().when(credentialRepository).persist(Mockito.any(Credential.class)); + Mockito.doNothing().when(userRepository).persist(Mockito.any(User.class)); + Mockito.when(messageSender.send(Mockito.any(Message.class))).thenReturn(messageStatus); + + // WHEN + var output = + createUser.run(new Input.NewUser(firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(messageSender).send(Mockito.any(Message.class)); + Assertions.assertInstanceOf(Output.Created.class, output); + } + + @Test + public void cannotCreateUserByNonExistingUser() { + // GIVEN + var authUserUuid = UUID.randomUUID(); + + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.ADMINISTRATOR); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.empty()); + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + + // WHEN + var output = + createUser.run( + new Input.NewUserBy( + authUserUuid, firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(userRepository).findByUuid(Mockito.any(UUID.class)); + Mockito.verifyNoMoreInteractions(userRepository); + Assertions.assertInstanceOf(Output.Forbidden.class, output); + } + + @Test + public void cannotCreateUserByUserHavingInferiorRole() { + // GIVEN + var authUserUuid = UUID.randomUUID(); + var authUserRole = Role.of(AccessLevel.PUBLISHER); + var authUser = Mockito.mock(User.class); + Mockito.when(authUser.getRole()).thenReturn(authUserRole); + + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.ADMINISTRATOR); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))) + .thenReturn(Optional.of(authUser)); + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + + // WHEN + var output = + createUser.run( + new Input.NewUserBy( + authUserUuid, firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(userRepository).findByUuid(Mockito.any(UUID.class)); + Mockito.verifyNoMoreInteractions(userRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateUserWithEmptyFirstName() { + // GIVEN + var firstName = ""; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + + // WHEN + var output = + createUser.run(new Input.NewUser(firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verifyNoInteractions(userRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateUserWithEmptyLastName() { + // GIVEN + var firstName = "firstName"; + var lastName = ""; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + + // WHEN + var output = + createUser.run(new Input.NewUser(firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verifyNoInteractions(userRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateUserWithAlreadyExistingEmail() { + // GIVEN + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + Mockito.when(userRepository.exists(Mockito.any(Email.class))).thenReturn(true); + + // WHEN + var output = + createUser.run(new Input.NewUser(firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(userRepository).exists(Mockito.any(Email.class)); + Mockito.verifyNoMoreInteractions(userRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateUserWithAlreadyExistingCredentials() { + // GIVEN + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + Mockito.when(userRepository.exists(Mockito.any(Email.class))).thenReturn(false); + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.of(Mockito.mock(Credential.class))); + + // WHEN + var output = + createUser.run(new Input.NewUser(firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(userRepository).exists(Mockito.any(Email.class)); + Mockito.verify(credentialRepository).findByLogin(Mockito.any(Login.class)); + Mockito.verifyNoMoreInteractions(userRepository); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateUserWithANonExistingBrand() { + // GIVEN + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + Mockito.when(userRepository.exists(Mockito.any(Email.class))).thenReturn(false); + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.empty()); + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + + // WHEN + var output = + createUser.run(new Input.NewUser(firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(credentialRepository).findByLogin(Mockito.any(Login.class)); + Mockito.verify(brandRepository).exists(Mockito.any(UUID.class)); + Mockito.verify(userRepository, Mockito.times(0)).persist(Mockito.any(User.class)); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } + + @Test + public void cannotCreateUserIfMailIsNotSent() { + // GIVEN + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + var passwordHash = PasswordHash.of("$Su$pass$word"); + var messageStatus = new MessageSender.Status.Failed(); + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + Mockito.when(userRepository.exists(Mockito.any(UUID.class))).thenReturn(false); + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.empty()); + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(true); + Mockito.when(hashProvider.hash(PasswordHash.class, "password")).thenReturn(passwordHash); + Mockito.doNothing().when(credentialRepository).persist(Mockito.any(Credential.class)); + Mockito.doNothing().when(userRepository).persist(Mockito.any(User.class)); + Mockito.when(messageSender.send(Mockito.any(Message.class))).thenReturn(messageStatus); + + // WHEN + var output = + createUser.run(new Input.NewUser(firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(messageSender).send(Mockito.any(Message.class)); + Assertions.assertInstanceOf(Output.MessageCannotBeSent.class, output); + } + + @Test + public void cannotCreateUserIfUuidAlreadyExists() { + // GIVEN + var firstName = "firstName"; + var lastName = "lastName"; + var email = Email.of("email@email.com"); + var role = Role.of(AccessLevel.PUBLISHER); + var login = Login.of("user.net"); + var brandsUuids = Set.of(UUID.randomUUID()); + var password = "SuperP4ssword."; + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(password); + Mockito.when(credentialRepository.findByLogin(Mockito.any(Login.class))) + .thenReturn(Optional.empty()); + + // WHEN + var output = + createUser.run(new Input.NewUser(firstName, lastName, email, role, login, brandsUuids)); + + // THEN + Mockito.verify(userRepository, Mockito.never()).persist(Mockito.any(User.class)); + Mockito.verify(messageSender, Mockito.never()).send(Mockito.any(Message.class)); + Assertions.assertInstanceOf(Output.NotCreated.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/GetUserTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/GetUserTest.java new file mode 100644 index 0000000..3277dd1 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/GetUserTest.java @@ -0,0 +1,50 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.usecase.users.GetUser.Input; +import tech.pegase.z3.domain.usecase.users.GetUser.Output; + +public class GetUserTest { + private final UserRepository userRepository; + private final GetUser getUser; + + GetUserTest() { + this.userRepository = Mockito.mock(UserRepository.class); + this.getUser = new GetUserImpl(userRepository); + } + + @Test + public void canGetUser() { + // GIVEN + var userUuid = UUID.randomUUID(); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))) + .thenReturn(Optional.of(Mockito.mock(User.class))); + + // WHEN + var output = getUser.run(new Input.ForId(userUuid)); + + // THEN + Mockito.verify(userRepository, Mockito.times(1)).findByUuid(Mockito.any(UUID.class)); + Assertions.assertInstanceOf(Output.Found.class, output); + } + + @Test + public void cannotGetMissingUser() { + // GIVEN + var userUuid = UUID.randomUUID(); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.empty()); + + // WHEN + var output = getUser.run(new Input.ForId(userUuid)); + + // THEN + Mockito.verify(userRepository, Mockito.times(1)).findByUuid(Mockito.any(UUID.class)); + Assertions.assertInstanceOf(Output.NoSuchUser.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ListUsersTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ListUsersTest.java new file mode 100644 index 0000000..15932ba --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ListUsersTest.java @@ -0,0 +1,38 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.usecase.users.ListUsers.Input; +import tech.pegase.z3.domain.usecase.users.ListUsers.Output; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +public class ListUsersTest { + private final UserRepository userRepository; + private final ListUsers listUsers; + + ListUsersTest() { + this.userRepository = Mockito.mock(UserRepository.class); + this.listUsers = new ListUsersImpl(userRepository); + } + + @Test + public void canListUsers() { + // GIVEN + var user = Mockito.mock(User.class); + var pagination = Pagination.none(); + var paginatedData = PaginatedData.wrap(List.of(user), 1, 0, 1); + Mockito.when(userRepository.findAll(Mockito.any(Pagination.class))).thenReturn(paginatedData); + + // WHEN + var output = listUsers.run(new Input.WithPagination(pagination)); + + // THEN + Mockito.when(userRepository.findAll(Mockito.any(Pagination.class))).thenReturn(paginatedData); + Assertions.assertInstanceOf(Output.PaginatedUsers.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/RemoveUserTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/RemoveUserTest.java new file mode 100644 index 0000000..cc415f0 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/RemoveUserTest.java @@ -0,0 +1,116 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.usecase.users.RemoveUser.Input; +import tech.pegase.z3.domain.usecase.users.RemoveUser.Output; + +public class RemoveUserTest { + private final UserRepository userRepository; + private final CredentialRepository credentialRepository; + private final RemoveUser removeUser; + + RemoveUserTest() { + this.userRepository = Mockito.mock(UserRepository.class); + this.credentialRepository = Mockito.mock(CredentialRepository.class); + this.removeUser = new RemoveUserImpl(userRepository, credentialRepository); + } + + @Test + public void canRemoveUser() { + // GIVEN + var userUuidToRemove = UUID.randomUUID(); + var credentialUuid = UUID.randomUUID(); + var requestingUserUuid = UUID.randomUUID(); + var requestingUser = Mockito.mock(User.class); + var userToRemove = Mockito.mock(User.class); + Mockito.when(requestingUser.getUuid()).thenReturn(requestingUserUuid); + Mockito.when(userRepository.findByUuid(Mockito.eq(requestingUserUuid))) + .thenReturn(Optional.of(requestingUser)); + Mockito.when(userRepository.findByUuid(Mockito.eq(userUuidToRemove))) + .thenReturn(Optional.of(userToRemove)); + Mockito.when(userToRemove.getUuid()).thenReturn(userUuidToRemove); + Mockito.when(userToRemove.getCredentialUuid()).thenReturn(credentialUuid); + Mockito.when(userRepository.remove(Mockito.eq(userUuidToRemove))).thenReturn(true); + + // WHEN + var output = removeUser.run(new Input.WhereUuid(requestingUserUuid, userUuidToRemove)); + + // THEN + Mockito.verify(userRepository).remove(Mockito.eq(userUuidToRemove)); + Mockito.verify(credentialRepository).remove(Mockito.eq(credentialUuid)); + Assertions.assertInstanceOf(Output.Removed.class, output); + } + + @Test + public void cannotRemoveUserForNonExistingAuthUser() { + // GIVEN + var userUuidToRemove = UUID.randomUUID(); + var requestingUserUuid = UUID.randomUUID(); + Mockito.when(userRepository.findByUuid(Mockito.eq(requestingUserUuid))) + .thenReturn(Optional.empty()); + + // WHEN + var output = removeUser.run(new Input.WhereUuid(requestingUserUuid, userUuidToRemove)); + + // THEN + Mockito.verify(userRepository).findByUuid(Mockito.eq(requestingUserUuid)); + Mockito.verifyNoMoreInteractions(userRepository); + Mockito.verifyNoInteractions(credentialRepository); + Assertions.assertInstanceOf(Output.ForbiddenAction.class, output); + } + + @Test + public void cannotRemoveUserOwnAccount() { + // GIVEN + var userUuidToRemove = UUID.randomUUID(); + var requestingUserUuid = UUID.randomUUID(); + var requestingUser = Mockito.mock(User.class); + Mockito.when(requestingUser.getUuid()).thenReturn(userUuidToRemove); + Mockito.when(userRepository.findByUuid(Mockito.eq(requestingUserUuid))) + .thenReturn(Optional.of(requestingUser)); + + // WHEN + var output = removeUser.run(new Input.WhereUuid(requestingUserUuid, userUuidToRemove)); + + // THEN + Mockito.verify(userRepository).findByUuid(Mockito.eq(requestingUserUuid)); + Mockito.verifyNoMoreInteractions(userRepository); + Mockito.verifyNoInteractions(credentialRepository); + Assertions.assertInstanceOf(Output.ForbiddenAction.class, output); + } + + @Test + public void canRemoveNonExistingUser() { + // GIVEN + var userUuidToRemove = UUID.randomUUID(); + var credentialUuid = UUID.randomUUID(); + var requestingUserUuid = UUID.randomUUID(); + var requestingUser = Mockito.mock(User.class); + var userToRemove = Mockito.mock(User.class); + Mockito.when(requestingUser.getUuid()).thenReturn(requestingUserUuid); + Mockito.when(userRepository.findByUuid(Mockito.eq(requestingUserUuid))) + .thenReturn(Optional.of(requestingUser)); + Mockito.when(userRepository.findByUuid(Mockito.eq(userUuidToRemove))) + .thenReturn(Optional.empty()); + Mockito.when(userToRemove.getUuid()).thenReturn(userUuidToRemove); + Mockito.when(userToRemove.getCredentialUuid()).thenReturn(credentialUuid); + Mockito.when(userRepository.remove(Mockito.eq(userUuidToRemove))).thenReturn(true); + + // WHEN + var output = removeUser.run(new Input.WhereUuid(requestingUserUuid, userUuidToRemove)); + + // THEN + Mockito.verify(userRepository).findByUuid(Mockito.eq(requestingUserUuid)); + Mockito.verify(userRepository).findByUuid(Mockito.eq(userUuidToRemove)); + Mockito.verifyNoMoreInteractions(userRepository); + Mockito.verifyNoInteractions(credentialRepository); + Assertions.assertInstanceOf(Output.NotSuchUser.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ResetUserPasswordTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ResetUserPasswordTest.java new file mode 100644 index 0000000..92e97e9 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/ResetUserPasswordTest.java @@ -0,0 +1,163 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.port.service.MessageSender; +import tech.pegase.z3.domain.port.service.PasswordGenerator; +import tech.pegase.z3.domain.usecase.users.ResetUserPassword.Input; +import tech.pegase.z3.domain.usecase.users.ResetUserPassword.Output; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Login; +import tech.pegase.z3.domain.valueobject.Message; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +public class ResetUserPasswordTest { + private final PasswordGenerator passwordGenerator; + private final MessageSender messageSender; + private final HashProvider hashProvider; + private final CredentialRepository credentialRepository; + private final UserRepository userRepository; + private final ResetUserPassword resetUserPassword; + + ResetUserPasswordTest() { + this.passwordGenerator = Mockito.mock(PasswordGenerator.class); + this.messageSender = Mockito.mock(MessageSender.class); + this.hashProvider = Mockito.mock(HashProvider.class); + this.credentialRepository = Mockito.mock(CredentialRepository.class); + this.userRepository = Mockito.mock(UserRepository.class); + this.resetUserPassword = + new ResetUserPasswordImpl( + passwordGenerator, messageSender, credentialRepository, userRepository, hashProvider); + } + + @Test + public void canResetPassword() { + // GIVEN + var newPassword = "Sup3r.P4ssword"; + var newPasswordHash = PasswordHash.of("$Su$per$Hash"); + var userUuidToReset = UUID.randomUUID(); + var userCredentialUuid = UUID.randomUUID(); + var userToRemove = Mockito.mock(User.class); + var userCredentialToReset = Mockito.mock(Credential.class); + var userEmailToRemove = new Email("user@exampl.net"); + var userLoginToRemove = new Login("user.net"); + + Mockito.when(userToRemove.getCredentialUuid()).thenReturn(userCredentialUuid); + Mockito.when(userToRemove.getEmail()).thenReturn(userEmailToRemove); + Mockito.when(userCredentialToReset.getLogin()).thenReturn(userLoginToRemove); + + Mockito.when(userRepository.findByUuid(userUuidToReset)).thenReturn(Optional.of(userToRemove)); + Mockito.when(credentialRepository.findById(userToRemove.getCredentialUuid())) + .thenReturn(Optional.of(userCredentialToReset)); + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(newPassword); + Mockito.when(hashProvider.hash(PasswordHash.class, newPassword)).thenReturn(newPasswordHash); + Mockito.doNothing().when(credentialRepository).update(Mockito.any(Credential.class)); + Mockito.when(messageSender.send(Mockito.any(Message.class))) + .thenReturn(new MessageSender.Status.Success()); + + // WHEN + var output = resetUserPassword.run(new Input.ForUser(userUuidToReset)); + + // THEN + Mockito.verify(messageSender).send(Mockito.any(Message.class)); + Mockito.verify(credentialRepository).update(Mockito.any(Credential.class)); + Assertions.assertInstanceOf(Output.MessageSent.class, output); + } + + @Test + public void cannotResetNonExistingUserPassword() { + // GIVEN + var userUuidToReset = UUID.randomUUID(); + var userCredentialUuid = UUID.randomUUID(); + var userToRemove = Mockito.mock(User.class); + var userCredentialToReset = Mockito.mock(Credential.class); + var userEmailToRemove = new Email("user@exampl.net"); + var userLoginToRemove = new Login("user.net"); + + Mockito.when(userToRemove.getCredentialUuid()).thenReturn(userCredentialUuid); + Mockito.when(userToRemove.getEmail()).thenReturn(userEmailToRemove); + Mockito.when(userCredentialToReset.getLogin()).thenReturn(userLoginToRemove); + + Mockito.when(userRepository.findByUuid(userUuidToReset)).thenReturn(Optional.empty()); + + // WHEN + var output = resetUserPassword.run(new Input.ForUser(userUuidToReset)); + + // THEN + Mockito.verify(userRepository).findByUuid(userUuidToReset); + Mockito.verifyNoInteractions(messageSender); + Mockito.verifyNoInteractions(credentialRepository); + Mockito.verifyNoMoreInteractions(userRepository); + Assertions.assertInstanceOf(Output.NotSuchUser.class, output); + } + + @Test + public void cannotResetUserPasswordIfMissingCredentials() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var userUuidToReset = UUID.randomUUID(); + var userCredentialUuid = UUID.randomUUID(); + var userToRemove = Mockito.mock(User.class); + var userCredentialToReset = Mockito.mock(Credential.class); + var userEmailToRemove = new Email("user@exampl.net"); + var userLoginToRemove = new Login("user.net"); + + Mockito.when(userToRemove.getCredentialUuid()).thenReturn(userCredentialUuid); + Mockito.when(userToRemove.getEmail()).thenReturn(userEmailToRemove); + Mockito.when(userCredentialToReset.getLogin()).thenReturn(userLoginToRemove); + + Mockito.when(userRepository.findByUuid(userUuidToReset)) + .thenReturn(Optional.of(userToRemove)); + Mockito.when(credentialRepository.findById(userToRemove.getCredentialUuid())) + .thenReturn(Optional.empty()); + + // WHEN + resetUserPassword.run(new Input.ForUser(userUuidToReset)); + }); + } + + @Test + public void cannotResetUserPasswordWhenMessageSendFailed() { + // GIVEN + var newPassword = "Sup3r.P4ssword"; + var newPasswordHash = PasswordHash.of("$Su$per$Hash"); + var userUuidToReset = UUID.randomUUID(); + var userCredentialUuid = UUID.randomUUID(); + var userToRemove = Mockito.mock(User.class); + var userCredentialToReset = Mockito.mock(Credential.class); + var userEmailToRemove = new Email("user@exampl.net"); + var userLoginToRemove = new Login("user.net"); + + Mockito.when(userToRemove.getCredentialUuid()).thenReturn(userCredentialUuid); + Mockito.when(userToRemove.getEmail()).thenReturn(userEmailToRemove); + Mockito.when(userCredentialToReset.getLogin()).thenReturn(userLoginToRemove); + + Mockito.when(userRepository.findByUuid(userUuidToReset)).thenReturn(Optional.of(userToRemove)); + Mockito.when(credentialRepository.findById(userToRemove.getCredentialUuid())) + .thenReturn(Optional.of(userCredentialToReset)); + Mockito.when(passwordGenerator.generatePassword(Mockito.anyInt())).thenReturn(newPassword); + Mockito.when(hashProvider.hash(PasswordHash.class, newPassword)).thenReturn(newPasswordHash); + Mockito.doNothing().when(credentialRepository).update(Mockito.any(Credential.class)); + Mockito.when(messageSender.send(Mockito.any(Message.class))) + .thenReturn(new MessageSender.Status.Failed()); + + // WHEN + var output = resetUserPassword.run(new Input.ForUser(userUuidToReset)); + + // THEN + Mockito.verify(credentialRepository).update(Mockito.any(Credential.class)); + Mockito.verify(messageSender).send(Mockito.any(Message.class)); + Assertions.assertInstanceOf(Output.MessageCannotBeSent.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/UpdateUserTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/UpdateUserTest.java new file mode 100644 index 0000000..f80e9c2 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/usecase/users/UpdateUserTest.java @@ -0,0 +1,136 @@ +package tech.pegase.z3.domain.usecase.users; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.usecase.users.UpdateUser.Input; +import tech.pegase.z3.domain.usecase.users.UpdateUser.Output; +import tech.pegase.z3.domain.valueobject.Email; + +public class UpdateUserTest { + private final UserRepository userRepository; + private final BrandRepository brandRepository; + private final UpdateUser updateUser; + + UpdateUserTest() { + this.userRepository = Mockito.mock(UserRepository.class); + this.brandRepository = Mockito.mock(BrandRepository.class); + this.updateUser = new UpdateUserImpl(userRepository, brandRepository); + } + + @Test + public void canUpdateUser() { + // GIVEN + var userId = UUID.randomUUID(); + var userFirstName = "firstName"; + var userLastName = "lastName"; + var userEmail = Email.of("user@example.net"); + var userBrands = Set.of(UUID.randomUUID(), UUID.randomUUID()); + var user = Mockito.mock(User.class); + + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(true); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.doNothing().when(userRepository).persist(Mockito.any(User.class)); + + // WHEN + var output = + updateUser.run( + new Input.WhereId(userId, userFirstName, userLastName, userEmail, userBrands)); + + // THEN + Mockito.verify(userRepository).persist(Mockito.any(User.class)); + Assertions.assertInstanceOf(Output.Updated.class, output); + } + + @Test + public void cannotUpdateUserWithEmptyFirstName() { + // GIVEN + var userId = UUID.randomUUID(); + var userFirstName = ""; + var userLastName = "lastName"; + var userEmail = Email.of("user@example.net"); + var userBrands = Set.of(UUID.randomUUID(), UUID.randomUUID()); + + // WHEN + var output = + updateUser.run( + new Input.WhereId(userId, userFirstName, userLastName, userEmail, userBrands)); + + // THEN + Mockito.verifyNoInteractions(userRepository); + Assertions.assertInstanceOf(Output.NotUpdated.class, output); + } + + @Test + public void cannotUpdateUserWithEmptyLastName() { + // GIVEN + var userId = UUID.randomUUID(); + var userFirstName = "firstName"; + var userLastName = ""; + var userEmail = Email.of("user@example.net"); + var userBrands = Set.of(UUID.randomUUID(), UUID.randomUUID()); + + // WHEN + var output = + updateUser.run( + new Input.WhereId(userId, userFirstName, userLastName, userEmail, userBrands)); + + // THEN + Assertions.assertInstanceOf(Output.NotUpdated.class, output); + } + + @Test + public void cannotUpdateNonExistingUser() { + // GIVEN + var userId = UUID.randomUUID(); + var userFirstName = "firstName"; + var userLastName = "lastName"; + var userEmail = Email.of("user@example.net"); + var userBrands = Set.of(UUID.randomUUID(), UUID.randomUUID()); + + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))).thenReturn(true); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.empty()); + + // WHEN + var output = + updateUser.run( + new Input.WhereId(userId, userFirstName, userLastName, userEmail, userBrands)); + + // THEN + Mockito.verify(userRepository).findByUuid(Mockito.any(UUID.class)); + Mockito.verifyNoMoreInteractions(userRepository); + Assertions.assertInstanceOf(Output.NoSuchUser.class, output); + } + + @Test + public void canUpdateUserWhenMissingBrandIsProvided() { + // GIVEN + var userId = UUID.randomUUID(); + var userFirstName = "firstName"; + var userLastName = "lastName"; + var userEmail = Email.of("user@example.net"); + var userBrands = Set.of(UUID.randomUUID(), UUID.randomUUID()); + var user = Mockito.mock(User.class); + + Mockito.when(brandRepository.exists(Mockito.any(UUID.class))) + .thenReturn(true) + .thenReturn(false); + Mockito.when(userRepository.findByUuid(Mockito.any(UUID.class))).thenReturn(Optional.of(user)); + Mockito.doNothing().when(userRepository).persist(Mockito.any(User.class)); + + // WHEN + var output = + updateUser.run( + new Input.WhereId(userId, userFirstName, userLastName, userEmail, userBrands)); + + // THEN + Mockito.verify(userRepository, Mockito.never()).persist(Mockito.any(User.class)); + Assertions.assertInstanceOf(Output.NotUpdated.class, output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/ColorTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/ColorTest.java new file mode 100644 index 0000000..b81dd05 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/ColorTest.java @@ -0,0 +1,74 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ColorTest { + + @Test + public void colorValueIsRequired() { + Assertions.assertThrows( + IllegalStateException.class, () -> new Color(null), "Color value should be required"); + } + + @Test + public void colorCanBeCreated() { + Assertions.assertDoesNotThrow( + () -> { + new Color(0x000000); + new Color(0x0D0D0D); + new Color(0xFFFFFF); + Color.of(0); + Color.of(0xFFFFFF); + Color.of("#000"); + Color.of("#000000"); + Color.of("#FFF"); + Color.of("#FFFFFF"); + }, + "Color should be able to exists using a valid html color value"); + } + + @Test + public void colorCanBeStringify() { + Assertions.assertEquals( + "#000000", + (new Color(0)).toString(), + "Color should be able to be converted to a html color string format"); + Assertions.assertEquals( + "#000FFF", + (new Color(0xFFF)).toString(), + "Color should be able to be converted to a html color string format"); + Assertions.assertEquals( + "#FFFFFF", + (new Color(0xFFFFFF)).toString(), + "Color should be able to be converted to a html color string format"); + } + + @Test + public void colorCannotBeCreatedWhenInvalid() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Color(0x3FFFFFF), + "Color should not be able to exists using an invalid html color value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> new Color(0x1000000), + "Color should not be able to exists using an invalid html color value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> Color.of(0x1000000), + "Color should not be able to exists using an invalid html color value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> Color.of("#VFF"), + "Color should not be able to exists using an invalid html color value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> Color.of("#FFFF"), + "Color should not be able to exists using an invalid html color value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> Color.of("#FFFFFFF"), + "Color should not be able to exists using an invalid html color value"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/EmailTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/EmailTest.java new file mode 100644 index 0000000..6640448 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/EmailTest.java @@ -0,0 +1,30 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class EmailTest { + + @Test + public void emailValueIsRequired() { + Assertions.assertThrows( + IllegalStateException.class, () -> new Email(null), "Email value should be required"); + } + + @Test + public void emailCanBeCreated() { + Assertions.assertDoesNotThrow( + () -> { + new Email("username@example.com"); + }, + "Email should be able to exists using a valid email value"); + } + + @Test + public void emailCannotBeCreatedWhenInvalid() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Email("no email here"), + "Email should not be able to exists using an invalid email value"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/FqdnTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/FqdnTest.java new file mode 100644 index 0000000..87d3215 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/FqdnTest.java @@ -0,0 +1,108 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class FqdnTest { + @Test + public void valueIsRequired() { + Assertions.assertThrows( + IllegalStateException.class, () -> Fqdn.of(null), "Fqdn value should be required"); + Assertions.assertThrows( + IllegalStateException.class, () -> Fqdn.of(""), "Fqdn value should be required"); + } + + @Test + public void canBeEquals() { + var fqnd1 = Fqdn.of("example.com"); + var fqnd2 = Fqdn.of("example.com"); + Assertions.assertEquals(fqnd1, fqnd2, "Fqdn should be equal"); + Assertions.assertEquals(fqnd1, fqnd1, "Fqdn should be equal"); + } + + @Test + public void canBeNonEquals() { + Assertions.assertNotEquals(Fqdn.of("example.com"), Fqdn.of("example.net")); + Assertions.assertNotEquals(Fqdn.of("example.com"), null); + Assertions.assertNotEquals(Fqdn.of("example.com"), "example.com"); + } + + @Test + public void canGetValueUri() { + Assertions.assertDoesNotThrow( + () -> Fqdn.of("example.com").getFqdnUri(), "Fqdn should be able to provide its URI"); + } + + @Test + public void canGetValueValue() { + // Arrange + var fqdnString = "1.sub.example.com"; + var fqdn = Fqdn.of(fqdnString); + + // Act + var output = fqdn.getValue(); + + // Assert + Assertions.assertEquals(fqdnString, output); + } + + @Test + public void urlCanBeCreatedWithoutScheme() { + Assertions.assertDoesNotThrow( + () -> Fqdn.of("example.com"), "Fqdn should be able to exists without a scheme"); + } + + @Test + public void urlCanBeCreatedWithSubdomain() { + Assertions.assertDoesNotThrow( + () -> Fqdn.of("sub.example.com"), "Fqdn should be able to exists with a sub domain"); + } + + @Test + public void urlCannotBeCreatedWithoutValidDomainName() { + Assertions.assertThrows( + IllegalStateException.class, + () -> Fqdn.of("\\"), + "Fqdn should not be able to exists with scheme"); + } + + @Test + public void urlCannotBeCreatedWithScheme() { + Assertions.assertThrows( + IllegalStateException.class, + () -> Fqdn.of("https://example.com"), + "Fqdn should not be able to exists with scheme"); + } + + @Test + public void urlCannotBeCreatedWithCredential() { + Assertions.assertThrows( + IllegalStateException.class, + () -> Fqdn.of("user:pwd@example.com"), + "Fqdn should be not able to exists with credentials"); + } + + @Test + public void urlCannotBeCreatedWithPath() { + Assertions.assertThrows( + IllegalStateException.class, + () -> Fqdn.of("example.com/path"), + "Fqdn should be not able to exists with path"); + } + + @Test + public void urlCannotBeCreatedWithQuery() { + Assertions.assertThrows( + IllegalStateException.class, + () -> Fqdn.of("example.com?query=2"), + "Fqdn should be not able to exists with query"); + } + + @Test + public void urlCannotBeCreatedWithFragment() { + Assertions.assertThrows( + IllegalStateException.class, + () -> Fqdn.of("example.com#anchor"), + "Fqdn should be not able to exists with fragment"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/JwtTokenTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/JwtTokenTest.java new file mode 100644 index 0000000..c77b6bc --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/JwtTokenTest.java @@ -0,0 +1,14 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class JwtTokenTest { + @Test + public void valueIsRequired() { + Assertions.assertThrows( + IllegalStateException.class, () -> new JwtToken(null), "JwtToken value should be required"); + Assertions.assertThrows( + IllegalStateException.class, () -> new JwtToken(""), "JwtToken value should be required"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkFilteringTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkFilteringTest.java new file mode 100644 index 0000000..068f815 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkFilteringTest.java @@ -0,0 +1,108 @@ +package tech.pegase.z3.domain.valueobject; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering.Field; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering.Value; + +public class LinkFilteringTest { + + @Test + public void canBeCreatedForEachFields() { + Assertions.assertDoesNotThrow( + () -> + Arrays.stream(Field.values()) + .forEach(field -> LinkFiltering.of(Map.of(field.name(), "some-field")))); + } + + @Test + public void canGetFilters() { + Assertions.assertDoesNotThrow( + () -> + Arrays.stream(Field.values()) + .forEach( + field -> { + var ignored = + LinkFiltering.of(Map.of(field.name(), "some-field")).getFilters(); + })); + } + + @Test + public void canBeCreatedWithSomeNullFields() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var fields = new HashMap(); + fields.put(Field.TAG.name(), "some-field"); + fields.put(Field.BRAND.name(), null); + + // WHEN + LinkFiltering.of(fields); + }); + } + + @Test + public void canBeCreatedForEachFieldsOfMap() { + Assertions.assertDoesNotThrow( + () -> + Arrays.stream(Field.values()) + .forEach(field -> LinkFiltering.ofMap(Map.of(field, Value.of("some-field"))))); + } + + @Test + public void canBeCreatedAsEmpty() { + Assertions.assertDoesNotThrow(LinkFiltering::empty); + } + + @Test + public void assertFieldNameAreCorrect() { + for (var field : Field.values()) { + switch (field) { + case BRAND -> + Assertions.assertEquals("brand", field.getFieldName(), "field name is a value-object"); + case CATALOG -> + Assertions.assertEquals( + "catalog", field.getFieldName(), "field name is a value-object"); + case TAG -> + Assertions.assertEquals("tag", field.getFieldName(), "field name is a value-object"); + case IS_EXTERNAL -> + Assertions.assertEquals( + "isExternal", field.getFieldName(), "field name is a value-object"); + case IS_VALID -> + Assertions.assertEquals( + "validity", field.getFieldName(), "field name is a value-object"); + } + } + } + + @Test + public void cannotCreateLinkFilteringFieldWithInvalidValue() { + Assertions.assertThrows( + IllegalStateException.class, () -> LinkFiltering.Field.of("INVALID_VALUE")); + } + + @Test + public void canCreateLinkFilteringValue() { + Assertions.assertDoesNotThrow(() -> LinkFiltering.Value.of(UUID.randomUUID())); + Assertions.assertDoesNotThrow(() -> LinkFiltering.Value.of((UUID) null)); + } + + @Test + public void canGetLinkFilteringValue() { + // GIVEN + var value = LinkFiltering.Value.of(UUID.randomUUID()); + + // WHEN + var output = value.getFieldValue(); + + // THEN + Assertions.assertEquals(value.value(), output); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkOrderingTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkOrderingTest.java new file mode 100644 index 0000000..87dc54b --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkOrderingTest.java @@ -0,0 +1,100 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.concepts.Ordering.Direction; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering.Field; + +public class LinkOrderingTest { + + @Test + public void linkOrderingCanBeCreatedForEachFields() { + Assertions.assertDoesNotThrow( + () -> { + for (var field : Field.values()) { + for (var direction : Direction.values()) { + new LinkOrdering(field, direction); + } + } + }); + } + + @Test + public void assertFieldNameAreCorrect() { + for (var field : Field.values()) { + switch (field) { + case SOURCE -> Assertions.assertEquals("source", field.getFieldName()); + case VISITS -> Assertions.assertEquals("visits", field.getFieldName()); + case IS_EXTERNAL -> Assertions.assertEquals("isExternal", field.getFieldName()); + case VALIDITY -> Assertions.assertEquals("validity", field.getFieldName()); + case CREATED_AT -> Assertions.assertEquals("createdAt", field.getFieldName()); + } + } + } + + @Test + public void canGetLinkOrderingFieldName() { + // Arrange + var fieldName = "IS_EXTERNAL"; + var linkOrdering = LinkOrdering.of(Field.of(fieldName), Direction.NATURAL); + + // Act + var output = linkOrdering.getFieldName(); + + // Assert + Assertions.assertEquals("isExternal", output); + } + + @Test + public void canGetLinkOrderingDirection() { + // Arrange + var value = Direction.NATURAL; + var linkOrdering = LinkOrdering.of(Field.IS_EXTERNAL, value); + + // Act + var output = linkOrdering.getDirection(); + + // Assert + Assertions.assertEquals(value, output); + } + + @Test + public void canGetLinkOrderingField() { + // Arrange + var field = Field.SOURCE; + var linkOrdering = LinkOrdering.of(field, Direction.of("NATURAL")); + + // Act + var output = linkOrdering.getField(); + + // Assert + Assertions.assertEquals(field, output); + } + + @Test + public void canGetLinkOrderingFieldFromStringValue() { + // Arrange + var fieldValue = "SOURCE"; + + // Act + var output = Field.of(fieldValue); + + // Assert + Assertions.assertEquals(Field.SOURCE, output); + } + + @Test + public void cannotGetLinkOrderingFieldFromInvalidStringValue() { + // Assert + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // Arrange + var fieldValue = "NOT_VALID"; + + // Act + Field.of(fieldValue); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkSearchingTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkSearchingTest.java new file mode 100644 index 0000000..e7f9fc5 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkSearchingTest.java @@ -0,0 +1,99 @@ +package tech.pegase.z3.domain.valueobject; + +import java.util.Arrays; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.concepts.Searching.SearchValue; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching.Field; + +public class LinkSearchingTest { + + @Test + public void linkSearchingCanBeCreatedForEachFields() { + Assertions.assertDoesNotThrow( + () -> + Arrays.stream(Field.values()) + .forEach(field -> new LinkSearching(field, SearchValue.of("some-field")))); + } + + @Test + public void assertFieldNameAreCorrect() { + for (var field : Field.values()) { + switch (field) { + case SOURCE -> + Assertions.assertEquals( + "source.value", field.getFieldName(), "field name is a value-object"); + + case TARGET -> + Assertions.assertEquals( + "target.value", field.getFieldName(), "field name is a value-object"); + } + } + } + + @Test + public void canGetLinkSearchingFieldName() { + // Arrange + var field = Field.SOURCE; + + // Act + var output = field.getFieldName(); + + // Assert + Assertions.assertEquals( + "source.value", output, "field name should be then name used to query db"); + } + + @Test + public void canGetLinkSearchingValue() { + // Arrange + var value = "some-value"; + var linkSearching = LinkSearching.of(Field.TARGET, SearchValue.of(value)); + + // Act + var output = linkSearching.getSearchValue(); + + // Assert + Assertions.assertEquals(value, output); + } + + @Test + public void canGetLinkSearchingField() { + // Arrange + var field = Field.TARGET; + var linkSearching = LinkSearching.of(field, SearchValue.of("some-value")); + + // Act + var output = linkSearching.getField(); + + // Assert + Assertions.assertEquals(field, output); + } + + @Test + public void canGetLinkSearchingFieldFromStringValue() { + // Arrange + var fieldValue = "TARGET"; + + // Act + var output = Field.of(fieldValue); + + // Assert + Assertions.assertEquals(Field.TARGET, output); + } + + @Test + public void cannotGetLinkSearchingFieldFromInvalidStringValue() { + // Assert + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // Arrange + var fieldValue = "NOT_VALID"; + + // Act + Field.of(fieldValue); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkValidityTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkValidityTest.java new file mode 100644 index 0000000..a1a76fb --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LinkValidityTest.java @@ -0,0 +1,37 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LinkValidityTest { + + @Test + public void canBeCreated() { + Assertions.assertDoesNotThrow( + () -> { + LinkValidity.unknown(); + LinkValidity.valid(); + LinkValidity.invalid(); + }); + } + + @Test + public void canGetLabel() { + Assertions.assertDoesNotThrow( + () -> { + LinkValidity.unknown().label(); + LinkValidity.valid().label(); + LinkValidity.invalid().label(); + }); + } + + @Test + public void canGetOrdinal() { + Assertions.assertDoesNotThrow( + () -> { + LinkValidity.unknown().ordinal(); + LinkValidity.valid().ordinal(); + LinkValidity.invalid().ordinal(); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LoginTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LoginTest.java new file mode 100644 index 0000000..ad52442 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/LoginTest.java @@ -0,0 +1,45 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LoginTest { + + @Test + public void loginCanBeCreated() { + Assertions.assertDoesNotThrow( + () -> { + // Arrange + var login = "user-login"; + + // Act + new Login(login); + }); + } + + @Test + public void loginCannotBeCreatedWithEmptyValue() { + Assertions.assertThrowsExactly( + IllegalStateException.class, + () -> { + // Arrange + var login = ""; + + // Act + new Login(login); + }); + } + + @Test + public void loginCannotBeCreatedWithALessThan3CharactersValue() { + Assertions.assertThrowsExactly( + IllegalStateException.class, + () -> { + // Arrange + var login = "lg"; + + // Act + new Login(login); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/MessageTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/MessageTest.java new file mode 100644 index 0000000..ed211e2 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/MessageTest.java @@ -0,0 +1,51 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class MessageTest { + + @Test + public void messageCanBeCreated() { + Assertions.assertDoesNotThrow( + () -> { + // Arrange + var email = new Email("user@example.net"); + var subject = "new message inbox"; + var message = "Hello, you have a new message, check your inbox. Have a nice day !"; + + // Act + new Message(email, subject, message); + }); + } + + @Test + public void messageCannotBeCreatedWithEmptySubject() { + Assertions.assertThrowsExactly( + IllegalStateException.class, + () -> { + // Arrange + var email = new Email("user@example.net"); + var subject = ""; + var message = "Hello, you have a new message, check your inbox. Have a nice day !"; + + // Act + new Message(email, subject, message); + }); + } + + @Test + public void messageCannotBeCreatedWithEmptyMessage() { + Assertions.assertThrowsExactly( + IllegalStateException.class, + () -> { + // Arrange + var email = new Email("user@example.net"); + var subject = "new message inbox"; + var message = ""; + + // Act + new Message(email, subject, message); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/OrderingDirectionTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/OrderingDirectionTest.java new file mode 100644 index 0000000..adb2d8f --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/OrderingDirectionTest.java @@ -0,0 +1,19 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.concepts.Ordering.Direction; + +public class OrderingDirectionTest { + @Test + public void canCreateOrderingDirection() { + Assertions.assertDoesNotThrow(() -> Direction.of("NATURAL")); + Assertions.assertDoesNotThrow(() -> Direction.of("ASC")); + Assertions.assertDoesNotThrow(() -> Direction.of("DESC")); + } + + @Test + public void cannotCreateOrderingDirectionWithInvalidValue() { + Assertions.assertThrows(IllegalStateException.class, () -> Direction.of("NOT_VALID")); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/PasswordHashTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/PasswordHashTest.java new file mode 100644 index 0000000..7c634ec --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/PasswordHashTest.java @@ -0,0 +1,67 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +public class PasswordHashTest { + + @Test + public void valueIsRequired() { + Assertions.assertThrows( + IllegalStateException.class, () -> new PasswordHash(null), "Value should be required"); + Assertions.assertThrows( + IllegalStateException.class, () -> new PasswordHash(""), "Value should be required"); + } + + @Test + public void canBeCreated() { + Assertions.assertDoesNotThrow( + () -> { + new PasswordHash("$02$v=2$anything"); + new PasswordHash("$02$v=2$anything$"); + // BCrypt + new PasswordHash("$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi"); + // Argon2 + new PasswordHash( + "$argon2id$v=19$m=12288,t=3,p=1$+Eoh2/WaMEhQsn/a03tXXw$JFLRTyysxQxuLC5oCUaaTuByNLFo4Awlw1eum/Gf1Sc"); + }, + "PasswordHash should be able to exists using a valid string hashed value"); + } + + @Test + public void canGetValue() { + // GIVEN + var passwordHash = new PasswordHash("$02$v=2$anything"); + + // WHEN + var output = passwordHash.getValue(); + + // THEN + Assertions.assertEquals("$02$v=2$anything", output); + } + + @Test + public void cannotBeCreated() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new PasswordHash("$02$v=2anything"), + "PasswordHash should not be able to exists using an invalid hash value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> new PasswordHash("$02v=2$anything"), + "PasswordHash should not be able to exists using an invalid hash value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> new PasswordHash("02$v$=2anything"), + "PasswordHash should not be able to exists using an invalid hash value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> new PasswordHash("89a1d7ae79a410e1c4e7625459de37be5f988d34a09f5ad2bed206f8835fc111"), + "PasswordHash should not be able to exists using an invalid hash value"); + Assertions.assertThrows( + IllegalStateException.class, + () -> new PasswordHash("YXNkYXNkYXNkIGN4dnhjdnNjeHY="), + "PasswordHash should not be able to exists using an invalid hash value"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/PasswordTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/PasswordTest.java new file mode 100644 index 0000000..d87f509 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/PasswordTest.java @@ -0,0 +1,62 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class PasswordTest { + + @Test + public void passwordCanExistWhenRobustEnough() { + Assertions.assertDoesNotThrow(() -> new Password("ABCqwe123....................")); + } + + @Test + public void passwordShouldNotBeEmpty() { + var thrown = + Assertions.assertThrowsExactly(IllegalStateException.class, () -> new Password("")); + Assertions.assertEquals("le mot de passe ne peut être vide", thrown.getMessage()); + } + + @Test + public void passwordShouldBeLongEnough() { + var thrown = + Assertions.assertThrowsExactly(IllegalStateException.class, () -> new Password("aA1.")); + Assertions.assertEquals( + "le mot de passe doit contenir au moins 8 caractères", thrown.getMessage()); + } + + @Test + public void passwordShouldContainLowerCaseCharacters() { + var thrown = + Assertions.assertThrowsExactly( + IllegalStateException.class, () -> new Password("ABC123....................")); + Assertions.assertEquals( + "le mot de passe doit contenir des majuscules et des minuscules", thrown.getMessage()); + } + + @Test + public void passwordShouldContainUpperCaseCharacters() { + var thrown = + Assertions.assertThrowsExactly( + IllegalStateException.class, () -> new Password("abc123....................")); + Assertions.assertEquals( + "le mot de passe doit contenir des majuscules et des minuscules", thrown.getMessage()); + } + + @Test + public void passwordShouldContainDigitsCharacters() { + var thrown = + Assertions.assertThrowsExactly( + IllegalStateException.class, () -> new Password("abcABC....................")); + Assertions.assertEquals("le mot de passe doit contenir des nombres", thrown.getMessage()); + } + + @Test + public void passwordShouldContainSpecialCharacters() { + var thrown = + Assertions.assertThrowsExactly( + IllegalStateException.class, () -> new Password("abcABC111111111111111111111")); + Assertions.assertEquals( + "le mot de passe doit contenir des caractères spéciaux", thrown.getMessage()); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/RoleTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/RoleTest.java new file mode 100644 index 0000000..bf12359 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/RoleTest.java @@ -0,0 +1,28 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.valueobject.Role.AccessLevel; + +public class RoleTest { + + @Test + public void roleCanBeCompared() { + // Arrange + var role = new Role(AccessLevel.ADMINISTRATOR); + var anotherRole = new Role(AccessLevel.PUBLISHER); + var anotherAdminRole = new Role(AccessLevel.ADMINISTRATOR); + + // Act + var notEquals = role.equals(anotherRole); + var equals = role.equals(anotherAdminRole); + var reverseNotEquals = anotherRole.equals(role); + var reverseEquals = role.equals(anotherAdminRole); + + // Assert + Assertions.assertFalse(notEquals); + Assertions.assertFalse(reverseNotEquals); + Assertions.assertTrue(equals); + Assertions.assertTrue(reverseEquals); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SlugTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SlugTest.java new file mode 100644 index 0000000..bc5f312 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SlugTest.java @@ -0,0 +1,30 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class SlugTest { + + @Test + public void slugValueIsRequired() { + Assertions.assertThrows( + IllegalStateException.class, () -> new Slug(null), "Slug value should be required"); + Assertions.assertThrows( + IllegalStateException.class, () -> new Slug(""), "Slug value should be required"); + } + + @Test + public void slugCanBeCreated() { + Assertions.assertDoesNotThrow( + () -> new Slug("slug-pattern-matches"), + "Slug should be able to exists using a valid slug pattern"); + } + + @Test + public void slugCannotBeCreatedUsingNotSlugString() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Slug("This is not a Slug"), + "Source value should be required"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SourceTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SourceTest.java new file mode 100644 index 0000000..fb24826 --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SourceTest.java @@ -0,0 +1,97 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.shared.valueobject.Source; + +public class SourceTest { + @Test + public void sourceValueIsRequired() { + Assertions.assertThrows( + IllegalStateException.class, () -> new Source(null), "Source value should be required"); + Assertions.assertThrows( + IllegalStateException.class, () -> new Source(""), "Source value should be required"); + } + + @Test + public void sourcesCanBeCompared() { + // Arrange + var source1 = new Source("source1"); + var source2 = new Source("source2"); + var source3 = new Source("source1"); + + // Act Assert + Assertions.assertEquals(-1, source1.compareTo(source2)); + Assertions.assertEquals(1, source2.compareTo(source1)); + Assertions.assertEquals(0, source1.compareTo(source3)); + } + + @Test + public void sourceCanBeCreatedWithPath() { + Assertions.assertDoesNotThrow( + () -> new Source("/my-app-path"), "Source should be able to exist with an URL path"); + } + + @Test + public void sourceCanBeCreatedWithLongerPath() { + Assertions.assertDoesNotThrow( + () -> new Source("/my/app/path"), "Source should be able to exist with an URL path"); + } + + @Test + public void sourceCanBeCreatedWithRelativePath() { + Assertions.assertDoesNotThrow( + () -> new Source("my-app-path"), "Source should be able to exist with an URL path"); + } + + @Test + public void sourceCanBeCreatedWithLongerRelativePath() { + Assertions.assertDoesNotThrow( + () -> new Source("my/app/path"), "Source should be able to exist with an URL path"); + } + + @Test + public void sourceCannotBeCreatedWithAFullUrl() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Source("https://user:password@example.com:8080/my/app/path?foo=bar#foobar"), + "Source should not be able to exist with a full URL"); + } + + @Test + public void sourceCannotBeCreatedWithOnlyAnUrlSchemeAndHost() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Source("https://example.com"), + "Source should not be able to exist with only an URL scheme and host"); + + Assertions.assertThrows( + IllegalStateException.class, + () -> new Source("https://example.com/"), + "Source should not be able to exist with only an URL scheme and host"); + } + + @Test + public void sourceCannotBeCreatedWithAnUrlHostAndPathOnly() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Source("example.com/my/app/path"), + "Source should not be able to exist with an URL host and path only"); + } + + @Test + public void sourceCannotBeCreatedWithInvalidUrl() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Source("http://finance.yahoo.com/q/h?s=^IXIC"), + "Source should not be able to exist without a valid URL"); + } + + @Test + public void sourceCannotBeCreatedWithEmptyValue() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Source(""), + "Source should not be able to exist with an empty value"); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SubscriptionReportTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SubscriptionReportTest.java new file mode 100644 index 0000000..147cfcd --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/SubscriptionReportTest.java @@ -0,0 +1,70 @@ +package tech.pegase.z3.domain.valueobject; + +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.entity.User; + +public class SubscriptionReportTest { + @Test + public void canInstantiate() { + // ASSERT + Assertions.assertDoesNotThrow( + () -> { + // ARRANGE + var brandUuid = UUID.randomUUID(); + var link = Mockito.mock(Link.class); + var invalidLinks = Set.of(link); + var brand = Mockito.mock(Brand.class); + var user = Mockito.mock(User.class); + + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + Mockito.when(link.getBrand()).thenReturn(brandUuid); + + // ACT + new SubscriptionReport(invalidLinks, brand, user); + }); + } + + @Test + public void cannotInstantiateIfBrandDoesNotMatch() { + // ASSERT + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // ARRANGE + var brandUuid = UUID.randomUUID(); + var antoherBrandUuid = UUID.randomUUID(); + var link = Mockito.mock(Link.class); + var invalidLinks = Set.of(link); + var brand = Mockito.mock(Brand.class); + var user = Mockito.mock(User.class); + + Mockito.when(brand.getUuid()).thenReturn(brandUuid); + Mockito.when(link.getBrand()).thenReturn(antoherBrandUuid); + + // ACT + new SubscriptionReport(invalidLinks, brand, user); + }); + } + + @Test + public void cannotInstantiateIfInvalidLinksIsEmpty() { + // ASSERT + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // ARRANGE + var invalidLinks = Set.of(); + var brand = Mockito.mock(Brand.class); + var user = Mockito.mock(User.class); + + // ACT + new SubscriptionReport(invalidLinks, brand, user); + }); + } +} diff --git a/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/TargetTest.java b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/TargetTest.java new file mode 100644 index 0000000..74d695e --- /dev/null +++ b/z3-domain/src/test/java/tech/pegase/z3/domain/valueobject/TargetTest.java @@ -0,0 +1,62 @@ +package tech.pegase.z3.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TargetTest { + @Test + public void valueIsRequired() { + Assertions.assertThrows( + IllegalStateException.class, + () -> { + new Target(null); + new Target(""); + }, + "Target value should be required"); + } + + @Test + public void urlCanBeCannotBeCreatedWithoutScheme() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Target("example.com"), + "Target should not be able to exists without a scheme"); + } + + @Test + public void urlSchemeHasToBeHttps() { + Assertions.assertThrows( + IllegalStateException.class, + () -> new Target("ftp://example.com"), + "Target should not be able to exists without a https scheme"); + } + + @Test + public void urlCannotContainCredentials() { + Assertions.assertThrows( + IllegalStateException.class, () -> new Target("https://user:pass@example.com")); + } + + @Test + public void urlCanBeCreatedWithSchemeAndHost() { + Assertions.assertDoesNotThrow( + () -> { + new Target("https://example.com"); + }, + "Target should be able to exist with an URL schema and host"); + } + + @Test + public void urlCanContainQueryOrPathOrFragment() { + Assertions.assertDoesNotThrow( + () -> { + new Target("https://example.com?foo=bar"); + new Target("https://example.com#title2"); + new Target("https://example.com/my/path"); + new Target("https://example.com/my/path#title3"); + new Target("https://example.com/my/path?foo=bar"); + new Target("https://example.com?foo=bar#title2"); + new Target("https://example.com/my/path?foo=bar#title2"); + }); + } +} diff --git a/z3-infrastructure/pom.xml b/z3-infrastructure/pom.xml new file mode 100644 index 0000000..148960c --- /dev/null +++ b/z3-infrastructure/pom.xml @@ -0,0 +1,195 @@ + + + z3-infrastructure + + + + + jacoco-maven-plugin + + + + + + true + ${project.build.directory}/coverage-reports/jacoco-ut.exec + surefire.jacoco.args + + + prepare-agent + + pre-unit-test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + ${project.build.directory}/coverage-jacoco/unit-tests + + + + report + + post-unit-test + test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + + + BUNDLE + + + LINE + 0.10 + COVEREDRATIO + + + + + + + check + + jacoco-check-ut + verify + + + + org.jacoco + ${jacoco.version} + + + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + ${surefire.jacoco.args} -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + + + + **/*IT.java + + + org.jboss.logmanager.LogManager + ${maven.home} + + + ${surefire-plugin.version} + + + + + + + z3-domain + tech.pegase.z3 + ${z3.version} + + + z3-shared + tech.pegase.z3 + ${z3.version} + + + commons-csv + org.apache.commons + ${commons-csv.version} + + + quarkus-hibernate-orm + io.quarkus + + + quarkus-mailer + io.quarkus + + + quarkus-junit5-mockito + io.quarkus + test + + + quarkus-jacoco + io.quarkus + test + + + quarkus-jdbc-postgresql + io.quarkus + + + blaze-persistence-integration-quarkus-3 + com.blazebit + ${blaze-persistence.version} + + + blaze-persistence-integration-hibernate-6.2 + com.blazebit + runtime + ${blaze-persistence.version} + + + slugify + com.github.slugify + ${slugify.version} + + + + icu4j + com.ibm.icu + ${slugify-dep-icu4j-icu.version} + + + + + spring-security-crypto + org.springframework.security + ${spring-security.version} + + + bcpkix-jdk18on + org.bouncycastle + ${bouncycastle-bcpkix.version} + + + commons-validator + commons-validator + ${commons-validator.version} + + + + + + + + + spring-security-bom + org.springframework.security + import + pom + ${spring-security.version} + + + + + 4.0.0 + + + z3 + tech.pegase.z3 + 1.1.0-SNAPSHOT + + + + 22 + 22 + UTF-8 + + diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/brands/BrandDatabaseRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/brands/BrandDatabaseRepository.java new file mode 100644 index 0000000..ed58f74 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/brands/BrandDatabaseRepository.java @@ -0,0 +1,144 @@ +package tech.pegase.z3.infrastructure.adapter.repository.brands; + +import com.blazebit.persistence.CriteriaBuilder; +import com.blazebit.persistence.CriteriaBuilderFactory; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheName; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@NullMarked +@ApplicationScoped +public class BrandDatabaseRepository implements BrandRepository { + private final Cache cache; + private final EntityManager em; + private final CriteriaBuilderFactory cbf; + + @Inject + public BrandDatabaseRepository( + @CacheName("z3-brands") Cache cache, EntityManager em, CriteriaBuilderFactory cbf) { + this.cache = cache; + this.em = em; + this.cbf = cbf; + } + + @Override + public void persist(Brand brand) { + em.persist(brand); + clearCache(); + } + + @Override + public void update(Brand brand) { + em.merge(brand); + clearCache(); + } + + @Override + public boolean exists(UUID uuid) { + return cache + .get( + "%s-exists".formatted(uuid.toString()), + k -> { + var typedQuery = query().where("uuid").eq(uuid).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + }) + .await() + .indefinitely(); + } + + @Override + public boolean exists(Slug slug) { + return cache + .get( + "%s-exists".formatted(slug.value()), + k -> { + var typedQuery = query().where("slug").eq(slug).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + }) + .await() + .indefinitely(); + } + + @Override + public Optional findByUuid(UUID uuid) { + return cache + .>get( + uuid, + k -> { + var typedQuery = query().where("uuid").eq(uuid).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public Optional findBySlug(Slug slug) { + return cache + .>get( + slug, + k -> { + var typedQuery = query().where("slug").eq(slug).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public Optional findByFqdn(Fqdn fqdn) { + return cache + .>get( + fqdn, + k -> { + var typedQuery = query().where("fqdns").in(fqdn).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public PaginatedData findAll(Pagination pagination) { + var query = query().page(pagination.offset(), pagination.limit()).orderByAsc("uuid").getQuery(); + + var paginatedBrandList = query.getResultList(); + + return PaginatedData.wrap( + paginatedBrandList, pagination.limit(), pagination.offset(), query.getTotalCount()); + } + + private void clearCache() { + cache.invalidateAll().await().indefinitely(); + } + + private CriteriaBuilder query() { + return cbf.create(em, Brand.class).from(Brand.class); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/brands/BrandInMemoryRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/brands/BrandInMemoryRepository.java new file mode 100644 index 0000000..683356b --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/brands/BrandInMemoryRepository.java @@ -0,0 +1,89 @@ +package tech.pegase.z3.infrastructure.adapter.repository.brands; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.Brand; +import tech.pegase.z3.domain.port.repository.BrandRepository; +import tech.pegase.z3.domain.valueobject.Fqdn; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@NullMarked +public class BrandInMemoryRepository implements BrandRepository { + private final ConcurrentMap data = new ConcurrentHashMap<>(); + + @Override + public void persist(Brand brand) { + data.put(brand.getUuid(), brand); + } + + @Override + public void update(Brand brand) { + data.put(brand.getUuid(), brand); + } + + @Override + public boolean exists(UUID uuid) { + return data.containsKey(uuid); + } + + @Override + public boolean exists(Slug slug) { + return findBySlug(slug).isPresent(); + } + + @Override + public Optional findByUuid(UUID uuid) { + return Optional.ofNullable(data.get(uuid)); + } + + @Override + public Optional findBySlug(Slug slug) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var value : data.values()) { + if (value.getSlug().equals(slug)) { + return Optional.of(value); + } + } + + return Optional.empty(); + } + + @Override + public Optional findByFqdn(Fqdn fqdn) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var value : data.values()) { + if (value.getFqdns().contains(fqdn)) { + return Optional.of(value); + } + } + + return Optional.empty(); + } + + @Override + public PaginatedData findAll(Pagination pagination) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream().skip(pagination.offset()).limit(pagination.limit()).toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/catalogs/CatalogDatabaseRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/catalogs/CatalogDatabaseRepository.java new file mode 100644 index 0000000..cf47912 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/catalogs/CatalogDatabaseRepository.java @@ -0,0 +1,175 @@ +package tech.pegase.z3.infrastructure.adapter.repository.catalogs; + +import com.blazebit.persistence.CriteriaBuilder; +import com.blazebit.persistence.CriteriaBuilderFactory; +import com.blazebit.persistence.DeleteCriteriaBuilder; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheName; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.infrastructure.adapter.repository.links.LinkDatabaseRepository; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@NullMarked +@ApplicationScoped +public class CatalogDatabaseRepository implements CatalogRepository { + private final LinkRepository linkRepository; + private final Cache cache; + private final EntityManager em; + private final CriteriaBuilderFactory cbf; + + @Inject + public CatalogDatabaseRepository( + LinkRepository linkRepository, + @CacheName("z3-catalogs") Cache cache, + EntityManager em, + CriteriaBuilderFactory cbf) { + this.linkRepository = linkRepository; + this.cache = cache; + this.em = em; + this.cbf = cbf; + } + + @Override + public void persist(Catalog catalog) { + em.persist(catalog); + clearCache(); + } + + @Override + public void update(Catalog catalog) { + em.merge(catalog); + clearCache(); + } + + @Override + public boolean exists(UUID uuid) { + Objects.requireNonNull(uuid, "uuid cannot be null"); + return cache + .get( + "%s-exists".formatted(uuid), + k -> { + var typedQuery = query().where("uuid").eq(uuid).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + }) + .await() + .indefinitely(); + } + + @Override + public boolean exists(Slug slug) { + return cache + .get( + "%s-exists".formatted(slug.value()), + k -> { + var typedQuery = query().where("slug").eq(slug).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + }) + .await() + .indefinitely(); + } + + @Override + public boolean remove(UUID uuid) { + var typedQuery = deleteQuery().where("uuid").eq(uuid).getQuery(); + clearCache(); + + return typedQuery.executeUpdate() != 0; + } + + @Override + public boolean remove(Slug slug) { + var typedQuery = deleteQuery().where("slug").eq(slug).getQuery(); + clearCache(); + + return typedQuery.executeUpdate() != 0; + } + + @Override + public Optional findByUuid(UUID uuid) { + return cache + .>get( + uuid, + k -> { + var typedQuery = query().where("uuid").eq(uuid).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public Optional findBySlug(Slug slug) { + return cache + .>get( + slug, + k -> { + var typedQuery = query().where("slug").eq(slug).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public PaginatedData findAll(Pagination pagination) { + var query = query().page(pagination.offset(), pagination.limit()).orderByAsc("uuid").getQuery(); + + var paginatedCatalogList = query.getResultList(); + + return PaginatedData.wrap( + paginatedCatalogList, pagination.limit(), pagination.offset(), query.getTotalCount()); + } + + @Override + public PaginatedData findAllForBrand(UUID brandUuid, Pagination pagination) { + var query = + query() + .where("brand") + .eq(brandUuid) + .page(pagination.offset(), pagination.limit()) + .orderByAsc("uuid") + .getQuery(); + + var paginatedCatalogList = query.getResultList(); + + return PaginatedData.wrap( + paginatedCatalogList, pagination.limit(), pagination.offset(), query.getTotalCount()); + } + + private void clearCache() { + if (linkRepository instanceof LinkDatabaseRepository) { + ((LinkDatabaseRepository) linkRepository).clearCache(); + } + cache.invalidateAll().await().indefinitely(); + } + + private CriteriaBuilder query() { + return cbf.create(em, Catalog.class).from(Catalog.class); + } + + private DeleteCriteriaBuilder deleteQuery() { + return cbf.delete(em, Catalog.class).from(Catalog.class); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/catalogs/CatalogInMemoryRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/catalogs/CatalogInMemoryRepository.java new file mode 100644 index 0000000..64bd67d --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/catalogs/CatalogInMemoryRepository.java @@ -0,0 +1,113 @@ +package tech.pegase.z3.infrastructure.adapter.repository.catalogs; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.Catalog; +import tech.pegase.z3.domain.port.repository.CatalogRepository; +import tech.pegase.z3.domain.valueobject.Slug; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@NullMarked +public class CatalogInMemoryRepository implements CatalogRepository { + private final ConcurrentMap data = new ConcurrentHashMap<>(); + + @Override + public void persist(Catalog catalog) { + data.put(catalog.getUuid(), catalog); + } + + @Override + public void update(Catalog catalog) { + data.put(catalog.getUuid(), catalog); + } + + @Override + public boolean exists(UUID uuid) { + return data.containsKey(uuid); + } + + @Override + public boolean exists(Slug slug) { + return findBySlug(slug).isPresent(); + } + + @Override + public boolean remove(UUID uuid) { + + if (!data.containsKey(uuid)) { + return false; + } + + data.remove(uuid); + return true; + } + + @Override + public boolean remove(Slug slug) { + var entry = findBySlug(slug); + + if (entry.isEmpty()) { + return false; + } + + data.remove(entry.get().getUuid()); + return true; + } + + @Override + public Optional findByUuid(UUID uuid) { + return Optional.ofNullable(data.get(uuid)); + } + + @Override + public Optional findBySlug(Slug slug) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var value : data.values()) { + if (value.getSlug().equals(slug)) { + return Optional.of(value); + } + } + + return Optional.empty(); + } + + @Override + public PaginatedData findAll(Pagination pagination) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream().skip(pagination.offset()).limit(pagination.limit()).toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } + + @Override + public PaginatedData findAllForBrand(UUID brandUuid, Pagination pagination) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream() + .filter((catalog) -> catalog.getBrandUuid().equals(brandUuid)) + .skip(pagination.offset()) + .limit(pagination.limit()) + .toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/credentials/CredentialDatabaseRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/credentials/CredentialDatabaseRepository.java new file mode 100644 index 0000000..05c171d --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/credentials/CredentialDatabaseRepository.java @@ -0,0 +1,82 @@ +package tech.pegase.z3.infrastructure.adapter.repository.credentials; + +import com.blazebit.persistence.CriteriaBuilder; +import com.blazebit.persistence.CriteriaBuilderFactory; +import com.blazebit.persistence.DeleteCriteriaBuilder; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.valueobject.Login; + +@NullMarked +@ApplicationScoped +public class CredentialDatabaseRepository implements CredentialRepository { + private final EntityManager em; + private final CriteriaBuilderFactory cbf; + + @Inject + public CredentialDatabaseRepository(EntityManager em, CriteriaBuilderFactory cbf) { + this.em = em; + this.cbf = cbf; + } + + @Override + public void persist(Credential credential) { + em.persist(credential); + } + + @Override + public void update(Credential credential) { + em.merge(credential); + } + + @Override + public boolean exists(UUID uuid) { + var typedQuery = query().where("uuid").eq(uuid).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + } + + @Override + public boolean remove(UUID uuid) { + var typedQuery = deleteQuery().where("uuid").eq(uuid).getQuery(); + + return typedQuery.executeUpdate() != 0; + } + + @Override + public Optional findById(UUID uuid) { + try { + var typedQuery = query().where("uuid").eq(uuid).getQuery(); + + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + @Override + public Optional findByLogin(Login login) { + try { + var typedQuery = query().where("login").eq(login).getQuery(); + + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + private CriteriaBuilder query() { + return cbf.create(em, Credential.class).from(Credential.class); + } + + private DeleteCriteriaBuilder deleteQuery() { + return cbf.delete(em, Credential.class).from(Credential.class); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/credentials/CredentialInMemoryRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/credentials/CredentialInMemoryRepository.java new file mode 100644 index 0000000..307967c --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/credentials/CredentialInMemoryRepository.java @@ -0,0 +1,77 @@ +package tech.pegase.z3.infrastructure.adapter.repository.credentials; + +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.Credential; +import tech.pegase.z3.domain.port.repository.CredentialRepository; +import tech.pegase.z3.domain.valueobject.Login; + +@NullMarked +public class CredentialInMemoryRepository implements CredentialRepository { + private final ConcurrentMap data = new ConcurrentHashMap<>(); + + @Override + public void persist(Credential credential) { + Objects.requireNonNull(credential, "credential cannot be null"); + + data.put(credential.getUuid(), credential); + } + + @Override + public void update(Credential credential) { + data.put(credential.getUuid(), credential); + } + + @Override + public boolean exists(UUID uuid) { + + return data.containsKey(uuid); + } + + @Override + public boolean remove(UUID uuid) { + + if (!data.containsKey(uuid)) { + return false; + } + + data.remove(uuid); + return true; + } + + @Override + public Optional findById(UUID uuid) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var keySet : data.keySet()) { + if (keySet.equals(uuid)) { + return Optional.of(data.get(keySet)); + } + } + + return Optional.ofNullable(data.get(uuid)); + } + + @Override + public Optional findByLogin(Login login) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var credential : data.values()) { + if (credential.getLogin().equals(login)) { + return Optional.of(credential); + } + } + + return Optional.empty(); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/links/LinkDatabaseRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/links/LinkDatabaseRepository.java new file mode 100644 index 0000000..eeef277 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/links/LinkDatabaseRepository.java @@ -0,0 +1,368 @@ +package tech.pegase.z3.infrastructure.adapter.repository.links; + +import com.blazebit.persistence.CriteriaBuilder; +import com.blazebit.persistence.CriteriaBuilderFactory; +import com.blazebit.persistence.PaginatedCriteriaBuilder; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheName; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import java.util.List; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.concepts.Ordering; +import tech.pegase.z3.domain.valueobject.concepts.Ordering.Direction; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering.Value; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering.Field; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; +import tech.pegase.z3.shared.valueobject.Source; + +@NullMarked +@ApplicationScoped +public class LinkDatabaseRepository implements LinkRepository { + private final Cache cache; + private final EntityManager em; + private final CriteriaBuilderFactory cbf; + + @Inject + public LinkDatabaseRepository( + @CacheName("z3-links") Cache cache, EntityManager em, CriteriaBuilderFactory cbf) { + this.cache = cache; + this.em = em; + this.cbf = cbf; + } + + @Override + public void persist(Link link) { + em.persist(link); + clearCache(); + } + + @Override + public void update(Link link) { + em.merge(link); + clearCache(); + } + + @Override + public boolean exists(Source source) { + return cache + .get( + "%s-exists".formatted(source.toString()), + k -> { + var typedQuery = query(Link.class).where("source").eq(source).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + }) + .await() + .indefinitely(); + } + + @Override + public Optional findBySource(Source source) { + return cache + .>get( + source, + k -> { + var typedQuery = query(Link.class).where("source").eq(source).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public PaginatedData findAllForBrand(Pagination pagination, @Nullable UUID brandUuid) { + var query = + query(Link.class) + .page(pagination.offset(), pagination.limit()) + .orderByAsc("createdAt") + .orderByAsc("source"); + + if (brandUuid != null) { + query = query.where("brand").eq(brandUuid); + } else { + query = query.where("brand").isNull(); + } + + var paginatedLinkList = query.getQuery().getResultList(); + + return PaginatedData.wrap( + paginatedLinkList, + pagination.limit(), + pagination.offset(), + paginatedLinkList.getTotalSize()); + } + + @Override + public PaginatedData findAllForTag(Pagination pagination, @Nullable UUID tagUuid) { + var query = + query(Link.class) + .page(pagination.offset(), pagination.limit()) + .orderByAsc("createdAt") + .orderByAsc("source"); + + if (tagUuid != null) { + query = query.where("tag").eq(tagUuid); + } else { + query = query.where("tag").isNull(); + } + + var paginatedLinkList = query.getQuery().getResultList(); + + return PaginatedData.wrap( + paginatedLinkList, + pagination.limit(), + pagination.offset(), + paginatedLinkList.getTotalSize()); + } + + @Override + public PaginatedData findAllForCatalog(Pagination pagination, @Nullable UUID catalogUuid) { + var query = + query(Link.class) + .page(pagination.offset(), pagination.limit()) + .orderByAsc("createdAt") + .orderByAsc("source"); + + if (catalogUuid != null) { + query = query.where("catalog").eq(catalogUuid); + } else { + query = query.where("catalog").isNull(); + } + + var paginatedLinkList = query.getQuery().getResultList(); + + return PaginatedData.wrap( + paginatedLinkList, + pagination.limit(), + pagination.offset(), + paginatedLinkList.getTotalSize()); + } + + @Override + public PaginatedData findAll(Pagination pagination, LinkFiltering filters) { + var paginatedLinkList = + paginatedQueryAnyWherePartiallyMatchFilter( + Link.class, pagination, null, filters, List.of(), "source") + .getResultList(); + + return PaginatedData.wrap( + paginatedLinkList, + pagination.limit(), + pagination.offset(), + paginatedLinkList.getTotalSize()); + } + + @Override + public PaginatedData findAll( + Pagination pagination, LinkFiltering filters, LinkOrdering ordering) { + + var paginatedLinkList = + paginatedQueryAnyWherePartiallyMatchFilter( + Link.class, pagination, ordering, filters, List.of(), "source") + .getResultList(); + + return PaginatedData.wrap( + paginatedLinkList, + pagination.limit(), + pagination.offset(), + paginatedLinkList.getTotalSize()); + } + + @Override + public PaginatedData findAll( + Pagination pagination, LinkFiltering filters, List searchingList) { + + var paginatedLinkList = + paginatedQueryAnyWherePartiallyMatchFilter( + Link.class, pagination, null, filters, searchingList, "source") + .getResultList(); + + var totalItemCount = paginatedLinkList.getTotalSize(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), totalItemCount); + } + + @Override + public PaginatedData findAll( + Pagination pagination, + LinkFiltering filters, + LinkOrdering ordering, + List searchingList) { + var paginatedLinkList = + paginatedQueryAnyWherePartiallyMatchFilter( + Link.class, pagination, ordering, filters, searchingList, "source") + .getResultList(); + + return PaginatedData.wrap( + paginatedLinkList, + pagination.limit(), + pagination.offset(), + paginatedLinkList.getTotalSize()); + } + + @Override + public Long countTotalVisits(@Nullable UUID brandUuid) { + Class<@Nullable Long> longType = Long.class; + var query = cbf.create(em, longType).select("sum(visits)").from(Link.class); + + if (brandUuid != null) { + query = query.where("brand").eq(brandUuid); + } else { + query = query.where("brand").isNull(); + } + + // * Get single result could return NULL if there are no results from the query. + var totalVisitsCount = query.getQuery().getSingleResult(); + + return totalVisitsCount != null ? totalVisitsCount : 0; + } + + @Override + public Long countTotalVisits() { + Class<@Nullable Long> longType = Long.class; + var query = cbf.create(em, longType).select("sum(visits)").from(Link.class); + + // * Get single result could return NULL if there are no results from the query. + var totalVisitsCount = query.getQuery().getSingleResult(); + + return totalVisitsCount != null ? totalVisitsCount : 0; + } + + @Override + public Long countTotalExternal(@Nullable UUID brandUuid) { + var query = query(Link.class).where("isExternal").eq(true); + + if (brandUuid != null) { + query = query.where("brand").eq(brandUuid); + } else { + query = query.where("brand").isNull(); + } + + return query.getCountQuery().getSingleResult(); + } + + @Override + public Long countTotalExternal() { + var query = query(Link.class).where("isExternal").eq(true); + + return query.getCountQuery().getSingleResult(); + } + + @Override + public Long countTotalInvalids(@Nullable UUID brandUuid) { + var query = query(Link.class).where("validity").eq(LinkValidity.invalid()); + + if (brandUuid != null) { + query = query.where("brand").eq(brandUuid); + } else { + query = query.where("brand").isNull(); + } + + return query.getCountQuery().getSingleResult(); + } + + @Override + public Long countTotalInvalids() { + var query = query(Link.class).where("validity").eq(LinkValidity.valid()); + + return query.getCountQuery().getSingleResult(); + } + + public void clearCache() { + cache.invalidateAll().await().indefinitely(); + } + + private PaginatedCriteriaBuilder paginatedQueryAnyWherePartiallyMatchFilter( + Class resultClass, + Pagination pagination, + @Nullable Ordering ordering, + LinkFiltering filters, + List searchingList, + String defaultOrderBy) { + + // Select * From `links` Where (`x` like "%str%" or `y` like "%str%" or ...) + var blazeQuery = query(resultClass); + + if (!searchingList.isEmpty()) { + var i = searchingList.size(); + var whereOr = blazeQuery.whereOr(); + while (i-- > 0) { + var searchCriteria = searchingList.get(i); + whereOr = + whereOr + .where(searchingFieldToColumnName(searchCriteria.getField())) + .like() + .value("%" + searchCriteria.getSearchValue() + "%") + .noEscape(); + } + blazeQuery = whereOr.endOr(); + } + + if (!filters.getFilters().isEmpty()) { + for (var filter : filters.getFilters().entrySet()) { + blazeQuery = filterCriteria(blazeQuery, filter); + } + } + + var blazeQueryPaginated = blazeQuery.page(pagination.offset(), pagination.limit()); + + if (ordering != null && !ordering.getDirection().equals(Direction.NATURAL)) { + blazeQueryPaginated = + blazeQueryPaginated + .orderBy(ordering.getFieldName(), ordering.getDirection().equals(Direction.ASC)) + .orderBy(defaultOrderBy, true); + } else { + blazeQueryPaginated = blazeQueryPaginated.orderBy(defaultOrderBy, true); + } + + return blazeQueryPaginated; + } + + private CriteriaBuilder filterCriteria( + CriteriaBuilder criteriaBuilder, Entry filter) { + var fieldName = filter.getKey().getFieldName(); + var fieldValue = filter.getValue() != null ? filter.getValue().getFieldValue() : null; + + if (fieldValue == null) { + return criteriaBuilder.where(fieldName).isNull(); + } + + return switch (filter.getKey()) { + case TAG, CATALOG, BRAND -> criteriaBuilder.where(fieldName).eq(UUID.fromString(fieldValue)); + case IS_EXTERNAL, IS_VALID -> + criteriaBuilder + .where(fieldName) + .eq(Boolean.parseBoolean(fieldValue.equals("1") ? "true" : fieldValue)); + }; + } + + private CriteriaBuilder query(Class output) { + return cbf.create(em, output).from(output); + } + + private String searchingFieldToColumnName(LinkSearching.Field searchingField) { + return switch (searchingField) { + case LinkSearching.Field.SOURCE -> "source.value"; + case LinkSearching.Field.TARGET -> "target.value"; + }; + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/links/LinkInMemoryRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/links/LinkInMemoryRepository.java new file mode 100644 index 0000000..9860cb8 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/links/LinkInMemoryRepository.java @@ -0,0 +1,290 @@ +package tech.pegase.z3.infrastructure.adapter.repository.links; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.domain.entity.Link; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.valueobject.LinkValidity; +import tech.pegase.z3.domain.valueobject.concepts.Ordering.Direction; +import tech.pegase.z3.domain.valueobject.filterings.LinkFiltering; +import tech.pegase.z3.domain.valueobject.orderings.LinkOrdering; +import tech.pegase.z3.domain.valueobject.searchings.LinkSearching; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; +import tech.pegase.z3.shared.valueobject.Source; + +@NullMarked +public class LinkInMemoryRepository implements LinkRepository { + private final ConcurrentMap data = new ConcurrentHashMap<>(); + + @Override + public void persist(Link link) { + data.put(link.getSource(), link); + } + + @Override + public void update(Link link) { + data.put(link.getSource(), link); + } + + @Override + public boolean exists(Source source) { + return data.containsKey(source); + } + + @Override + public Optional findBySource(Source source) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var keySet : data.keySet()) { + if (keySet.value().equals(source.value())) { + return Optional.of(data.get(keySet)); + } + } + + return Optional.ofNullable(data.get(source)); + } + + @Override + public PaginatedData findAllForBrand(Pagination pagination, @Nullable UUID brandUuid) { + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream() + .filter((link) -> brandUuid == null || brandUuid.equals(link.getBrand())) + .skip(pagination.offset()) + .limit(pagination.limit()) + .toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } + + @Override + public PaginatedData findAllForTag(Pagination pagination, @Nullable UUID tagUuid) { + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream() + .filter((link) -> tagUuid == null || tagUuid.equals(link.getTag())) + .skip(pagination.offset()) + .limit(pagination.limit()) + .toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } + + @Override + public PaginatedData findAllForCatalog(Pagination pagination, @Nullable UUID catalogUuid) { + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream() + .filter((link) -> catalogUuid == null || catalogUuid.equals(link.getCatalog())) + .skip(pagination.offset()) + .limit(pagination.limit()) + .toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } + + @Override + public PaginatedData findAll(Pagination pagination, LinkFiltering filters) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream() + .filter((link) -> filterLink(link, filters)) + .skip(pagination.offset()) + .limit(pagination.limit()) + .toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } + + @Override + public PaginatedData findAll( + Pagination pagination, LinkFiltering filters, LinkOrdering ordering) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream() + .filter((link) -> filterLink(link, filters)) + .sorted(this.sortLinks(ordering)) + .skip(pagination.offset()) + .limit(pagination.limit()) + .toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } + + @Override + public PaginatedData findAll( + Pagination pagination, LinkFiltering filters, List searchings) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream() + .filter((link) -> searchLink(link, searchings)) + .skip(pagination.offset()) + .limit(pagination.limit()) + .toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } + + @Override + public PaginatedData findAll( + Pagination pagination, + LinkFiltering filters, + LinkOrdering ordering, + List searchings) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream() + .filter((link) -> searchLink(link, searchings)) + .filter((link) -> filterLink(link, filters)) + .sorted(this.sortLinks(ordering)) + .skip(pagination.offset()) + .limit(pagination.limit()) + .toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } + + @Override + public Long countTotalVisits(@Nullable UUID brandUuid) { + return data.values().stream() + .filter((link) -> link.getBrand() == brandUuid) + .map(Link::getVisits) + .reduce(Integer::sum) + .orElse(0) + .longValue(); + } + + @Override + public Long countTotalVisits() { + return data.values().stream().map(Link::getVisits).reduce(Integer::sum).orElse(0).longValue(); + } + + @Override + public Long countTotalExternal(@Nullable UUID brandUuid) { + return data.values().stream().filter(Link::isExternal).count(); + } + + @Override + public Long countTotalExternal() { + return data.values().stream().filter(Link::isExternal).count(); + } + + @Override + public Long countTotalInvalids(@Nullable UUID brandUuid) { + return data.values().stream() + .filter(link -> link.getBrand() == brandUuid) + .filter(link -> link.getValidity().equals(LinkValidity.valid())) + .count(); + } + + @Override + public Long countTotalInvalids() { + return data.values().stream() + .filter(link -> link.getValidity().equals(LinkValidity.valid())) + .count(); + } + + private Comparator sortLinks(LinkOrdering ordering) { + return (final Link o1, final Link o2) -> { + return switch (ordering.getField()) { + case LinkOrdering.Field.SOURCE -> { + var cmp = o1.getSource().compareTo(o2.getSource()); + yield ordering.getDirection().equals(Direction.ASC) ? cmp : -cmp; + } + case LinkOrdering.Field.VISITS -> { + var cmp = o1.getVisits() - o2.getVisits(); + yield ordering.getDirection().equals(Direction.ASC) ? cmp : -cmp; + } + case LinkOrdering.Field.IS_EXTERNAL -> { + var cmp = (o1.isExternal() ? 1 : 0) - (o2.isExternal() ? 1 : 0); + yield ordering.getDirection().equals(Direction.ASC) ? cmp : -cmp; + } + case LinkOrdering.Field.VALIDITY -> { + var cmp = o1.getValidity().value().compareTo(o2.getValidity().value()); + yield ordering.getDirection().equals(Direction.ASC) ? cmp : -cmp; + } + case LinkOrdering.Field.CREATED_AT -> { + var cmp = o1.getCreatedAt().compareTo(o2.getCreatedAt()); + yield ordering.getDirection().equals(Direction.ASC) ? cmp : -cmp; + } + }; + }; + } + + private boolean searchLink(Link link, List searchingList) { + if (searchingList.isEmpty()) { + return false; + } + + return searchingList.stream() + .anyMatch( + (searching) -> { + return switch (searching.field()) { + case SOURCE -> link.getSource().value().contains(searching.getSearchValue()); + case TARGET -> link.getTarget().value().contains(searching.getSearchValue()); + }; + }); + } + + private boolean filterLink(Link link, LinkFiltering filteringList) { + if (filteringList.getFilters().isEmpty()) { + return false; + } + + return filteringList.getFilters().entrySet().stream() + .anyMatch( + (entry) -> { + var value = entry.getValue() != null ? entry.getValue().value() : null; + value = value != null && value.equals("1") ? "true" : value; + return switch (entry.getKey()) { + case BRAND -> link.getBrand() != null && link.getBrand().toString().equals(value); + case TAG -> link.getTag() != null && link.getTag().toString().equals(value); + case CATALOG -> + link.getCatalog() != null && link.getCatalog().toString().equals(value); + case IS_EXTERNAL, IS_VALID -> link.isExternal() == Boolean.parseBoolean(value); + }; + }); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/reporting/ReportingSubscriptionDatabaseRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/reporting/ReportingSubscriptionDatabaseRepository.java new file mode 100644 index 0000000..91a218b --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/reporting/ReportingSubscriptionDatabaseRepository.java @@ -0,0 +1,105 @@ +package tech.pegase.z3.infrastructure.adapter.repository.reporting; + +import com.blazebit.persistence.CriteriaBuilder; +import com.blazebit.persistence.CriteriaBuilderFactory; +import com.blazebit.persistence.DeleteCriteriaBuilder; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheName; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import tech.pegase.happy.core.DomainEntity.Id; +import tech.pegase.z3.domain.entity.ReportingSubscription; +import tech.pegase.z3.domain.port.repository.ReportingSubscriptionRepository; + +@NullMarked +@ApplicationScoped +public class ReportingSubscriptionDatabaseRepository implements ReportingSubscriptionRepository { + private final Cache cache; + private final EntityManager em; + private final CriteriaBuilderFactory cbf; + + @Inject + public ReportingSubscriptionDatabaseRepository( + @CacheName("z3-reporting-subscriptions") Cache cache, + EntityManager em, + CriteriaBuilderFactory cbf) { + this.cache = cache; + this.em = em; + this.cbf = cbf; + } + + @Override + public Set getAllSubscriptions() { + return cache + .get( + "all", + k -> { + var typedQuery = query().getQuery(); + return new HashSet<>(typedQuery.getResultList()); + }) + .await() + .indefinitely(); + } + + @Override + public Set getAllSubscriptions(UUID userUuid) { + return cache + .get( + "all-for%s".formatted(userUuid), + k -> { + var typedQuery = query().where("user").eq(userUuid).getQuery(); + return new HashSet<>(typedQuery.getResultList()); + }) + .await() + .indefinitely(); + } + + @Override + public void removeAllUserSubscriptions(UUID userUuid) { + clearCache(); + deleteQuery().where("user").eq(userUuid).getQuery().executeUpdate(); + } + + @Override + public void setSubscriptions(Set subscriptions) { + clearCache(); + subscriptions.forEach(em::persist); + } + + private void clearCache() { + cache.invalidateAll().await().indefinitely(); + } + + @Override + public Optional find(Id id) { + return cache + ., Optional>get( + id, + k -> { + try { + var typedQuery = query().where("uuid").eq(id.value()).getQuery(); + + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + private CriteriaBuilder query() { + return cbf.create(em, ReportingSubscription.class).from(ReportingSubscription.class); + } + + private DeleteCriteriaBuilder deleteQuery() { + return cbf.delete(em, ReportingSubscription.class).from(ReportingSubscription.class); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/reporting/ReportingSubscriptionInMemoryRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/reporting/ReportingSubscriptionInMemoryRepository.java new file mode 100644 index 0000000..2df6a93 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/reporting/ReportingSubscriptionInMemoryRepository.java @@ -0,0 +1,57 @@ +package tech.pegase.z3.infrastructure.adapter.repository.reporting; + +import java.util.HashSet; +import java.util.Map.Entry; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; +import org.jspecify.annotations.NullMarked; +import tech.pegase.happy.core.DomainEntity.Id; +import tech.pegase.z3.domain.entity.ReportingSubscription; +import tech.pegase.z3.domain.port.repository.ReportingSubscriptionRepository; + +@NullMarked +public class ReportingSubscriptionInMemoryRepository implements ReportingSubscriptionRepository { + private final ConcurrentMap data = new ConcurrentHashMap<>(); + + @Override + public Set getAllSubscriptions() { + return new HashSet<>(data.values()); + } + + @Override + public Set getAllSubscriptions(UUID userUuid) { + return data.values().stream() + .filter(v -> v.getUser().equals(userUuid)) + .collect(Collectors.toSet()); + } + + @Override + public void removeAllUserSubscriptions(UUID userUuid) { + var keysToRemove = + data.entrySet().stream() + .filter(entry -> entry.getValue().getUser().equals(userUuid)) + .map(Entry::getKey); + + keysToRemove.forEach(data::remove); + } + + @Override + public void setSubscriptions(Set subscriptions) { + subscriptions.forEach(subscription -> data.put(subscription.getUuid(), subscription)); + } + + @Override + public Optional find(Id id) { + var value = data.get(id.value()); + + if (value == null) { + return Optional.empty(); + } + + return Optional.of(value); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/tags/TagDatabaseRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/tags/TagDatabaseRepository.java new file mode 100644 index 0000000..d057c9f --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/tags/TagDatabaseRepository.java @@ -0,0 +1,139 @@ +package tech.pegase.z3.infrastructure.adapter.repository.tags; + +import com.blazebit.persistence.CriteriaBuilder; +import com.blazebit.persistence.CriteriaBuilderFactory; +import com.blazebit.persistence.DeleteCriteriaBuilder; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheName; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.LinkRepository; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.infrastructure.adapter.repository.links.LinkDatabaseRepository; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@NullMarked +@ApplicationScoped +public class TagDatabaseRepository implements TagRepository { + private final LinkRepository linkRepository; + private final Cache cache; + private final EntityManager em; + private final CriteriaBuilderFactory cbf; + + @Inject + public TagDatabaseRepository( + LinkRepository linkRepository, + @CacheName("z3-tags") Cache cache, + EntityManager em, + CriteriaBuilderFactory cbf) { + this.linkRepository = linkRepository; + this.cache = cache; + this.em = em; + this.cbf = cbf; + } + + @Override + public void persist(Tag tag) { + em.persist(tag); + clearCache(); + } + + @Override + public void update(Tag tag) { + em.merge(tag); + clearCache(); + } + + @Override + public boolean exists(UUID uuid) { + return cache + .get( + "%s-exists".formatted(uuid), + k -> { + var typedQuery = query().where("uuid").eq(uuid).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + }) + .await() + .indefinitely(); + } + + @Override + public boolean remove(UUID uuid) { + clearCache(); + + var typedQuery = deleteQuery().where("uuid").eq(uuid).getQuery(); + + return typedQuery.executeUpdate() != 0; + } + + @Override + public Optional findByUuid(UUID uuid) { + return cache + .>get( + uuid, + k -> { + var typedQuery = query().where("uuid").eq(uuid).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public Optional findByName(String name) { + return cache + .>get( + name, + k -> { + var typedQuery = query().where("name").eq(name).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public PaginatedData findAll(Pagination pagination) { + + var query = + query() + .page(pagination.offset(), pagination.limit()) + .orderByAsc("uuid") + .getQuery() + .getResultList(); + + return PaginatedData.wrap(query, pagination.limit(), pagination.offset(), query.getTotalSize()); + } + + private void clearCache() { + if (linkRepository instanceof LinkDatabaseRepository) { + ((LinkDatabaseRepository) linkRepository).clearCache(); + } + + cache.invalidateAll().await().indefinitely(); + } + + private CriteriaBuilder query() { + return cbf.create(em, Tag.class).from(Tag.class); + } + + private DeleteCriteriaBuilder deleteQuery() { + return cbf.delete(em, Tag.class).from(Tag.class); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/tags/TagInMemoryRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/tags/TagInMemoryRepository.java new file mode 100644 index 0000000..d0582cd --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/tags/TagInMemoryRepository.java @@ -0,0 +1,79 @@ +package tech.pegase.z3.infrastructure.adapter.repository.tags; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.Tag; +import tech.pegase.z3.domain.port.repository.TagRepository; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@NullMarked +public class TagInMemoryRepository implements TagRepository { + private final ConcurrentMap data = new ConcurrentHashMap<>(); + + @Override + public void persist(Tag tag) { + + data.put(tag.getUuid(), tag); + } + + @Override + public void update(Tag tag) { + data.put(tag.getUuid(), tag); + } + + @Override + public boolean exists(UUID uuid) { + + return data.containsKey(uuid); + } + + @Override + public boolean remove(UUID uuid) { + + if (!data.containsKey(uuid)) { + return false; + } + + data.remove(uuid); + return true; + } + + @Override + public Optional findByUuid(UUID uuid) { + return Optional.ofNullable(data.get(uuid)); + } + + @Override + public Optional findByName(String name) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var value : data.values()) { + if (value.getName().equals(name)) { + return Optional.of(value); + } + } + + return Optional.empty(); + } + + @Override + public PaginatedData findAll(Pagination pagination) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream().skip(pagination.offset()).limit(pagination.limit()).toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/users/UserDatabaseRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/users/UserDatabaseRepository.java new file mode 100644 index 0000000..8929d90 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/users/UserDatabaseRepository.java @@ -0,0 +1,103 @@ +package tech.pegase.z3.infrastructure.adapter.repository.users; + +import com.blazebit.persistence.CriteriaBuilder; +import com.blazebit.persistence.CriteriaBuilderFactory; +import com.blazebit.persistence.DeleteCriteriaBuilder; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import java.util.Optional; +import java.util.UUID; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@NullMarked +@ApplicationScoped +public class UserDatabaseRepository implements UserRepository { + private final EntityManager em; + private final CriteriaBuilderFactory cbf; + + @Inject + public UserDatabaseRepository(EntityManager em, CriteriaBuilderFactory cbf) { + this.em = em; + this.cbf = cbf; + } + + @Override + public void persist(User user) { + em.persist(user); + } + + @Override + public void update(User user) { + em.merge(user); + } + + @Override + public boolean exists(UUID uuid) { + var typedQuery = query().where("uuid").eq(uuid).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + } + + @Override + public boolean exists(Email email) { + var typedQuery = query().where("email").eq(email).getCountQuery(); + + return typedQuery.getSingleResult() != 0; + } + + @Override + public boolean remove(UUID uuid) { + var typedQuery = deleteQuery().where("uuid").eq(uuid).getQuery(); + + return typedQuery.executeUpdate() != 0; + } + + @Override + public Optional findByUuid(UUID uuid) { + var typedQuery = query().where("uuid").eq(uuid).getQuery(); + + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + @Override + public Optional findByCredentialUuid(UUID credentialUuid) { + var typedQuery = query().where("credentialUuid").eq(credentialUuid).getQuery(); + + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + @Override + public PaginatedData findAll(Pagination pagination) { + var query = + query() + .page(pagination.offset(), pagination.limit()) + .orderByAsc("uuid") + .getQuery() + .getResultList(); + + return PaginatedData.wrap(query, pagination.limit(), pagination.offset(), query.getTotalSize()); + } + + private CriteriaBuilder query() { + return cbf.create(em, User.class).from(User.class); + } + + private DeleteCriteriaBuilder deleteQuery() { + return cbf.delete(em, User.class).from(User.class); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/users/UserInMemoryRepository.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/users/UserInMemoryRepository.java new file mode 100644 index 0000000..d9b8fbe --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/repository/users/UserInMemoryRepository.java @@ -0,0 +1,101 @@ +package tech.pegase.z3.infrastructure.adapter.repository.users; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.jspecify.annotations.NullMarked; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.repository.UserRepository; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.shared.pagination.PaginatedData; +import tech.pegase.z3.shared.pagination.Pagination; + +@NullMarked +public class UserInMemoryRepository implements UserRepository { + private final ConcurrentMap data = new ConcurrentHashMap<>(); + + @Override + public void persist(User user) { + data.put(user.getUuid(), user); + } + + @Override + public void update(User user) { + data.put(user.getUuid(), user); + } + + @Override + public boolean exists(UUID uuid) { + return data.containsKey(uuid); + } + + @Override + public boolean exists(Email email) { + + for (var value : data.values()) { + if (value.getEmail().equals(email)) { + return true; + } + } + + return false; + } + + @Override + public boolean remove(UUID uuid) { + + if (!data.containsKey(uuid)) { + return false; + } + + data.remove(uuid); + return true; + } + + @Override + public Optional findByUuid(UUID uuid) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var keySet : data.keySet()) { + if (keySet.equals(uuid)) { + return Optional.of(data.get(keySet)); + } + } + + return Optional.ofNullable(data.get(uuid)); + } + + @Override + public Optional findByCredentialUuid(UUID credentialUuid) { + + if (data.isEmpty()) { + return Optional.empty(); + } + + for (var user : data.values()) { + if (user.getCredentialUuid().equals(credentialUuid)) { + return Optional.of(user); + } + } + + return Optional.empty(); + } + + @Override + public PaginatedData findAll(Pagination pagination) { + + if (data.isEmpty()) { + return PaginatedData.empty(); + } + + var paginatedLinkList = + data.values().stream().skip(pagination.offset()).limit(pagination.limit()).toList(); + + return PaginatedData.wrap( + paginatedLinkList, pagination.limit(), pagination.offset(), data.size()); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/AesEncryptionProviderImpl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/AesEncryptionProviderImpl.java new file mode 100644 index 0000000..c7969d0 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/AesEncryptionProviderImpl.java @@ -0,0 +1,202 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.AlgorithmParameterSpec; +import java.util.Arrays; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.bouncycastle.crypto.generators.Argon2BytesGenerator; +import org.bouncycastle.crypto.params.Argon2Parameters; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import tech.pegase.z3.domain.port.service.EncryptionProvider; + +@ApplicationScoped +public class AesEncryptionProviderImpl implements EncryptionProvider { + + /** Use Galois Counter Mode. */ + private static final String cipherAlgorithm = "AES/GCM/NoPadding"; + + /** + * For a 96-bits initialization vector (IV) that should be unique and unpredictable. {@see + * AES-GCM recommended IV size} + */ + private static final int cryptoIvLengthInBits = 96; + + /** + * For a 128-bits GCM tag. {@see + * AES-GCM recommended Tag size} + */ + private static final int cryptoAuthTagLengthInBits = 128; + + private final SecretKey secretKey; + + @Inject + AesEncryptionProviderImpl( + @ConfigProperty(name = "z3.infra.aes.key") String key, + @ConfigProperty(name = "z3.infra.aes.salt") String salt, + @ConfigProperty(name = "z3.infra.aes.key-length", defaultValue = "256") int keyLengthInBits) { + + if (key.isBlank()) { + throw new IllegalArgumentException("Key cannot be empty"); + } + + if (salt.isBlank()) { + throw new IllegalArgumentException("Salt cannot be empty"); + } + + /* AES-128 is 16 bytes long (AES-192 = 24 bytes, AES-256 = 32 bytes) */ + var acceptedLengths = new int[] {128, 192, 256}; + if (Arrays.stream(acceptedLengths).noneMatch((a) -> a == keyLengthInBits)) { + throw new IllegalArgumentException( + "Key length in bits has to be one of " + Arrays.toString(acceptedLengths)); + } + + this.secretKey = new SecretKeySpec(getArgon2Hash(key, salt, keyLengthInBits / 8), "AES"); + } + + private static String encodeToBase64(byte[] encrypted) { + return Base64.getEncoder().withoutPadding().encodeToString(encrypted); + } + + private static byte[] decodeFromBase64(String encoded) { + return Base64.getDecoder().decode(encoded); + } + + /** Get unique and unpredictable initialized vector. */ + private static byte[] getUniqueAndTrulyRandomInitVector() { + var initVector = new byte[AesEncryptionProviderImpl.cryptoIvLengthInBits / 8]; + new SecureRandom().nextBytes(initVector); + return initVector; + } + + /** This is used to hash our secret key with salting to a specified length. */ + private static byte[] getArgon2Hash(String value, String salt, int lengthInBits) { + + final var parallelism = 4; + final var memoryInKb = 19 * 1024; + final var iterations = 1; + var hash = new byte[lengthInBits]; + + var params = + new Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withParallelism(parallelism) + .withMemoryAsKB(memoryInKb) + .withIterations(iterations) + .withSalt(salt.getBytes()) + .build(); + + var generator = new Argon2BytesGenerator(); + generator.init(params); + generator.generateBytes(value.toCharArray(), hash); + + return hash; + } + + @Override + public String encrypt(String plainText) throws CryptException { + + try { + // Get an unique unpredictable vector. + var initVector = getUniqueAndTrulyRandomInitVector(); + + // Get an initialized cipher. + var cipher = getInitializedCipher(CipherMode.ENCRYPT_MODE, initVector); + + // Encrypt our string. + var cipheredText = cipher.doFinal(plainText.getBytes()); + + // Prepend our vector along ciphered text. + var vectorAndCipheredText = ByteBuffer.allocate(initVector.length + cipheredText.length); + + vectorAndCipheredText.put(initVector); + vectorAndCipheredText.put(cipheredText); + + // Encode the whole. + return encodeToBase64(vectorAndCipheredText.array()); + } catch (Exception e) { + throw new CryptException(e.getMessage(), e); + } + } + + @Override + public String decrypt(String encodedAndCiphered) throws CryptException { + + try { + // Decode the whole. + var vectorAndCipheredText = decodeFromBase64(encodedAndCiphered); + + // Retrieve the vector used to encrypt. + var initVector = Arrays.copyOfRange(vectorAndCipheredText, 0, cryptoIvLengthInBits / 8); + + // Retrieve the encrypted string. + var cipheredText = + Arrays.copyOfRange( + vectorAndCipheredText, cryptoIvLengthInBits / 8, vectorAndCipheredText.length); + + // Get an initialized cipher. + var cipher = getInitializedCipher(CipherMode.DECRYPT_MODE, initVector); + + // Decrypt our string. + var plainText = cipher.doFinal(cipheredText); + + return new String(plainText, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new CryptException(e.getMessage(), e); + } + } + + /** Get an initialized cipher using an initialized vector. */ + private Cipher getInitializedCipher(CipherMode cipherMode, byte[] initVector) + throws NoSuchPaddingException, + NoSuchAlgorithmException, + InvalidAlgorithmParameterException, + InvalidKeyException { + + /* + TL;DR: Padding is part of the specification of the mode and thus doesn't need to be done by + the user of the primitive. + @see https://crypto.stackexchange.com/questions/42412/gcm-padding-or-not + */ + var cipher = Cipher.getInstance(cipherAlgorithm); + + /* + Authentication tag is used to verify that the resulting ciphertext has not been tampered with + during the decryption process. 128-bit is the recommended configuration when using the GCM mode + for AES encryption. + @see https://smattme.com/posts/how-to-encrypt-decrypt-aes-in-java + */ + AlgorithmParameterSpec parameterSpec = + new GCMParameterSpec(cryptoAuthTagLengthInBits, initVector); + cipher.init(cipherMode.mode(), secretKey, parameterSpec); + return cipher; + } + + /** Private local cipher mode. */ + private enum CipherMode { + ENCRYPT_MODE(Cipher.ENCRYPT_MODE), + DECRYPT_MODE(Cipher.DECRYPT_MODE); + + private final int mode; + + CipherMode(int mode) { + this.mode = mode; + } + + public int mode() { + return mode; + } + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/Argon2HashProviderImpl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/Argon2HashProviderImpl.java new file mode 100644 index 0000000..59e0294 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/Argon2HashProviderImpl.java @@ -0,0 +1,124 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Base64; +import java.util.List; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.valueobject.concepts.Hash; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +@ApplicationScoped +public class Argon2HashProviderImpl implements HashProvider { + /* @see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html */ + private static final int SALT_LENGTH = 16; + private static final int HASH_LENGTH = 32; + private static final int PARALLELISM = 1; + private static final int MEMORY_IN_KB = 12288; + private static final int ITERATIONS = 3; + private final Argon2PasswordEncoder encoder; + + public Argon2HashProviderImpl() { + this.encoder = + new Argon2PasswordEncoder(SALT_LENGTH, HASH_LENGTH, PARALLELISM, MEMORY_IN_KB, ITERATIONS); + } + + @Override + public H hash(Class type, String value) { + var encodedString = encoder.encode(value); + + if (type == PasswordHash.class) { + return type.cast(new PasswordHash(encodedString)); + } + throw new IllegalArgumentException("Unsupported hash type: '%s'".formatted(type)); + } + + @Override + public boolean check(Hash hash) { + try { + Argon2DecoderVerifier.assertHashIsCorrectlyFormatted(hash.getValue()); + return true; + } catch (Argon2HashingException e) { + return false; + } + } + + @Override + public boolean areEquals(Hash hash, String value) { + return this.encoder.matches(value, hash.getValue()); + } + + /** Private Local Exception. */ + private static class Argon2HashingException extends Exception { + Argon2HashingException(String message) { + super(message); + } + } + + /** + * Private verifier based on {@link org.springframework.security.crypto.argon2 + * Argon2EncodingUtils}. + */ + private static class Argon2DecoderVerifier { + public static void assertHashIsCorrectlyFormatted(String hashToCheck) + throws Argon2HashingException { + var parts = hashToCheck.split("\\$"); + + // Minimal parts length is 5, 6 with version + if (parts.length < 5) { + throw new Argon2HashingException("Invalid encoded Argon2-hash"); + } + + var hasVersion = parts[2].startsWith("v="); + + var currentPart = 1; + + // Check algo used should exist + var validAlgos = List.of("argon2d", "argon2i", "argon2id"); + if (!validAlgos.contains(parts[currentPart++])) { + throw new Argon2HashingException("Invalid algorithm type: '%s'".formatted(parts[1])); + } + + // Check version part + if (hasVersion && parts.length < 6) { + throw new Argon2HashingException("Invalid encoded Argon2-hash"); + } + currentPart = hasVersion ? currentPart + 1 : currentPart; + + // Check performance params + var performanceParams = parts[currentPart++].split(","); + if (performanceParams.length != 3) { + throw new Argon2HashingException("Amount of performance parameters invalid"); + } + assertPerformanceParameter("memory", 'm', performanceParams[0]); + assertPerformanceParameter("iterations", 't', performanceParams[1]); + assertPerformanceParameter("parallelism", 'p', performanceParams[2]); + + assertBinaryChunkIsValid("salt", parts[currentPart++]); + assertBinaryChunkIsValid("secret", parts[currentPart]); + } + + private static void assertPerformanceParameter(String name, char prefix, String parameter) + throws Argon2HashingException { + if (!parameter.startsWith("%s=".formatted(prefix))) { + throw new Argon2HashingException("Invalid %s parameter".formatted(name)); + } + try { + Integer.parseInt(parameter.substring(2)); + } catch (NumberFormatException e) { + throw new Argon2HashingException("Invalid %s parameter".formatted(name)); + } + } + + private static void assertBinaryChunkIsValid(String name, String part) + throws Argon2HashingException { + try { + if (Base64.getDecoder().decode(part).length == 0) { + throw new Argon2HashingException("Invalid %s parameter".formatted(name)); + } + } catch (IllegalArgumentException e) { + throw new Argon2HashingException("Invalid %s parameter".formatted(name)); + } + } + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/CsvEditorImpl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/CsvEditorImpl.java new file mode 100644 index 0000000..c0b9afe --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/CsvEditorImpl.java @@ -0,0 +1,42 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import jakarta.enterprise.context.ApplicationScoped; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.util.function.Function; +import org.apache.commons.csv.CSVFormat; +import tech.pegase.z3.domain.port.service.CsvEditor; +import tech.pegase.z3.domain.valueobject.Target; + +@ApplicationScoped +public class CsvEditorImpl implements CsvEditor { + + @Override + public InputStream doMagic(InputStream csvFile, Function getSource) { + try { + var reader = new InputStreamReader(csvFile); + var parser = CSVFormat.DEFAULT.parse(reader); + + var outputStream = new ByteArrayOutputStream(); + + var writer = new OutputStreamWriter(outputStream); + var printer = CSVFormat.DEFAULT.print(writer); + + for (var record : parser) { + var target = new Target(record.get(0)); + var shortUrl = getSource.apply(target); + printer.printRecord(target.value(), shortUrl); + } + + // create input stream from writer + writer.flush(); + + return new ByteArrayInputStream(outputStream.toByteArray()); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/HttpUrlCheckerImpl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/HttpUrlCheckerImpl.java new file mode 100644 index 0000000..0cacf5c --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/HttpUrlCheckerImpl.java @@ -0,0 +1,88 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; +import tech.pegase.z3.domain.port.service.UrlChecker; + +@ApplicationScoped +public class HttpUrlCheckerImpl implements UrlChecker { + private static final Logger logger = Logger.getLogger(HttpUrlCheckerImpl.class); + private static final String HTTP_METHOD = "HEAD"; + private static final String USER_AGENT_PREFIX = "Z3-checker"; + private final String userAgent; + + public HttpUrlCheckerImpl(@ConfigProperty(name = "quarkus.application.version") String version) { + + if (version.isBlank()) { + throw new IllegalArgumentException("version is required"); + } + + userAgent = String.format("%s (%s)", USER_AGENT_PREFIX, version); + } + + @Override + public Uni verify(URL url, boolean assertIsHttps) { + return Uni.createFrom() + .deferred( + () -> { + try { + var code = getResponseCodeFrom(url); + + return switch (code) { + case HttpURLConnection.HTTP_OK, + HttpURLConnection.HTTP_MOVED_TEMP, + HttpURLConnection.HTTP_MOVED_PERM -> + Uni.createFrom().item(true); + // TEMP MOVED but use same method + case 307 -> Uni.createFrom().item(true); + // PERM MOVED but use same method + case 308 -> Uni.createFrom().item(true); + default -> Uni.createFrom().item(false); + }; + } catch (UrlconnectionException e) { + return Uni.createFrom().item(false); + } + }); + } + + private int getResponseCodeFrom(URL url) throws UrlconnectionException { + HttpURLConnection urlConnection = null; + try { + var connection = url.openConnection(); + + if (!(connection instanceof HttpURLConnection)) { + logger.debug("Url connection has to be https://, given target is: '%s'".formatted(url)); + throw new UrlconnectionException(); + } + + urlConnection = (HttpURLConnection) connection; + urlConnection.setRequestMethod(HTTP_METHOD); + urlConnection.setRequestProperty("User-Agent", userAgent); + urlConnection.connect(); + + var code = urlConnection.getResponseCode(); + logger.debug("Got '%d' response code calling '%s'".formatted(code, url)); + + return code; + } catch (SocketTimeoutException e) { + logger.debug("Initiating connection to '%s' timed out".formatted(url), e); + throw new UrlconnectionException(); + } catch (IOException e) { + logger.debug("Could not open a connection to '%s'".formatted(url), e); + throw new UrlconnectionException(); + } finally { + if (urlConnection != null) { + urlConnection.disconnect(); + } + } + } + + /** Local exception for connection. */ + private static class UrlconnectionException extends Exception {} +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/JwtGeneratorImpl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/JwtGeneratorImpl.java new file mode 100644 index 0000000..d4e5b2c --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/JwtGeneratorImpl.java @@ -0,0 +1,29 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import io.smallrye.jwt.build.Jwt; +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import tech.pegase.z3.domain.entity.User; +import tech.pegase.z3.domain.port.service.JwtGenerator; +import tech.pegase.z3.domain.valueobject.JwtToken; + +@ApplicationScoped +public class JwtGeneratorImpl implements JwtGenerator { + private final String jwtIssuer; + + JwtGeneratorImpl(@ConfigProperty(name = "mp.jwt.verify.issuer") String jwtIssuer) { + this.jwtIssuer = jwtIssuer; + } + + @Override + public JwtToken generateJwt(User user) { + return new JwtToken( + Jwt.issuer(jwtIssuer) + .subject(user.getUuid().toString()) + .groups(user.getRole().value().toString()) + .expiresAt(Instant.now().plus(8, ChronoUnit.HOURS)) + .sign()); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/PasswordGeneratorImpl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/PasswordGeneratorImpl.java new file mode 100644 index 0000000..a7e1294 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/PasswordGeneratorImpl.java @@ -0,0 +1,67 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import jakarta.enterprise.context.ApplicationScoped; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Collections; +import tech.pegase.z3.domain.port.service.PasswordGenerator; + +@ApplicationScoped +public class PasswordGeneratorImpl implements PasswordGenerator { + + private static final String CHAR_LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; + private static final String CHAR_UPPERCASE = CHAR_LOWERCASE.toUpperCase(); + private static final String CHAR_DIGIT = "0123456789"; + private static final String CHAR_SPECIAL = "!@#$%^"; + private static final SecureRandom random = new SecureRandom(); + + private static String generateRandomString(String input, int size) { + + if (size < 1) { + throw new IllegalArgumentException("Invalid size."); + } + + var result = new StringBuilder(size); + + for (int i = 0; i < size; i++) { + int index = random.nextInt(input.length()); + result.append(input.charAt(index)); + } + + return result.toString(); + } + + private static String shuffleString(String input) { + var result = Arrays.asList(input.split("")); + + Collections.shuffle(result); + + return String.join("", result); + } + + @Override + public String generatePassword(int length) { + + if (length < 8) { + throw new IllegalArgumentException("Length must be at least 8."); + } + + var upperLength = Math.ceilDiv(length, 5); + length -= upperLength; + var lowerLength = Math.ceilDiv(length, 5); + length -= lowerLength; + var digitLength = Math.ceilDiv(length, 5); + length -= digitLength; + var specialLength = Math.ceilDiv(length, 5); + length -= specialLength; + + var password = + generateRandomString(CHAR_UPPERCASE, upperLength) + + generateRandomString(CHAR_LOWERCASE, lowerLength) + + generateRandomString(CHAR_DIGIT, digitLength) + + generateRandomString(CHAR_SPECIAL, specialLength) + + generateRandomString(CHAR_LOWERCASE, length); + + return shuffleString(password); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/RandomSourceGeneratorBase32Impl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/RandomSourceGeneratorBase32Impl.java new file mode 100644 index 0000000..d5ee8ce --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/RandomSourceGeneratorBase32Impl.java @@ -0,0 +1,21 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import jakarta.enterprise.context.ApplicationScoped; +import java.math.BigInteger; +import java.util.UUID; +import tech.pegase.z3.domain.port.service.RandomSourceGenerator; +import tech.pegase.z3.shared.valueobject.Source; + +@ApplicationScoped +public class RandomSourceGeneratorBase32Impl implements RandomSourceGenerator { + @Override + public Source generate() { + var base16 = UUID.randomUUID().toString().replace("-", ""); + var base32 = base16ToBase32(base16); + return new Source(base32); + } + + private String base16ToBase32(String base16) { + return new BigInteger(base16, 16).toString(32); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/SlugifyierImpl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/SlugifyierImpl.java new file mode 100644 index 0000000..8cefbed --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/SlugifyierImpl.java @@ -0,0 +1,33 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import com.github.slugify.Slugify; +import jakarta.enterprise.context.ApplicationScoped; +import tech.pegase.z3.domain.port.service.Slugifyier; +import tech.pegase.z3.domain.valueobject.Slug; + +@ApplicationScoped +public class SlugifyierImpl implements Slugifyier { + + @Override + public Slug slugify(String value) throws IllegalArgumentException { + String sluggedValue; + + if (value.isBlank()) { + throw new IllegalArgumentException("Value cannot be empty"); + } + + sluggedValue = + Slugify.builder() + .underscoreSeparator(false) + .customReplacement("_", "-") + .transliterator(true) + .build() + .slugify(value); + + if (sluggedValue.isBlank()) { + throw new IllegalArgumentException("Slugged value cannot be empty"); + } + + return Slug.of(sluggedValue); + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/SmtpMessageSenderImpl.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/SmtpMessageSenderImpl.java new file mode 100644 index 0000000..d973eb6 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/SmtpMessageSenderImpl.java @@ -0,0 +1,29 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.Mailer; +import jakarta.enterprise.context.ApplicationScoped; +import org.jboss.logging.Logger; +import tech.pegase.z3.domain.port.service.MessageSender; +import tech.pegase.z3.domain.valueobject.Message; + +@ApplicationScoped +public class SmtpMessageSenderImpl implements MessageSender { + private static final Logger logger = Logger.getLogger(SmtpMessageSenderImpl.class); + private final Mailer mailer; + + SmtpMessageSenderImpl(Mailer mailer) { + this.mailer = mailer; + } + + @Override + public Status send(Message message) { + try { + mailer.send(Mail.withText(message.to().value(), message.subject(), message.message())); + return new Status.Success(); + } catch (Exception e) { + logger.warn("Failed to send message", e); + return new Status.Failed(); + } + } +} diff --git a/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/package-info.java b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/package-info.java new file mode 100644 index 0000000..abfa526 --- /dev/null +++ b/z3-infrastructure/src/main/java/tech/pegase/z3/infrastructure/adapter/service/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.infrastructure.adapter.service; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-infrastructure/src/main/resources/META-INF/beans.xml b/z3-infrastructure/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000..e69de29 diff --git a/z3-infrastructure/src/main/resources/application.properties b/z3-infrastructure/src/main/resources/application.properties new file mode 100644 index 0000000..2e604a0 --- /dev/null +++ b/z3-infrastructure/src/main/resources/application.properties @@ -0,0 +1,3 @@ +z3.infra.aes.key=${AES_KEY:password} +z3.infra.aes.salt=${AES_SALT:salt} +z3.infra.aes.key-length=${AES_KEY_LENGTH:32} \ No newline at end of file diff --git a/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/AesEncryptionProviderImplTest.java b/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/AesEncryptionProviderImplTest.java new file mode 100644 index 0000000..372bd33 --- /dev/null +++ b/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/AesEncryptionProviderImplTest.java @@ -0,0 +1,73 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.port.service.EncryptionProvider; +import tech.pegase.z3.domain.port.service.EncryptionProvider.CryptException; + +public class AesEncryptionProviderImplTest { + + @Test + public void canEncryptString() { + Assertions.assertDoesNotThrow( + () -> getProvider("qwerty", "salt", 128).encrypt("je suis un password")); + Assertions.assertDoesNotThrow( + () -> getProvider("qwerty", "salt", 192).encrypt("je suis un password")); + Assertions.assertDoesNotThrow( + () -> getProvider("qwerty", "salt", 256).encrypt("je suis un password")); + } + + @Test + public void canDecryptString() throws CryptException { + Assertions.assertEquals( + "je suis un password", + getProvider("qwerty", "salt", 128) + .decrypt("sZzfp1F/Afq5/UnLWzx+W9mjKcBGpMTG2LRP8MdfLEAIEP0a2gihORQGb24sGNc")); + Assertions.assertEquals( + "je suis un password", + getProvider("qwerty", "salt", 192) + .decrypt("fYabjIDfVwoANWOxU0Jt1gZNQUz2IyKClwfD2BFqqUeGYWsIoW9uZc6nJTIjfvo")); + Assertions.assertEquals( + "je suis un password", + getProvider("qwerty", "salt", 256) + .decrypt("pquf/ZVxbxKESAqyfOjTfqT2JEKpAs4zamQcYLQtPukCP4RO+YnOF7XaqF2NdtI")); + } + + @Test + public void canEncryptOrDecryptEmptyStrings() throws CryptException { + Assertions.assertDoesNotThrow(() -> getProvider("qwerty", "salt", 128).encrypt("")); + Assertions.assertEquals( + "", + getProvider("qwerty", "salt", 128).decrypt(getProvider("qwerty", "salt", 128).encrypt(""))); + } + + @Test + public void canEncryptAndDecryptString() throws CryptException { + Assertions.assertEquals( + "je suis encore un password", + getProvider("azerty", "pepper", 128) + .decrypt(getProvider("azerty", "pepper", 128).encrypt("je suis encore un password"))); + Assertions.assertEquals( + "je suis encore un password", + getProvider("azerty", "pepper", 192) + .decrypt(getProvider("azerty", "pepper", 192).encrypt("je suis encore un password"))); + Assertions.assertEquals( + "je suis encore un password", + getProvider("azerty", "pepper", 256) + .decrypt(getProvider("azerty", "pepper", 256).encrypt("je suis encore un password"))); + } + + @Test + public void cannotGetProviderWithInvalidConfig() { + Assertions.assertThrows(NullPointerException.class, () -> getProvider(null, "pepper", 256)); + Assertions.assertThrows(IllegalArgumentException.class, () -> getProvider("", "pepper", 256)); + Assertions.assertThrows(NullPointerException.class, () -> getProvider("azerty", null, 256)); + Assertions.assertThrows(IllegalArgumentException.class, () -> getProvider("azerty", "", 256)); + Assertions.assertThrows( + IllegalArgumentException.class, () -> getProvider("azerty", "pepper", 123)); + } + + private EncryptionProvider getProvider(String secret, String salt, int keyLength) { + return new AesEncryptionProviderImpl(secret, salt, keyLength); + } +} diff --git a/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/Argon2HashProviderImplTest.java b/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/Argon2HashProviderImplTest.java new file mode 100644 index 0000000..b94343a --- /dev/null +++ b/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/Argon2HashProviderImplTest.java @@ -0,0 +1,55 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.domain.port.service.HashProvider; +import tech.pegase.z3.domain.valueobject.hashes.PasswordHash; + +public class Argon2HashProviderImplTest { + @Test + public void canCreateProvider() { + Assertions.assertDoesNotThrow(this::createProvider); + } + + @Test + public void canCreateHash() { + var provider = createProvider(); + Assertions.assertDoesNotThrow( + () -> { + provider.hash(PasswordHash.class, ""); + provider.hash(PasswordHash.class, "Any string to be hashed"); + }); + Assertions.assertEquals(PasswordHash.class, provider.hash(PasswordHash.class, "").getClass()); + } + + @Test + public void canCheckHashIsValid() { + var provider = createProvider(); + Assertions.assertDoesNotThrow( + () -> + provider.check( + PasswordHash.of("$argon2id$v=19$m=16,t=2,p=1$YXNkYXNkd3c$+ODsEqT2m/UjTDO5uh4WPw"))); + Assertions.assertFalse( + provider.check(PasswordHash.of("$asd$as$dadasd")), + "Checking Argon2 hash should be false using a wrong hash"); + Assertions.assertTrue( + provider.check( + PasswordHash.of( + "$argon2id$v=19$m=60000,t=10,p=1$uIY2pGtOoYznSARSFQ0Ddg$eSUmcOVHsz8boW8bOme/YaIR3mvazNvYsCrApKIngwI")), + "Checking Argon2 hash should be true using a correct hash"); + } + + @Test + public void canCheckHashEquality() { + var provider = createProvider(); + + Assertions.assertTrue( + provider.areEquals(provider.hash(PasswordHash.class, "qwerty"), "qwerty")); + Assertions.assertFalse( + provider.areEquals(provider.hash(PasswordHash.class, "azerty"), "qwerty")); + } + + private HashProvider createProvider() { + return new Argon2HashProviderImpl(); + } +} diff --git a/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/HttpUrlCheckerImplTest.java b/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/HttpUrlCheckerImplTest.java new file mode 100644 index 0000000..95e99b2 --- /dev/null +++ b/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/HttpUrlCheckerImplTest.java @@ -0,0 +1,90 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import java.net.HttpURLConnection; +import java.net.URL; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +public class HttpUrlCheckerImplTest { + @Test + public void canInstantiateHttpUrlChecker() { + Assertions.assertDoesNotThrow(this::getNewUrlChecker); + } + + @Test + public void validUrlIsAccepted() throws Exception { + // Arrange + var urlChecker = getNewUrlChecker(); + + // Act + var output = urlChecker.verify(getNewURLWithWantedCode(HttpURLConnection.HTTP_OK)); + + // Assert + Assertions.assertTrue(output.await().indefinitely(), "Valid target should be accepted"); + } + + @Test + public void permanentRedirectionUrlIsAccepted() throws Exception { + // Arrange + var urlChecker = getNewUrlChecker(); + + // Act + var output = urlChecker.verify(getNewURLWithWantedCode(HttpURLConnection.HTTP_MOVED_PERM)); + + // Assert + Assertions.assertTrue( + output.await().indefinitely(), "Permanent redirections should be accepted"); + } + + @Test + public void tempRedirectionUrlIsAccepted() throws Exception { + // Arrange + var urlChecker = getNewUrlChecker(); + + // Act + var output = urlChecker.verify(getNewURLWithWantedCode(HttpURLConnection.HTTP_MOVED_TEMP)); + + // Assert + Assertions.assertTrue(output.await().indefinitely(), "Temp redirections should be accepted"); + } + + @Test + public void permRedirectionSameSchemeUrlIsAccepted() throws Exception { + // Arrange + var urlChecker = getNewUrlChecker(); + + // Act + var output = urlChecker.verify(getNewURLWithWantedCode(308)); + + // Assert + Assertions.assertTrue( + output.await().indefinitely(), + "Permanent redirections with same scheme should be accepted"); + } + + @Test + public void tempRedirectionSameSchemeUrlIsAccepted() throws Exception { + // Arrange + var urlChecker = getNewUrlChecker(); + + // Act + var output = urlChecker.verify(getNewURLWithWantedCode(307)); + + // Assert + Assertions.assertTrue( + output.await().indefinitely(), "Temp redirections with same scheme should be accepted"); + } + + private URL getNewURLWithWantedCode(int wantedCode) throws Exception { + var url = Mockito.mock(URL.class); + var urlConnection = Mockito.mock(HttpURLConnection.class); + Mockito.when(url.openConnection()).thenReturn(urlConnection); + Mockito.when(urlConnection.getResponseCode()).thenReturn(wantedCode); + return url; + } + + private HttpUrlCheckerImpl getNewUrlChecker() { + return new HttpUrlCheckerImpl("1.0"); + } +} diff --git a/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/SmtpMessageSenderImplTest.java b/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/SmtpMessageSenderImplTest.java new file mode 100644 index 0000000..66e2643 --- /dev/null +++ b/z3-infrastructure/src/test/java/tech/pegase/z3/infrastructure/adapter/service/SmtpMessageSenderImplTest.java @@ -0,0 +1,55 @@ +package tech.pegase.z3.infrastructure.adapter.service; + +import io.quarkus.mailer.Mail; +import io.quarkus.mailer.Mailer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.domain.port.service.MessageSender; +import tech.pegase.z3.domain.valueobject.Email; +import tech.pegase.z3.domain.valueobject.Message; + +public class SmtpMessageSenderImplTest { + private final SmtpMessageSenderImpl smtpMessageSenderImpl; + private final Mailer mailer; + + SmtpMessageSenderImplTest() { + this.mailer = Mockito.mock(Mailer.class); + this.smtpMessageSenderImpl = new SmtpMessageSenderImpl(mailer); + } + + @Test + public void canSendMessage() { + // Arrange + var message = getNewMessage(); + Mockito.doNothing().when(mailer).send(Mockito.any(Mail.class)); + + // Act + var output = smtpMessageSenderImpl.send(message); + + // Assert + Mockito.verify(mailer).send(Mockito.any(Mail.class)); + Assertions.assertInstanceOf( + MessageSender.Status.Success.class, output, "message can effectively be sent"); + } + + @Test + public void canSendingMessageCanFail() { + // Arrange + var message = getNewMessage(); + Mockito.doThrow(RuntimeException.class).when(mailer).send(Mockito.any(Mail.class)); + + // Act + var output = smtpMessageSenderImpl.send(message); + + // Assert + Mockito.verify(mailer).send(Mockito.any(Mail.class)); + Assertions.assertInstanceOf( + MessageSender.Status.Failed.class, output, "message can sending can fail"); + } + + private Message getNewMessage() { + return new Message( + Email.of("user@example.net"), "New important message", "This is an actual message"); + } +} diff --git a/z3-infrastructure/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/z3-infrastructure/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/z3-infrastructure/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file diff --git a/z3-shared/pom.xml b/z3-shared/pom.xml new file mode 100644 index 0000000..4f2f80e --- /dev/null +++ b/z3-shared/pom.xml @@ -0,0 +1,120 @@ + + + z3-shared + + + + + jacoco-maven-plugin + + + + + + true + ${project.build.directory}/coverage-reports/jacoco-ut.exec + surefire.jacoco.args + + + prepare-agent + + pre-unit-test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + ${project.build.directory}/coverage-jacoco/unit-tests + + + + report + + post-unit-test + test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + + + BUNDLE + + + LINE + 0.80 + COVEREDRATIO + + + + + + + check + + jacoco-check-ut + verify + + + + org.jacoco + ${jacoco.version} + + + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + ${surefire.jacoco.args} -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + + + + **/*IT.java + + + org.jboss.logmanager.LogManager + ${maven.home} + + + ${surefire-plugin.version} + + + + + + + quarkus-junit5-mockito + io.quarkus + test + + + quarkus-jacoco + io.quarkus + test + + + jakarta.persistence-api + jakarta.persistence + + + + 4.0.0 + + + z3 + tech.pegase.z3 + 1.1.0-SNAPSHOT + + + + 22 + 22 + UTF-8 + + + \ No newline at end of file diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/annotations/ExcludeFromJacocoGeneratedReport.java b/z3-shared/src/main/java/tech/pegase/z3/shared/annotations/ExcludeFromJacocoGeneratedReport.java new file mode 100644 index 0000000..d24f07b --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/annotations/ExcludeFromJacocoGeneratedReport.java @@ -0,0 +1,12 @@ +package tech.pegase.z3.shared.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR}) +public @interface ExcludeFromJacocoGeneratedReport {} diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyCollectors.java b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyCollectors.java new file mode 100644 index 0000000..3464b69 --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyCollectors.java @@ -0,0 +1,78 @@ +package tech.pegase.z3.shared.lib; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collector; + +public class ReadOnlyCollectors { + public static Collector, ReadOnlyList> toReadOnlyList() { + return new Collector<>() { + @Override + public Supplier> supplier() { + return ArrayList::new; + } + + @Override + public BiConsumer, T> accumulator() { + return List::add; + } + + @Override + public BinaryOperator> combiner() { + return (list1, list2) -> { + list1.addAll(list2); + return list1; + }; + } + + @Override + public Function, ReadOnlyList> finisher() { + return ReadOnlyList::new; + } + + @Override + public Set characteristics() { + return Collections.emptySet(); + } + }; + } + + public static Collector, ReadOnlySet> toReadOnlySet() { + return new Collector<>() { + @Override + public Supplier> supplier() { + return HashSet::new; + } + + @Override + public BiConsumer, T> accumulator() { + return Set::add; + } + + @Override + public BinaryOperator> combiner() { + return (set1, set2) -> { + set1.addAll(set2); + return set1; + }; + } + + @Override + public Function, ReadOnlySet> finisher() { + return ReadOnlySet::new; + } + + @Override + public Set characteristics() { + return Collections.emptySet(); + } + }; + } +} diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyList.java b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyList.java new file mode 100644 index 0000000..a756334 --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyList.java @@ -0,0 +1,131 @@ +package tech.pegase.z3.shared.lib; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; + +public final class ReadOnlyList implements List { + private final List unmodifiableList; + + public ReadOnlyList(List unmodifiableList) { + this.unmodifiableList = Collections.unmodifiableList(unmodifiableList); + } + + @Override + public int size() { + return unmodifiableList.size(); + } + + @Override + public boolean isEmpty() { + return unmodifiableList.isEmpty(); + } + + @Override + public boolean contains(Object o) { + return unmodifiableList.contains(o); + } + + @Override + public Iterator iterator() { + return unmodifiableList.iterator(); + } + + @Override + public Object[] toArray() { + return unmodifiableList.toArray(); + } + + @Override + public T[] toArray(T[] a) { + return unmodifiableList.toArray(a); + } + + @Override + public boolean add(V v) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public void add(int index, V element) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object o) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public V remove(int index) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean containsAll(Collection c) { + return new HashSet<>(unmodifiableList).containsAll(c); + } + + @Override + public boolean addAll(Collection c) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean addAll(int index, Collection c) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeAll(Collection c) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retainAll(Collection c) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public V get(int index) { + return unmodifiableList.get(index); + } + + @Override + public V set(int index, V element) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public int indexOf(Object o) { + return unmodifiableList.indexOf(o); + } + + @Override + public int lastIndexOf(Object o) { + return unmodifiableList.lastIndexOf(o); + } + + @Override + public ListIterator listIterator() { + return unmodifiableList.listIterator(); + } + + @Override + public ListIterator listIterator(int index) { + return unmodifiableList.listIterator(index); + } + + @Override + public List subList(int fromIndex, int toIndex) { + return unmodifiableList.subList(fromIndex, toIndex); + } +} diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyMap.java b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyMap.java new file mode 100644 index 0000000..58ac70e --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlyMap.java @@ -0,0 +1,70 @@ +package tech.pegase.z3.shared.lib; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +public final class ReadOnlyMap implements Map { + private final Map readOnlyMap; + + public ReadOnlyMap(Map readOnlyMap) { + this.readOnlyMap = Collections.unmodifiableMap(readOnlyMap); + } + + @Override + public int size() { + return readOnlyMap.size(); + } + + @Override + public boolean isEmpty() { + return readOnlyMap.isEmpty(); + } + + @Override + public boolean containsKey(Object key) { + return readOnlyMap.containsKey(key); + } + + @Override + public boolean containsValue(Object value) { + return readOnlyMap.containsValue(value); + } + + public V get(Object key) { + return readOnlyMap.get(key); + } + + @Override + public V put(K key, V value) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public V remove(Object key) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public void putAll(Map m) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() {} + + @Override + public ReadOnlySet keySet() { + return new ReadOnlySet<>(readOnlyMap.keySet()); + } + + @Override + public Collection values() { + return readOnlyMap.values(); + } + + @Override + public ReadOnlySet> entrySet() { + return new ReadOnlySet<>(readOnlyMap.entrySet()); + } +} diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlySet.java b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlySet.java new file mode 100644 index 0000000..cd1e8bc --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/ReadOnlySet.java @@ -0,0 +1,79 @@ +package tech.pegase.z3.shared.lib; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.Set; + +public final class ReadOnlySet implements Set { + private final Set unmodifiableSet; + + public ReadOnlySet(Set unmodifiableSet) { + this.unmodifiableSet = Collections.unmodifiableSet(unmodifiableSet); + } + + @Override + public int size() { + return unmodifiableSet.size(); + } + + @Override + public boolean isEmpty() { + return unmodifiableSet.isEmpty(); + } + + @Override + public boolean contains(Object o) { + return unmodifiableSet.contains(o); + } + + @Override + public Iterator iterator() { + return unmodifiableSet.iterator(); + } + + @Override + public Object[] toArray() { + return unmodifiableSet.toArray(); + } + + @Override + public T[] toArray(T[] a) { + return unmodifiableSet.toArray(a); + } + + @Override + public boolean add(V v) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean remove(Object o) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean containsAll(Collection c) { + return unmodifiableSet.containsAll(c); + } + + @Override + public boolean addAll(Collection c) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean retainAll(Collection c) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public boolean removeAll(Collection c) throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() throws UnsupportedOperationException { + throw new UnsupportedOperationException(); + } +} diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/lib/package-info.java b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/package-info.java new file mode 100644 index 0000000..26e5fc5 --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/lib/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.shared.lib; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/pagination/PaginatedData.java b/z3-shared/src/main/java/tech/pegase/z3/shared/pagination/PaginatedData.java new file mode 100644 index 0000000..9db72fe --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/pagination/PaginatedData.java @@ -0,0 +1,52 @@ +package tech.pegase.z3.shared.pagination; + +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collector; +import java.util.stream.Stream; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record PaginatedData(List items, long perPage, long offset, long total) { + public PaginatedData(List items, long perPage, long offset, long total) { + Objects.requireNonNull(items, "Items cannot be null"); + + this.items = List.copyOf(items); + this.perPage = perPage; + this.offset = offset; + this.total = total; + } + + public static PaginatedData empty() { + return new PaginatedData<>(List.of(), 50, 0, 0); + } + + public static PaginatedData wrap(List items, long perPage, long offset, long total) { + return new PaginatedData<>(items, perPage, offset, total); + } + + public boolean fullContent() { + return items.size() == total; + } + + public PaginatedData map(Function mapFunction) { + var items = this.items.stream().map(mapFunction).toList(); + return new PaginatedData<>(items, this.perPage, this.offset, this.total); + } + + public PaginatedData flatMap(Function> mapper) { + var items = this.items.stream().flatMap(mapper).toList(); + return new PaginatedData<>(items, this.perPage, this.offset, this.total); + } + + public PaginatedData filter(Predicate predicate) { + var items = this.items.stream().filter(predicate).toList(); + return new PaginatedData<>(items, this.perPage, this.offset, this.total); + } + + public R collect(Collector collector) { + return this.items.stream().collect(collector); + } +} diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/pagination/Pagination.java b/z3-shared/src/main/java/tech/pegase/z3/shared/pagination/Pagination.java new file mode 100644 index 0000000..0ba5207 --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/pagination/Pagination.java @@ -0,0 +1,59 @@ +package tech.pegase.z3.shared.pagination; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public sealed interface Pagination { + static Pagination fromStartAndEndInclusive(int start, int end) { + return new Paginated(start, end - start + 1); + } + + static Pagination fromOffsetAndLimit(int offset, int limit) { + return new Paginated(offset, limit); + } + + static Pagination fromPageNumberAndPageSize(int pageNumber, int pageSize) { + var offset = (pageNumber - 1) * pageSize; + return new Paginated(offset, pageSize); + } + + static Pagination none() { + return new None(); + } + + int offset(); + + int limit(); + + record None() implements Pagination { + @Override + public int offset() { + return 0; + } + + @Override + public int limit() { + return 0; + } + } + + record Paginated(int offset, int limit) implements Pagination { + public Paginated { + final var securityLimit = 50; + + if (limit > securityLimit) { + throw new IllegalArgumentException( + "Limit cannot exceed '%d' given is '%d'".formatted(securityLimit, limit)); + } + } + + public int lastIndex() { + return offset + limit - 1; + } + + @Override + public int limit() { + return limit; + } + } +} diff --git a/z3-shared/src/main/java/tech/pegase/z3/shared/valueobject/Source.java b/z3-shared/src/main/java/tech/pegase/z3/shared/valueobject/Source.java new file mode 100644 index 0000000..f555157 --- /dev/null +++ b/z3-shared/src/main/java/tech/pegase/z3/shared/valueobject/Source.java @@ -0,0 +1,63 @@ +package tech.pegase.z3.shared.valueobject; + +import jakarta.persistence.Embeddable; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Pattern; + +@Embeddable +public record Source(String value) implements Comparable { + + @SuppressWarnings("ConstantConditions") + public Source { + if (value == null) { + throw new IllegalStateException("la source ne peut être vide"); + } + if (startsWithFqdn(value)) { + throw new IllegalStateException( + "la source ne peut contenir une url complète, seulement la partie chemin"); + } + try { + var uri = new URI(value); + var scheme = uri.getScheme(); + var host = uri.getHost(); + var port = uri.getPort(); + var path = uri.getPath(); + var query = uri.getQuery(); + var fragment = uri.getFragment(); + var userInfo = uri.getUserInfo(); + if (path == null || path.isBlank()) { + throw new IllegalStateException("la source nécessite d'avoir un chemin d'url valide"); + } + if (scheme != null + || userInfo != null + || host != null + || port != -1 + || query != null + || fragment != null) { + throw new IllegalStateException("la source n'accepte seulement qu'un chemin d'url valide"); + } + } catch (URISyntaxException | NullPointerException e) { + throw new IllegalStateException("la source doit êre un chemin d'url valide"); + } + } + + public static Source of(String value) throws IllegalStateException { + return new Source(value); + } + + private Boolean startsWithFqdn(String value) { + if (value.isBlank()) { + return false; + } + + /* Regex to match a String that starts with a FQDN */ + final var regex = "^((?!-)[A-Za-z0-9-]{1,63}(? + + z3-statistics-domain + + + + + jacoco-maven-plugin + + + **/port/repository/*.class + **/port/service/*.class + + + + + + + + true + ${project.build.directory}/coverage-reports/jacoco-ut.exec + surefire.jacoco.args + + + prepare-agent + + pre-unit-test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + ${project.build.directory}/coverage-jacoco/unit-tests + + + + report + + post-unit-test + test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + + + BUNDLE + + + LINE + 1.00 + COVEREDRATIO + + + + + + + check + + jacoco-check-ut + verify + + + + org.jacoco + ${jacoco.version} + + + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + ${surefire.jacoco.args} -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + + + + **/*IT.java + + + org.jboss.logmanager.LogManager + ${maven.home} + + + ${surefire-plugin.version} + + + + + + + z3-shared + tech.pegase.z3 + ${z3.version} + + + quarkus-hibernate-orm + io.quarkus + + + quarkus-jdbc-postgresql + io.quarkus + + + quarkus-jdbc-h2 + io.quarkus + test + + + quarkus-junit5-mockito + io.quarkus + test + + + quarkus-jacoco + io.quarkus + test + + + + 4.0.0 + + + z3 + tech.pegase.z3 + 1.1.0-SNAPSHOT + + + + 22 + 22 + UTF-8 + + + \ No newline at end of file diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/entity/LinkStatistic.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/entity/LinkStatistic.java new file mode 100644 index 0000000..0e03568 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/entity/LinkStatistic.java @@ -0,0 +1,220 @@ +package tech.pegase.z3.statistics.domain.entity; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.time.ZonedDateTime; +import java.util.UUID; +import org.jspecify.annotations.Nullable; +import tech.pegase.happy.core.AggregateRoot; +import tech.pegase.z3.shared.annotations.ExcludeFromJacocoGeneratedReport; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.valueobject.BrowserInfo; +import tech.pegase.z3.statistics.domain.valueobject.Country; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.OperatingSystemInfo; +import tech.pegase.z3.statistics.domain.valueobject.Referrer; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +@Entity +@Table(schema = "z3-statistics", name = "link_statistics") +public class LinkStatistic implements AggregateRoot { + @jakarta.persistence.Id + @Column(name = "uuid", columnDefinition = "uuid", nullable = false, updatable = false) + private final UUID uuid; + + @AttributeOverride( + name = "value", + column = + @Column(name = "source", columnDefinition = "value", nullable = false, updatable = false)) + private final Source linkSource; + + @Column(name = "was_valid", nullable = false, updatable = false) + private final Boolean linkWasValid; + + @Column(name = "do_not_track", nullable = false, updatable = false) + private final Boolean doNotTrack; + + @Column(name = "user_type", nullable = false, updatable = false) + private final UserType userType; + + @Nullable + @Column(name = "country", updatable = false) + private final Country country; + + @Nullable + @Column(name = "brand", updatable = false) + private final String brand; + + @Nullable + @Column(name = "catalog", updatable = false) + private final String catalog; + + @Nullable + @AttributeOverride( + name = "fingerprint", + column = @Column(name = "fingerprint", updatable = false)) + private final Fingerprint fingerprint; + + @Embedded + @Nullable + @AttributeOverride(name = "lang", column = @Column(name = "lang", updatable = false)) + @AttributeOverride(name = "region", column = @Column(name = "region", updatable = false)) + private final Locale locale; + + @Embedded + @Nullable + @AttributeOverride(name = "host", column = @Column(name = "referrer_host", updatable = false)) + @AttributeOverride(name = "path", column = @Column(name = "referrer_path", updatable = false)) + private final Referrer referrer; + + @Embedded + @Nullable + @AttributeOverride( + name = "classType", + column = @Column(name = "browser_class", updatable = false)) + @AttributeOverride(name = "name", column = @Column(name = "browser_name", updatable = false)) + @AttributeOverride( + name = "version", + column = @Column(name = "browser_version", updatable = false)) + private final BrowserInfo browser; + + @Embedded + @Nullable + @AttributeOverride(name = "height", column = @Column(name = "screen_height", updatable = false)) + @AttributeOverride(name = "width", column = @Column(name = "screen_width", updatable = false)) + @AttributeOverride(name = "dpi", column = @Column(name = "screen_dpi", updatable = false)) + private final ScreenProperties screen; + + @Embedded + @Nullable + @AttributeOverride(name = "classType", column = @Column(name = "os_class", updatable = false)) + @AttributeOverride(name = "name", column = @Column(name = "os_name", updatable = false)) + @AttributeOverride(name = "version", column = @Column(name = "os_version", updatable = false)) + private final OperatingSystemInfo operatingSystem; + + @Column(name = "visited_at", nullable = false, updatable = false) + private final ZonedDateTime visitedAt; + + public LinkStatistic( + Source linkSource, + Boolean linkWasValid, + Boolean doNotTrack, + UserType userType, + ZonedDateTime visitedAt, + @Nullable String brand, + @Nullable String catalog, + @Nullable Fingerprint fingerprint, + @Nullable ScreenProperties screen, + @Nullable Country country, + @Nullable Locale locale, + @Nullable OperatingSystemInfo operatingSystem, + @Nullable BrowserInfo browser, + @Nullable Referrer referrer) { + this.uuid = UUID.randomUUID(); + this.brand = brand; + this.catalog = catalog; + this.fingerprint = fingerprint; + this.linkSource = linkSource; + this.linkWasValid = linkWasValid; + this.doNotTrack = doNotTrack; + this.visitedAt = visitedAt; + this.screen = screen; + this.userType = userType; + this.country = country; + this.locale = locale; + this.operatingSystem = operatingSystem; + this.browser = browser; + this.referrer = referrer; + } + + /// Required by Hibernate + @SuppressWarnings("DataFlowIssue") + @ExcludeFromJacocoGeneratedReport + protected LinkStatistic() { + this.uuid = null; + this.brand = null; + this.catalog = null; + this.fingerprint = null; + this.screen = null; + this.userType = null; + this.country = null; + this.locale = null; + this.operatingSystem = null; + this.browser = null; + this.referrer = null; + this.linkSource = null; + this.linkWasValid = null; + this.doNotTrack = null; + this.visitedAt = null; + } + + @Override + public Id getId() { + return new Id<>(getLinkSource()); + } + + public Source getLinkSource() { + return linkSource; + } + + public UUID getUuid() { + return uuid; + } + + public @Nullable String getCatalog() { + return catalog; + } + + public @Nullable String getBrand() { + return brand; + } + + public @Nullable Fingerprint getFingerprint() { + return fingerprint; + } + + public Boolean getLinkWasValid() { + return linkWasValid; + } + + public Boolean getDoNotTrack() { + return doNotTrack; + } + + public ZonedDateTime getVisitedAt() { + return visitedAt; + } + + public @Nullable ScreenProperties getScreen() { + return screen; + } + + public UserType getUserType() { + return userType; + } + + public @Nullable Country getCountry() { + return country; + } + + public @Nullable Locale getLocale() { + return locale; + } + + public @Nullable OperatingSystemInfo getOperatingSystem() { + return operatingSystem; + } + + public @Nullable BrowserInfo getBrowser() { + return browser; + } + + public @Nullable Referrer getReferrer() { + return referrer; + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/entity/package-info.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/entity/package-info.java new file mode 100644 index 0000000..93d9287 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/entity/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.domain.entity; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/LinkStatisticBuilder.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/LinkStatisticBuilder.java new file mode 100644 index 0000000..7402105 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/LinkStatisticBuilder.java @@ -0,0 +1,65 @@ +package tech.pegase.z3.statistics.domain.lib.link.statistic.builder; + +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.entity.LinkStatistic; +import tech.pegase.z3.statistics.domain.valueobject.BrowserInfo; +import tech.pegase.z3.statistics.domain.valueobject.Country; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.OperatingSystemInfo; +import tech.pegase.z3.statistics.domain.valueobject.Referrer; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +public interface LinkStatisticBuilder { + static LinkStatisticBuilderCountry make( + Source linkSource, Boolean linkWasValid, ZonedDateTime visitedAt) { + return new LinkStatisticBuilderImpl(linkSource, linkWasValid, visitedAt); + } + + interface LinkStatisticBuilderCountry { + LinkStatisticBuilderLocale setCountry(@Nullable Country country); + } + + interface LinkStatisticBuilderLocale { + LinkStatisticBuilderReferer setLocale(@Nullable Locale locale); + } + + interface LinkStatisticBuilderOs { + LinkStatisticBuilderBrowser setOs(@Nullable OperatingSystemInfo os); + } + + interface LinkStatisticBuilderBrowser { + LinkStatisticBuilderUserType setBrowser(@Nullable BrowserInfo browserInfo); + } + + interface LinkStatisticBuilderUserType { + LinkStatisticBuilderScreen setUserType(UserType userType); + } + + interface LinkStatisticBuilderReferer { + LinkStatisticBuilderOs setReferer(@Nullable Referrer referrer); + } + + interface LinkStatisticBuilderScreen { + LinkStatisticBuilderDoNotTrack setScreen(@Nullable ScreenProperties screenProperties); + } + + interface LinkStatisticBuilderDoNotTrack { + LinkStatisticBuilderFingerprint setDoNotTrack(Boolean doNotTrack); + } + + interface LinkStatisticBuilderFingerprint { + LinkStatisticBuilderFinal setFingerprint(@Nullable Fingerprint fingerprint); + } + + interface LinkStatisticBuilderFinal { + void setBrand(@Nullable String brand); + + void setCatalog(@Nullable String catalog); + + LinkStatistic make(); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/LinkStatisticBuilderImpl.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/LinkStatisticBuilderImpl.java new file mode 100644 index 0000000..72c78f3 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/LinkStatisticBuilderImpl.java @@ -0,0 +1,143 @@ +package tech.pegase.z3.statistics.domain.lib.link.statistic.builder; + +import java.time.ZonedDateTime; +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.entity.LinkStatistic; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderBrowser; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderCountry; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderDoNotTrack; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderFinal; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderFingerprint; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderLocale; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderOs; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderReferer; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderScreen; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderUserType; +import tech.pegase.z3.statistics.domain.valueobject.BrowserInfo; +import tech.pegase.z3.statistics.domain.valueobject.Country; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.OperatingSystemInfo; +import tech.pegase.z3.statistics.domain.valueobject.Referrer; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +public class LinkStatisticBuilderImpl + implements LinkStatisticBuilderUserType, + LinkStatisticBuilderCountry, + LinkStatisticBuilderLocale, + LinkStatisticBuilderOs, + LinkStatisticBuilderBrowser, + LinkStatisticBuilderReferer, + LinkStatisticBuilderScreen, + LinkStatisticBuilderDoNotTrack, + LinkStatisticBuilderFingerprint, + LinkStatisticBuilderFinal { + Source linkSource; + Boolean linkWasValid; + Boolean doNotTrack; + ZonedDateTime visitedAt; + UserType userType; + @Nullable String brand; + @Nullable String catalog; + @Nullable Fingerprint fingerprint; + @Nullable ScreenProperties screen; + @Nullable Country country; + @Nullable Locale locale; + @Nullable OperatingSystemInfo operatingSystem; + @Nullable BrowserInfo browser; + @Nullable Referrer referrer; + + protected LinkStatisticBuilderImpl( + Source linkSource, Boolean linkWasValid, ZonedDateTime visitedAt) { + this.linkSource = linkSource; + this.linkWasValid = linkWasValid; + this.visitedAt = visitedAt; + this.doNotTrack = false; + this.userType = UserType.UNKNOWN; + } + + @Override + public LinkStatisticBuilderUserType setBrowser(@Nullable BrowserInfo browser) { + this.browser = browser; + return this; + } + + @Override + public LinkStatisticBuilderLocale setCountry(@Nullable Country country) { + this.country = country; + return this; + } + + @Override + public void setBrand(@Nullable String brand) { + this.brand = brand; + } + + @Override + public void setCatalog(@Nullable String catalog) { + this.catalog = catalog; + } + + @Override + public LinkStatistic make() { + return new LinkStatistic( + linkSource, + linkWasValid, + doNotTrack, + userType, + visitedAt, + brand, + catalog, + fingerprint, + screen, + country, + locale, + operatingSystem, + browser, + referrer); + } + + @Override + public LinkStatisticBuilderReferer setLocale(@Nullable Locale locale) { + this.locale = locale; + return this; + } + + @Override + public LinkStatisticBuilderBrowser setOs(@Nullable OperatingSystemInfo os) { + this.operatingSystem = os; + return this; + } + + @Override + public LinkStatisticBuilderOs setReferer(@Nullable Referrer referrer) { + this.referrer = referrer; + return this; + } + + @Override + public LinkStatisticBuilderDoNotTrack setScreen(@Nullable ScreenProperties screenProperties) { + this.screen = screenProperties; + return this; + } + + @Override + public LinkStatisticBuilderScreen setUserType(UserType userType) { + this.userType = userType; + return this; + } + + @Override + public LinkStatisticBuilderFingerprint setDoNotTrack(Boolean doNotTrack) { + this.doNotTrack = doNotTrack; + return this; + } + + @Override + public LinkStatisticBuilderFinal setFingerprint(@Nullable Fingerprint fingerprint) { + this.fingerprint = fingerprint; + return this; + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/package-info.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/package-info.java new file mode 100644 index 0000000..f75dbfe --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/lib/link/statistic/builder/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.domain.lib.link.statistic.builder; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/repository/LinkStatisticRepository.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/repository/LinkStatisticRepository.java new file mode 100644 index 0000000..35cee6a --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/repository/LinkStatisticRepository.java @@ -0,0 +1,37 @@ +package tech.pegase.z3.statistics.domain.port.repository; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import org.jspecify.annotations.NullMarked; +import tech.pegase.happy.core.DomainEntity; +import tech.pegase.happy.core.Repository; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.entity.LinkStatistic; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLineWithEntity; + +@NullMarked +public interface LinkStatisticRepository extends Repository { + + Optional findByLinkSource(Source linkSource); + + void persist(LinkStatistic linkStatistic); + + @Override + default Optional find(DomainEntity.Id id) { + return findByLinkSource(id.value()); + } + + List getUniqueVisitorsPerDayForEachBrand(Duration duration); + + List getUniqueVisitorsPerDayForEachCatalog(Duration duration); + + List getMostVisitedSourcesWithBrandFromNow( + Duration duration, Integer amount); + + List getMostUsedReferrerWithBrandFromNow( + Duration duration, Integer amount); + + List getInvalidLinksPerWeeksByBrandsAndFromNow(Duration duration); +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/GeoIpService.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/GeoIpService.java new file mode 100644 index 0000000..d0d6352 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/GeoIpService.java @@ -0,0 +1,9 @@ +package tech.pegase.z3.statistics.domain.port.service; + +import java.net.InetAddress; +import java.util.Optional; +import tech.pegase.z3.statistics.domain.valueobject.Country; + +public interface GeoIpService { + Optional locateInetAddress(InetAddress inetAddress); +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/HashService.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/HashService.java new file mode 100644 index 0000000..955516e --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/HashService.java @@ -0,0 +1,5 @@ +package tech.pegase.z3.statistics.domain.port.service; + +public interface HashService { + String hash(String rawValue); +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/UserAgentService.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/UserAgentService.java new file mode 100644 index 0000000..f9a39df --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/UserAgentService.java @@ -0,0 +1,14 @@ +package tech.pegase.z3.statistics.domain.port.service; + +import org.jspecify.annotations.Nullable; +import tech.pegase.z3.statistics.domain.valueobject.BrowserInfo; +import tech.pegase.z3.statistics.domain.valueobject.OperatingSystemInfo; +import tech.pegase.z3.statistics.domain.valueobject.RawUserAgent; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +public interface UserAgentService { + UserAgentInfo getUserAgentFrom(RawUserAgent rawUserAgent); + + record UserAgentInfo( + @Nullable BrowserInfo browser, @Nullable OperatingSystemInfo systemInfo, UserType userType) {} +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/package-info.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/package-info.java new file mode 100644 index 0000000..b2baae2 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/port/service/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.domain.port.service; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLink.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLink.java new file mode 100644 index 0000000..5cc762d --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLink.java @@ -0,0 +1,39 @@ +package tech.pegase.z3.statistics.domain.usecase.links; + +import jakarta.annotation.Nullable; +import java.net.InetAddress; +import java.net.URI; +import java.time.ZonedDateTime; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.RawUserAgent; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; + +public interface VisitLink extends UseCase { + sealed interface Input { + record AddVisitFor( + Source source, + Boolean linkWasValid, + ZonedDateTime visitedAt, + AdditionalInputs additionalInputs) + implements Input { + + public record AdditionalInputs( + @Nullable String brand, + @Nullable String catalog, + @Nullable Boolean doNotTrack, + @Nullable Fingerprint fingerprint, + @Nullable InetAddress inetAddress, + @Nullable Locale locale, + @Nullable URI referrerUri, + @Nullable RawUserAgent rawUserAgent, + @Nullable ScreenProperties screenProperties) {} + } + } + + sealed interface Output { + record VisitAdded() implements Output {} + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLinkImpl.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLinkImpl.java new file mode 100644 index 0000000..eb32427 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLinkImpl.java @@ -0,0 +1,131 @@ +package tech.pegase.z3.statistics.domain.usecase.links; + +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.ApplicationScoped; +import java.net.InetAddress; +import java.net.URI; +import java.util.Optional; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderCountry; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderDoNotTrack; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderFinal; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderLocale; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderOs; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderReferer; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder.LinkStatisticBuilderScreen; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.port.service.GeoIpService; +import tech.pegase.z3.statistics.domain.port.service.UserAgentService; +import tech.pegase.z3.statistics.domain.port.service.UserAgentService.UserAgentInfo; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.RawUserAgent; +import tech.pegase.z3.statistics.domain.valueobject.Referrer; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +@ApplicationScoped +public class VisitLinkImpl implements VisitLink { + private final LinkStatisticRepository linkStatisticRepository; + private final GeoIpService geoIpService; + private final UserAgentService userAgentService; + + VisitLinkImpl( + LinkStatisticRepository linkStatisticRepository, + GeoIpService geoIpService, + UserAgentService userAgentService) { + this.linkStatisticRepository = linkStatisticRepository; + this.geoIpService = geoIpService; + this.userAgentService = userAgentService; + } + + private LinkStatisticBuilderReferer fillWithLocale( + LinkStatisticBuilderLocale builder, @Nullable Locale locale) { + return builder.setLocale(locale); + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.AddVisitFor inputCase -> run(inputCase); + }; + } + + private Output run(Input.AddVisitFor input) { + var source = input.source(); + var linkWasValid = input.linkWasValid(); + var visitedAt = input.visitedAt(); + var additionalInputs = input.additionalInputs(); + + var builder = LinkStatisticBuilder.make(source, linkWasValid, visitedAt); + var linkStatistic = fillWithAdditionalInputs(builder, additionalInputs).make(); + + linkStatisticRepository.persist(linkStatistic); + + return new Output.VisitAdded(); + } + + private LinkStatisticBuilderFinal fillWithAdditionalInputs( + LinkStatisticBuilderCountry builder, Input.AddVisitFor.AdditionalInputs additionalInputs) { + var inetAddress = additionalInputs.inetAddress(); + var referrerUri = additionalInputs.referrerUri(); + var screenProperties = additionalInputs.screenProperties(); + var rawUserAgent = additionalInputs.rawUserAgent(); + var locale = additionalInputs.locale(); + var doNotTrack = additionalInputs.doNotTrack(); + var brand = additionalInputs.brand(); + var catalog = additionalInputs.catalog(); + var fingerprint = additionalInputs.fingerprint(); + + return Optional.of(builder) + .map(b -> fillWithInetAddress(b, inetAddress)) + .map(b -> fillWithLocale(b, locale)) + .map(b -> fillWithRefererUri(b, referrerUri)) + .map(b -> fillWithUserAgentInfo(b, rawUserAgent)) + .map(b -> fillWithScreenProperties(b, screenProperties)) + .map(b -> b.setDoNotTrack(doNotTrack != null ? doNotTrack : false)) + .map(b -> b.setFingerprint(fingerprint)) + .map( + finalBuilder -> { + finalBuilder.setBrand(brand); + finalBuilder.setCatalog(catalog); + + return finalBuilder; + }) + .get(); + } + + private LinkStatisticBuilderLocale fillWithInetAddress( + LinkStatisticBuilderCountry builder, @Nullable InetAddress inetAddress) { + var country = + Optional.ofNullable(inetAddress).flatMap(geoIpService::locateInetAddress).orElse(null); + + return builder.setCountry(country); + } + + private LinkStatisticBuilderScreen fillWithUserAgentInfo( + LinkStatisticBuilderOs builder, @Nullable RawUserAgent rawUserAgent) { + var userAgentInfo = + Optional.ofNullable(rawUserAgent).map(userAgentService::getUserAgentFrom).orElse(null); + var browser = Optional.ofNullable(userAgentInfo).map(UserAgentInfo::browser).orElse(null); + var osInfo = Optional.ofNullable(userAgentInfo).map(UserAgentInfo::systemInfo).orElse(null); + var userType = + Optional.ofNullable(userAgentInfo).map(UserAgentInfo::userType).orElse(UserType.UNKNOWN); + + return Optional.of(builder) + .map(b -> b.setOs(osInfo)) + .map(b -> b.setBrowser(browser)) + .map(b -> b.setUserType(userType)) + .orElseThrow(); + } + + private LinkStatisticBuilderOs fillWithRefererUri( + LinkStatisticBuilderReferer builder, @Nullable URI referrerUri) { + var referrer = referrerUri != null ? Referrer.of(referrerUri) : null; + return builder.setReferer(referrer); + } + + private LinkStatisticBuilderDoNotTrack fillWithScreenProperties( + LinkStatisticBuilderScreen builder, @Nullable ScreenProperties screenProperties) { + return builder.setScreen(screenProperties); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/package-info.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/package-info.java new file mode 100644 index 0000000..f69825d --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/links/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.domain.usecase.links; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeek.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeek.java new file mode 100644 index 0000000..0ca5a63 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeek.java @@ -0,0 +1,16 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +public interface GetInvalidLinksCountsPerWeek + extends UseCase { + sealed interface Input { + record ByBrandForThePastYear() implements Input {} + } + + sealed interface Output { + record GetCounts(ReadOnlyList counts) implements Output {} + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeekImpl.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeekImpl.java new file mode 100644 index 0000000..01ec1f5 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeekImpl.java @@ -0,0 +1,35 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Duration; +import java.time.ZonedDateTime; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.usecase.statistics.GetInvalidLinksCountsPerWeek.Input.ByBrandForThePastYear; + +@ApplicationScoped +public class GetInvalidLinksCountsPerWeekImpl implements GetInvalidLinksCountsPerWeek { + private final LinkStatisticRepository linkStatisticRepository; + + GetInvalidLinksCountsPerWeekImpl(LinkStatisticRepository linkStatisticRepository) { + this.linkStatisticRepository = linkStatisticRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case ByBrandForThePastYear inputCase -> run(inputCase); + }; + } + + public Output run(ByBrandForThePastYear ignoredInputCase) { + var now = ZonedDateTime.now().minusMonths(1); + var limit = now.minusMonths(1); + var duration = Duration.between(now, limit); + + var invalidLinks = linkStatisticRepository.getInvalidLinksPerWeeksByBrandsAndFromNow(duration); + var readOnlyList = new ReadOnlyList<>(invalidLinks); + + return new Output.GetCounts(readOnlyList); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrers.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrers.java new file mode 100644 index 0000000..d98eb9e --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrers.java @@ -0,0 +1,16 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLineWithEntity; + +public interface MostFrequentReferrers + extends UseCase { + sealed interface Input { + record GetTop20() implements Input {} + } + + sealed interface Output { + record TopList(ReadOnlyList count) implements Output {} + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrersImpl.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrersImpl.java new file mode 100644 index 0000000..9dfb0d1 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrersImpl.java @@ -0,0 +1,35 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Duration; +import java.time.ZonedDateTime; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; + +@ApplicationScoped +public class MostFrequentReferrersImpl implements MostFrequentReferrers { + private final LinkStatisticRepository repository; + + MostFrequentReferrersImpl(LinkStatisticRepository repository) { + this.repository = repository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.GetTop20 inputCase -> run(inputCase); + }; + } + + private Output run(Input.GetTop20 ignoredInput) { + var now = ZonedDateTime.now().minusMonths(1); + var limit = now.minusMonths(1); + var duration = Duration.between(now, limit); + var resultAmount = 20; + + var results = repository.getMostUsedReferrerWithBrandFromNow(duration, resultAmount); + var readOnlyList = new ReadOnlyList<>(results); + + return new Output.TopList(readOnlyList); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSource.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSource.java new file mode 100644 index 0000000..f33d0e3 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSource.java @@ -0,0 +1,16 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLineWithEntity; + +public interface MostVisitedSource + extends UseCase { + sealed interface Input { + record GetTop30() implements Input {} + } + + sealed interface Output { + record TopList(ReadOnlyList count) implements Output {} + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSourceImpl.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSourceImpl.java new file mode 100644 index 0000000..09a0003 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSourceImpl.java @@ -0,0 +1,35 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Duration; +import java.time.ZonedDateTime; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; + +@ApplicationScoped +public class MostVisitedSourceImpl implements MostVisitedSource { + private final LinkStatisticRepository repository; + + MostVisitedSourceImpl(LinkStatisticRepository repository) { + this.repository = repository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.GetTop30 inputCase -> run(inputCase); + }; + } + + private Output run(Input.GetTop30 ignoredInput) { + var now = ZonedDateTime.now().minusMonths(1); + var limit = now.minusMonths(1); + var duration = Duration.between(now, limit); + var resultAmount = 30; + + var results = repository.getMostVisitedSourcesWithBrandFromNow(duration, resultAmount); + var readOnlyList = new ReadOnlyList<>(results); + + return new Output.TopList(readOnlyList); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrand.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrand.java new file mode 100644 index 0000000..75c2b82 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrand.java @@ -0,0 +1,16 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +public interface UniqueVisitorsByBrand + extends UseCase { + sealed interface Input { + record GetCountsForThePastYear() implements Input {} + } + + sealed interface Output { + record CountsForAll(ReadOnlyList counts) implements Output {} + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrandImpl.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrandImpl.java new file mode 100644 index 0000000..2bdb7d1 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrandImpl.java @@ -0,0 +1,34 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Duration; +import java.time.ZonedDateTime; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; + +@ApplicationScoped +public class UniqueVisitorsByBrandImpl implements UniqueVisitorsByBrand { + private final LinkStatisticRepository linkStatisticRepository; + + UniqueVisitorsByBrandImpl(LinkStatisticRepository linkStatisticRepository) { + this.linkStatisticRepository = linkStatisticRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.GetCountsForThePastYear inputCase -> run(inputCase); + }; + } + + private Output run(Input.GetCountsForThePastYear ignoredInputCase) { + var now = ZonedDateTime.now().minusMonths(1); + var limit = now.minusMonths(1); + var duration = Duration.between(now, limit); + + var results = linkStatisticRepository.getUniqueVisitorsPerDayForEachBrand(duration); + var readOnlyList = new ReadOnlyList<>(results); + + return new Output.CountsForAll(readOnlyList); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalog.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalog.java new file mode 100644 index 0000000..b872be5 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalog.java @@ -0,0 +1,16 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +public interface UniqueVisitorsByCatalog + extends UseCase { + sealed interface Input { + record GetCountsForThePastYear() implements Input {} + } + + sealed interface Output { + record CountsForAll(ReadOnlyList counts) implements Output {} + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalogImpl.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalogImpl.java new file mode 100644 index 0000000..94e5abe --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalogImpl.java @@ -0,0 +1,34 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import jakarta.enterprise.context.ApplicationScoped; +import java.time.Duration; +import java.time.ZonedDateTime; +import tech.pegase.z3.shared.lib.ReadOnlyList; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; + +@ApplicationScoped +public class UniqueVisitorsByCatalogImpl implements UniqueVisitorsByCatalog { + private final LinkStatisticRepository linkStatisticRepository; + + UniqueVisitorsByCatalogImpl(LinkStatisticRepository linkStatisticRepository) { + this.linkStatisticRepository = linkStatisticRepository; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.GetCountsForThePastYear inputCase -> run(inputCase); + }; + } + + private Output run(Input.GetCountsForThePastYear ignoredInputCase) { + var now = ZonedDateTime.now().minusMonths(1); + var limit = now.minusMonths(1); + var duration = Duration.between(now, limit); + + var results = linkStatisticRepository.getUniqueVisitorsPerDayForEachCatalog(duration); + var readOnlyList = new ReadOnlyList<>(results); + + return new Output.CountsForAll(readOnlyList); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/package-info.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/package-info.java new file mode 100644 index 0000000..9fd1c85 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/statistics/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprint.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprint.java new file mode 100644 index 0000000..bb55107 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprint.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.statistics.domain.usecase.tracking; + +import java.net.InetAddress; +import tech.pegase.happy.core.usecase.UseCase; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; + +public interface CreateFingerprint + extends UseCase { + sealed interface Input { + record MakeFrom( + InetAddress ipAddress, String userAgent, ScreenProperties screenProperties, Locale locale) + implements Input {} + } + + sealed interface Output { + record GeneratedFingerprint(Fingerprint fingerprint) implements Output {} + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprintImpl.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprintImpl.java new file mode 100644 index 0000000..eb6ec0a --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprintImpl.java @@ -0,0 +1,55 @@ +package tech.pegase.z3.statistics.domain.usecase.tracking; + +import jakarta.enterprise.context.ApplicationScoped; +import java.net.InetAddress; +import tech.pegase.z3.statistics.domain.port.service.HashService; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; + +@ApplicationScoped +public class CreateFingerprintImpl implements CreateFingerprint { + private final HashService hashService; + + CreateFingerprintImpl(HashService hashService) { + this.hashService = hashService; + } + + @Override + public Output run(Input input) { + return switch (input) { + case Input.MakeFrom inputCase -> run(inputCase); + }; + } + + public Output run(Input.MakeFrom inputCase) { + var ipAddress = inputCase.ipAddress(); + var locale = inputCase.locale(); + var screenProperties = inputCase.screenProperties(); + + var rawString = + inetAddressToString(ipAddress) + + localeToString(locale) + + screenPropertiesToString(screenProperties); + var hashedValue = hashService.hash(rawString); + var fingerPrint = new Fingerprint(hashedValue); + + return new Output.GeneratedFingerprint(fingerPrint); + } + + private String inetAddressToString(InetAddress ipAddress) { + return ipAddress.getHostAddress(); + } + + private String localeToString(Locale locale) { + return locale.toString(); + } + + private String screenPropertiesToString(ScreenProperties screenProperties) { + return screenProperties.height() + + "+" + + screenProperties.width() + + "+" + + screenProperties.dpi(); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/package-info.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/package-info.java new file mode 100644 index 0000000..74a5764 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/usecase/tracking/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.domain.usecase.tracking; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/BrowserInfo.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/BrowserInfo.java new file mode 100644 index 0000000..642fc9a --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/BrowserInfo.java @@ -0,0 +1,32 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record BrowserInfo(String classType, String name, String version) { + public BrowserInfo { + if (classType.isEmpty()) { + throw new IllegalStateException("classType cannot be empty"); + } + + if (classType.length() > 255) { + throw new IllegalStateException("classType length exceeds 255 characters"); + } + + if (name.isEmpty()) { + throw new IllegalStateException("name cannot be empty"); + } + + if (name.length() > 255) { + throw new IllegalStateException("name length exceeds 255 characters"); + } + + if (version.isEmpty()) { + throw new IllegalStateException("version cannot be empty"); + } + + if (version.length() > 255) { + throw new IllegalStateException("version length exceeds 255 characters"); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Country.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Country.java new file mode 100644 index 0000000..f1eefae --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Country.java @@ -0,0 +1,309 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import java.util.HashMap; +import java.util.Map; + +public enum Country { + AFGHANISTAN(4, "AF", "Afghanistan"), + ALAND_ISLANDS(248, "AX", "Åland Islands"), + ALBANIA(8, "AL", "Albania"), + ALGERIA(12, "DZ", "Algeria"), + AMERICAN_SAMOA(16, "AS", "American Samoa"), + ANDORRA(20, "AD", "Andorra"), + ANGOLA(24, "AO", "Angola"), + ANGUILLA(660, "AI", "Anguilla"), + ANTARCTICA(10, "AQ", "Antarctica"), + ANTIGUA_AND_BARBUDA(28, "AG", "Antigua and Barbuda"), + ARGENTINA(32, "AR", "Argentina"), + ARMENIA(51, "AM", "Armenia"), + ARUBA(533, "AW", "Aruba"), + AUSTRALIA(36, "AU", "Australia"), + AUSTRIA(40, "AT", "Austria"), + AZERBAIJAN(31, "AZ", "Azerbaijan"), + BAHRAIN(48, "BH", "Bahrain"), + BAHAMAS(44, "BS", "Bahamas"), + BANGLADESH(50, "BD", "Bangladesh"), + BARBADOS(52, "BB", "Barbados"), + BELARUS(112, "BY", "Belarus"), + BELGIUM(56, "BE", "Belgium"), + BELIZE(84, "BZ", "Belize"), + BENIN(204, "BJ", "Benin"), + BERMUDA(60, "BM", "Bermuda"), + BHUTAN(64, "BT", "Bhutan"), + BOLIVIA_PLURINATIONAL_STATE_OF(68, "BO", "Bolivia, Plurinational State of"), + BONAIRE_SINT_EUSTATIUS_AND_SABA(535, "BQ", "Bonaire, Sint Eustatius and Saba"), + BOSNIA_AND_HERZEGOVINA(70, "BA", "Bosnia and Herzegovina"), + BOTSWANA(72, "BW", "Botswana"), + BOUVET_ISLAND(74, "BV", "Bouvet Island"), + BRAZIL(76, "BR", "Brazil"), + BRITISH_INDIAN_OCEAN_TERRITORY(86, "IO", "British Indian Ocean Territory"), + BRUNEI_DARUSSALAM(96, "BN", "Brunei Darussalam"), + BULGARIA(100, "BG", "Bulgaria"), + BURKINA_FASO(854, "BF", "Burkina Faso"), + BURUNDI(108, "BI", "Burundi"), + CAMBODIA(116, "KH", "Cambodia"), + CAMEROON(120, "CM", "Cameroon"), + CANADA(124, "CA", "Canada"), + CAPE_VERDE(132, "CV", "Cape Verde"), + CAYMAN_ISLANDS(136, "KY", "Cayman Islands"), + CENTRAL_AFRICAN_REPUBLIC(140, "CF", "Central African Republic"), + CHAD(148, "TD", "Chad"), + CHILE(152, "CL", "Chile"), + CHINA(156, "CN", "China"), + CHRISTMAS_ISLAND(162, "CX", "Christmas Island"), + COCOS_KEELING_ISLANDS(166, "CC", "Cocos (Keeling) Islands"), + COLOMBIA(170, "CO", "Colombia"), + COMOROS(174, "KM", "Comoros"), + CONGO(178, "CG", "Congo"), + CONGO_THE_DEMOCRATIC_REPUBLIC_OF_THE(180, "CD", "Congo, the Democratic Republic of the"), + COOK_ISLANDS(184, "CK", "Cook Islands"), + COSTA_RICA(188, "CR", "Costa Rica"), + COTE_DIVOIRE(384, "CI", "Côte d'Ivoire"), + CROATIA(191, "HR", "Croatia"), + CUBA(192, "CU", "Cuba"), + CURACAO(531, "CW", "Curaçao"), + CYPRUS(196, "CY", "Cyprus"), + CZECH_REPUBLIC(203, "CZ", "Czech Republic"), + DENMARK(208, "DK", "Denmark"), + DJIBOUTI(262, "DJ", "Djibouti"), + DOMINICA(212, "DM", "Dominica"), + DOMINICAN_REPUBLIC(214, "DO", "Dominican Republic"), + ECUADOR(218, "EC", "Ecuador"), + EGYPT(818, "EG", "Egypt"), + EL_SALVADOR(222, "SV", "El Salvador"), + EQUATORIAL_GUINEA(226, "GQ", "Equatorial Guinea"), + ERITREA(232, "ER", "Eritrea"), + ESTONIA(233, "EE", "Estonia"), + ETHIOPIA(231, "ET", "Ethiopia"), + FALKLAND_ISLANDS_MALVINAS(238, "FK", "Falkland Islands (Malvinas)"), + FAROE_ISLANDS(234, "FO", "Faroe Islands"), + FIJI(242, "FJ", "Fiji"), + FINLAND(246, "FI", "Finland"), + FRANCE(250, "FR", "France"), + FRENCH_GUIANA(254, "GF", "French Guiana"), + FRENCH_POLYNESIA(258, "PF", "French Polynesia"), + FRENCH_SOUTHERN_TERRITORIES(260, "TF", "French Southern Territories"), + GABON(266, "GA", "Gabon"), + GAMBIA(270, "GM", "Gambia"), + GEORGIA(268, "GE", "Georgia"), + GERMANY(276, "DE", "Germany"), + GHANA(288, "GH", "Ghana"), + GIBRALTAR(292, "GI", "Gibraltar"), + GREECE(300, "GR", "Greece"), + GREENLAND(304, "GL", "Greenland"), + GRENADA(308, "GD", "Grenada"), + GUADELOUPE(312, "GP", "Guadeloupe"), + GUAM(316, "GU", "Guam"), + GUATEMALA(320, "GT", "Guatemala"), + GUERNSEY(831, "GG", "Guernsey"), + GUINEA(324, "GN", "Guinea"), + GUINEA_BISSAU(624, "GW", "GuineaBissau"), + GUYANA(328, "GY", "Guyana"), + HAITI(332, "HT", "Haiti"), + HEARD_ISLAND_AND_MCDONALD_ISLANDS(334, "HM", "Heard Island and McDonald Islands"), + HOLY_SEE_VATICAN_CITY_STATE(336, "VA", "Holy See (Vatican City State)"), + HONDURAS(340, "HN", "Honduras"), + HONG_KONG(344, "HK", "Hong Kong"), + HUNGARY(348, "HU", "Hungary"), + ICELAND(352, "IS", "Iceland"), + INDIA(356, "IN", "India"), + INDONESIA(360, "ID", "Indonesia"), + IRAN_ISLAMIC_REPUBLIC_OF(364, "IR", "Iran, Islamic Republic of"), + IRAQ(368, "IQ", "Iraq"), + IRELAND(372, "IE", "Ireland"), + ISLE_OF_MAN(833, "IM", "Isle of Man"), + ISRAEL(376, "IL", "Israel"), + ITALY(380, "IT", "Italy"), + JAMAICA(388, "JM", "Jamaica"), + JAPAN(392, "JP", "Japan"), + JERSEY(832, "JE", "Jersey"), + JORDAN(400, "JO", "Jordan"), + KAZAKHSTAN(398, "KZ", "Kazakhstan"), + KENYA(404, "KE", "Kenya"), + KIRIBATI(296, "KI", "Kiribati"), + KOREA_DEMOCRATIC_PEOPLES_REPUBLIC_OF(408, "KP", "Korea, Democratic People's Republic of"), + KOREA_REPUBLIC_OF(410, "KR", "Korea, Republic of"), + KUWAIT(414, "KW", "Kuwait"), + KYRGYZSTAN(417, "KG", "Kyrgyzstan"), + LAO_PEOPLES_DEMOCRATIC_REPUBLIC(418, "LA", "Lao People's Democratic Republic"), + LATVIA(428, "LV", "Latvia"), + LEBANON(422, "LB", "Lebanon"), + LESOTHO(426, "LS", "Lesotho"), + LIBERIA(430, "LR", "Liberia"), + LIBYA(434, "LY", "Libya"), + LIECHTENSTEIN(438, "LI", "Liechtenstein"), + LITHUANIA(440, "LT", "Lithuania"), + LUXEMBOURG(442, "LU", "Luxembourg"), + MACAO(446, "MO", "Macao"), + MACEDONIA_THE_FORMER_YUGOSLAV_REPUBLIC_OF( + 807, "MK", "Macedonia, the Former Yugoslav Republic of"), + MADAGASCAR(450, "MG", "Madagascar"), + MALAWI(454, "MW", "Malawi"), + MALAYSIA(458, "MY", "Malaysia"), + MALDIVES(462, "MV", "Maldives"), + MALI(466, "ML", "Mali"), + MALTA(470, "MT", "Malta"), + MARSHALL_ISLANDS(584, "MH", "Marshall Islands"), + MARTINIQUE(474, "MQ", "Martinique"), + MAURITANIA(478, "MR", "Mauritania"), + MAURITIUS(480, "MU", "Mauritius"), + MAYOTTE(482, "YT", "Mayotte"), + MEXICO(484, "MX", "Mexico"), + MICRONESIA_FEDERATED_STATES_OF(583, "FM", "Micronesia, Federated States of"), + MOLDOVA_REPUBLIC_OF(498, "MD", "Moldova, Republic of"), + MONACO(492, "MC", "Monaco"), + MONGOLIA(496, "MN", "Mongolia"), + MONTENEGRO(499, "ME", "Montenegro"), + MONTSERRAT(500, "MS", "Montserrat"), + MOROCCO(504, "MA", "Morocco"), + MOZAMBIQUE(508, "MZ", "Mozambique"), + MYANMAR(104, "MM", "Myanmar"), + NAMIBIA(516, "NA", "Namibia"), + NAURU(520, "NR", "Nauru"), + NEPAL(524, "NP", "Nepal"), + NETHERLANDS(528, "NL", "Netherlands"), + NEW_CALEDONIA(540, "NC", "New Caledonia"), + NEW_ZEALAND(554, "NZ", "New Zealand"), + NICARAGUA(558, "NI", "Nicaragua"), + NIGER(562, "NE", "Niger"), + NIGERIA(566, "NG", "Nigeria"), + NIUE(570, "NU", "Niue"), + NORFOLK_ISLAND(574, "NF", "Norfolk Island"), + NORTHERN_MARIANA_ISLANDS(580, "MP", "Northern Mariana Islands"), + NORWAY(578, "NO", "Norway"), + OMAN(512, "OM", "Oman"), + PAKISTAN(586, "PK", "Pakistan"), + PALAU(585, "PW", "Palau"), + PALESTINE_STATE_OF(275, "PS", "Palestine, State of"), + PANAMA(590, "PA", "Panama"), + PAPUA_NEW_GUINEA(598, "PG", "Papua New Guinea"), + PARAGUAY(600, "PY", "Paraguay"), + PERU(604, "PE", "Peru"), + PHILIPPINES(608, "PH", "Philippines"), + PITCAIRN(612, "PN", "Pitcairn"), + POLAND(616, "PL", "Poland"), + PORTUGAL(620, "PT", "Portugal"), + PUERTO_RICO(630, "PR", "Puerto Rico"), + QATAR(634, "QA", "Qatar"), + REUNION(638, "RE", "Reunion"), + ROMANIA(642, "RO", "Romania"), + RUSSIAN_FEDERATION(643, "RU", "Russian Federation"), + RWANDA(646, "RW", "Rwanda"), + SAINT_BARTHELEMY(652, "BL", "Saint Barthélemy"), + SAINT_HELENA_ASCENSION_AND_TRISTAN_DA_CUNHA( + 654, "SH", "Saint Helena, Ascension and Tristan da Cunha"), + SAINT_KITTS_AND_NEVIS(659, "KN", "Saint Kitts and Nevis"), + SAINT_LUCIA(662, "LC", "Saint Lucia"), + SAINT_MARTIN_FRENCH_PART(663, "MF", "Saint Martin (French part)"), + SAINT_PIERRE_AND_MIQUELON(666, "PM", "Saint Pierre and Miquelon"), + SAINT_VINCENT_AND_THE_GRENADINES(670, "VC", "Saint Vincent and the Grenadines"), + SAMOA(672, "WS", "Samoa"), + SAN_MARINO(674, "SM", "San Marino"), + SAO_TOME_AND_PRINCIPE(678, "ST", "Sao Tome and Principe"), + SAUDI_ARABIA(682, "SA", "Saudi Arabia"), + SENEGAL(686, "SN", "Senegal"), + SERBIA(688, "RS", "Serbia"), + SEYCHELLES(690, "SC", "Seychelles"), + SIERRA_LEONE(694, "SL", "Sierra Leone"), + SINGAPORE(702, "SG", "Singapore"), + SINT_MAARTEN_DUTCH_PART(534, "SX", "Sint Maarten (Dutch part)"), + SLOVAKIA(703, "SK", "Slovakia"), + SLOVENIA(705, "SI", "Slovenia"), + SOLOMON_ISLANDS(90, "SB", "Solomon Islands"), + SOMALIA(706, "SO", "Somalia"), + SOUTH_AFRICA(710, "ZA", "South Africa"), + SOUTH_GEORGIA_AND_THE_SOUTH_SANDWICH_ISLANDS( + 239, "GS", "South Georgia and the South Sandwich Islands"), + SOUTH_SUDAN(728, "SS", "South Sudan"), + SPAIN(724, "ES", "Spain"), + SRI_LANKA(144, "LK", "Sri Lanka"), + SUDAN(729, "SD", "Sudan"), + SURINAME(740, "SR", "Suriname"), + SVALBARD_AND_JAN_MAYEN(744, "SJ", "Svalbard and Jan Mayen"), + SWAZILAND(748, "SZ", "Swaziland"), // Now Eswatini, SZ remains valid + SWEDEN(752, "SE", "Sweden"), + SWITZERLAND(756, "CH", "Switzerland"), + SYRIAN_ARAB_REPUBLIC(760, "SY", "Syrian Arab Republic"), + TAIWAN_PROVINCE_OF_CHINA(158, "TW", "Taiwan, Province of China"), + TAJIKISTAN(762, "TJ", "Tajikistan"), + TANZANIA_UNITED_REPUBLIC_OF(834, "TZ", "Tanzania, United Republic of"), + THAILAND(764, "TH", "Thailand"), + TIMOR_LESTE(626, "TL", "Timor-Leste"), + TOGO(768, "TG", "Togo"), + TOKELAU(772, "TK", "Tokelau"), + TONGA(776, "TO", "Tonga"), + TRINIDAD_AND_TOBAGO(780, "TT", "Trinidad and Tobago"), + TUNISIA(788, "TN", "Tunisia"), + TURKEY(792, "TR", "Turkey"), + TURKMENISTAN(795, "TM", "Turkmenistan"), + TURKS_AND_CAICOS_ISLANDS(796, "TC", "Turks and Caicos Islands"), + TUVALU(798, "TV", "Tuvalu"), + UGANDA(800, "UG", "Uganda"), + UKRAINE(804, "UA", "Ukraine"), + UNITED_ARAB_EMIRATES(784, "AE", "United Arab Emirates"), + UNITED_KINGDOM(826, "GB", "United Kingdom"), + UNITED_STATES(840, "US", "United States"), + UNITED_STATES_MINOR_OUTLYING_ISLANDS(581, "UM", "United States Minor Outlying Islands"), + URUGUAY(858, "UY", "Uruguay"), + UZBEKISTAN(860, "UZ", "Uzbekistan"), + VANUATU(548, "VU", "Vanuatu"), + VENEZUELA_BOLIVARIAN_REPUBLIC_OF(862, "VE", "Venezuela, Bolivarian Republic of"), + VIET_NAM(704, "VN", "Viet Nam"), + VIRGIN_ISLANDS_BRITISH(92, "VG", "Virgin Islands, British"), + VIRGIN_ISLANDS_US(850, "VI", "Virgin Islands, U.S."), + WALLIS_AND_FUTUNA(876, "WF", "Wallis and Futuna"), + WESTERN_SAHARA(732, "EH", "Western Sahara"), + YEMEN(887, "YE", "Yemen"), + ZAMBIA(894, "ZM", "Zambia"), + ZIMBABWE(716, "ZW", "Zimbabwe"); + + private static final Map BY_NUMERIC_CODE = new HashMap<>(); + private static final Map BY_ISO_CODE = new HashMap<>(); + + static { + for (var e : values()) { + BY_NUMERIC_CODE.put(e.numericCode, e); + BY_ISO_CODE.put(e.isoCode, e); + } + } + + private final Integer numericCode; + private final String isoCode; + private final String label; + + Country(Integer numericCode, String isoCode, String label) { + this.numericCode = numericCode; + this.isoCode = isoCode; + this.label = label; + } + + /** + * Get country from numerical iso code. + * + * @param numericCode Iso 3166-1 Numerical code. + */ + public static Country fromNumericCode(Integer numericCode) { + return BY_NUMERIC_CODE.get(numericCode); + } + + /** + * Get country from alpha 2 iso code. + * + * @param isoCode Iso 3166-1 alpha 2 code. + */ + public static Country fromIsoCode(String isoCode) { + return BY_ISO_CODE.get(isoCode); + } + + public Integer getIsoNumericCode() { + return numericCode; + } + + public String getIsoAlpha2Code() { + return isoCode; + } + + public String getLabel() { + return label; + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Fingerprint.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Fingerprint.java new file mode 100644 index 0000000..55d039d --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Fingerprint.java @@ -0,0 +1,12 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record Fingerprint(String fingerprint) { + public Fingerprint { + if (fingerprint.isBlank()) { + throw new IllegalStateException("CreateFingerprint cannot be blank"); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Locale.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Locale.java new file mode 100644 index 0000000..e75e195 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Locale.java @@ -0,0 +1,21 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record Locale(String lang, String region) { + public Locale { + if (lang.isEmpty()) { + throw new IllegalStateException("Language must not be empty"); + } + if (region.isEmpty()) { + throw new IllegalStateException("Region must not be empty"); + } + if (lang.length() != 2) { + throw new IllegalStateException("Language must 2 chars length"); + } + if (region.length() != 2) { + throw new IllegalStateException("Region must be 2 chars length"); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/OperatingSystemInfo.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/OperatingSystemInfo.java new file mode 100644 index 0000000..26e88c6 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/OperatingSystemInfo.java @@ -0,0 +1,32 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record OperatingSystemInfo(String classType, String name, String version) { + public OperatingSystemInfo { + if (classType.isEmpty()) { + throw new IllegalStateException("classType cannot be empty"); + } + + if (classType.length() > 255) { + throw new IllegalStateException("classType length exceeds 255 characters"); + } + + if (name.isEmpty()) { + throw new IllegalStateException("name cannot be empty"); + } + + if (name.length() > 255) { + throw new IllegalStateException("name length exceeds 255 characters"); + } + + if (version.isEmpty()) { + throw new IllegalStateException("version cannot be empty"); + } + + if (version.length() > 255) { + throw new IllegalStateException("version length exceeds 255 characters"); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/RawUserAgent.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/RawUserAgent.java new file mode 100644 index 0000000..b3d73bc --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/RawUserAgent.java @@ -0,0 +1,9 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +public record RawUserAgent(String value) { + public RawUserAgent { + if (value.isBlank()) { + throw new IllegalStateException("Value cannot be blank."); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Referrer.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Referrer.java new file mode 100644 index 0000000..a5aaebe --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/Referrer.java @@ -0,0 +1,23 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import jakarta.persistence.Embeddable; +import java.net.URI; + +@Embeddable +public record Referrer(String host, String path) { + public Referrer { + if (host.isBlank()) { + throw new IllegalStateException("Host cannot be empty"); + } + if (host.length() > 255) { + throw new IllegalStateException("Host cannot be longer than 255 characters"); + } + if (path.length() > 255) { + throw new IllegalStateException("Path cannot be longer than 255 characters"); + } + } + + public static Referrer of(URI uri) { + return new Referrer(uri.getHost(), uri.getPath()); + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/ScreenProperties.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/ScreenProperties.java new file mode 100644 index 0000000..97e5160 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/ScreenProperties.java @@ -0,0 +1,18 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import jakarta.persistence.Embeddable; + +@Embeddable +public record ScreenProperties(Integer height, Integer width, Integer dpi) { + public ScreenProperties { + if (height <= 0) { + throw new IllegalStateException("Height cannot be zero or negative"); + } + if (width <= 0) { + throw new IllegalStateException("Width cannot be zero or negative"); + } + if (dpi <= 0) { + throw new IllegalStateException("Dpi cannot be zero or negative"); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/UserType.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/UserType.java new file mode 100644 index 0000000..abd5a17 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/UserType.java @@ -0,0 +1,9 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +public enum UserType { + UNKNOWN, + HUMAN, + CRAWLER, + SPIDER, + BOT, +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/package-info.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/package-info.java new file mode 100644 index 0000000..242f5ab --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.domain.valueobject; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLine.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLine.java new file mode 100644 index 0000000..69a0c8b --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLine.java @@ -0,0 +1,9 @@ +package tech.pegase.z3.statistics.domain.valueobject.statistics; + +public record StatisticLine(String value, Integer count) { + public StatisticLine { + if (value.isBlank()) { + throw new IllegalStateException("Statistic line cannot be empty"); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLinePerInterval.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLinePerInterval.java new file mode 100644 index 0000000..fab00f1 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLinePerInterval.java @@ -0,0 +1,11 @@ +package tech.pegase.z3.statistics.domain.valueobject.statistics; + +import java.time.ZonedDateTime; + +public record StatisticLinePerInterval(String value, ZonedDateTime interval, Integer count) { + public StatisticLinePerInterval { + if (value.isBlank()) { + throw new IllegalStateException("Statistic line per interval cannot be empty"); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineWithEntity.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineWithEntity.java new file mode 100644 index 0000000..45ea34c --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineWithEntity.java @@ -0,0 +1,12 @@ +package tech.pegase.z3.statistics.domain.valueobject.statistics; + +public record StatisticLineWithEntity(String value, String entity, Integer count) { + public StatisticLineWithEntity { + if (value.isBlank()) { + throw new IllegalStateException("Value cannot be empty"); + } + if (entity.isBlank()) { + throw new IllegalStateException("Entity cannot be empty"); + } + } +} diff --git a/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/package-info.java b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/package-info.java new file mode 100644 index 0000000..1a67f54 --- /dev/null +++ b/z3-statistics-domain/src/main/java/tech/pegase/z3/statistics/domain/valueobject/statistics/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.domain.valueobject.statistics; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-domain/src/main/resources/META-INF/beans.xml b/z3-statistics-domain/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000..e69de29 diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/entity/LinkStatisticTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/entity/LinkStatisticTest.java new file mode 100644 index 0000000..2226cb1 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/entity/LinkStatisticTest.java @@ -0,0 +1,113 @@ +package tech.pegase.z3.statistics.domain.entity; + +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.valueobject.BrowserInfo; +import tech.pegase.z3.statistics.domain.valueobject.Country; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.OperatingSystemInfo; +import tech.pegase.z3.statistics.domain.valueobject.Referrer; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +public class LinkStatisticTest { + @Test + public void canCreateLinkStatistic() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var source = new Source("source"); + var linkWasValid = true; + var brand = "brand"; + var catalog = "catalog"; + var visitedAt = ZonedDateTime.now(); + var browser = new BrowserInfo("classType", "Name", "Version"); + var country = Country.AFGHANISTAN; + var locale = new Locale("en", "US"); + var os = new OperatingSystemInfo("classType", "Name", "Version"); + var referrer = new Referrer("https://google.com", "/hello"); + var screenProperties = new ScreenProperties(1080, 1920, 1); + var userType = UserType.UNKNOWN; + var doNotTrack = false; + var fingerprint = new Fingerprint("fingerprint"); + + // WHEN + new LinkStatistic( + source, + linkWasValid, + doNotTrack, + userType, + visitedAt, + brand, + catalog, + fingerprint, + screenProperties, + country, + locale, + os, + browser, + referrer); + }); + } + + @Test + public void canUseLinkStatisticGetters() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var source = new Source("source"); + var linkWasValid = true; + var brand = "brand"; + var catalog = "catalog"; + var visitedAt = ZonedDateTime.now(); + var browser = new BrowserInfo("classType", "Name", "Version"); + var country = Country.AFGHANISTAN; + var locale = new Locale("en", "US"); + var os = new OperatingSystemInfo("classType", "Name", "Version"); + var referrer = new Referrer("https://google.com", "/hello"); + var screenProperties = new ScreenProperties(1080, 1920, 1); + var userType = UserType.UNKNOWN; + var doNotTrack = false; + var fingerprint = new Fingerprint("fingerprint"); + var linkstatistic = + new LinkStatistic( + source, + linkWasValid, + doNotTrack, + userType, + visitedAt, + brand, + catalog, + fingerprint, + screenProperties, + country, + locale, + os, + browser, + referrer); + + // WHEN + Assertions.assertNotNull(linkstatistic.getId()); + Assertions.assertNotNull(linkstatistic.getUuid()); + Assertions.assertEquals(source, linkstatistic.getLinkSource()); + Assertions.assertEquals(brand, linkstatistic.getBrand()); + Assertions.assertEquals(catalog, linkstatistic.getCatalog()); + Assertions.assertEquals(screenProperties, linkstatistic.getScreen()); + Assertions.assertEquals(doNotTrack, linkstatistic.getDoNotTrack()); + Assertions.assertEquals(fingerprint, linkstatistic.getFingerprint()); + Assertions.assertEquals(linkWasValid, linkstatistic.getLinkWasValid()); + Assertions.assertEquals(visitedAt, linkstatistic.getVisitedAt()); + Assertions.assertEquals(browser, linkstatistic.getBrowser()); + Assertions.assertEquals(referrer, linkstatistic.getReferrer()); + Assertions.assertEquals(country, linkstatistic.getCountry()); + Assertions.assertEquals(locale, linkstatistic.getLocale()); + Assertions.assertEquals(os, linkstatistic.getOperatingSystem()); + Assertions.assertEquals(userType, linkstatistic.getUserType()); + }); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/lib/LinkStatisticsBuilderTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/lib/LinkStatisticsBuilderTest.java new file mode 100644 index 0000000..964b9a5 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/lib/LinkStatisticsBuilderTest.java @@ -0,0 +1,87 @@ +package tech.pegase.z3.statistics.domain.lib; + +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.lib.link.statistic.builder.LinkStatisticBuilder; +import tech.pegase.z3.statistics.domain.valueobject.BrowserInfo; +import tech.pegase.z3.statistics.domain.valueobject.Country; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.OperatingSystemInfo; +import tech.pegase.z3.statistics.domain.valueobject.Referrer; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +public class LinkStatisticsBuilderTest { + + @Test + public void canBuildLinkStatistics() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var source = new Source("source"); + var linkWasValid = true; + var doNotTrack = false; + var fingerprint = new Fingerprint("fingerprint"); + var visitedAt = ZonedDateTime.now(); + var browser = new BrowserInfo("classType", "Name", "Version"); + var country = Country.AFGHANISTAN; + var locale = new Locale("en", "US"); + var os = new OperatingSystemInfo("classType", "Name", "Version"); + var referrer = new Referrer("https://google.com", "/hello"); + var screenProperties = new ScreenProperties(1080, 1920, 1); + var userType = UserType.UNKNOWN; + + // WHEN + LinkStatisticBuilder.make(source, linkWasValid, visitedAt) + .setCountry(country) + .setLocale(locale) + .setReferer(referrer) + .setOs(os) + .setBrowser(browser) + .setUserType(userType) + .setScreen(screenProperties) + .setDoNotTrack(doNotTrack) + .setFingerprint(fingerprint) + .make(); + }); + } + + @Test + public void canBuildLinkStatisticsSettingDoNotTrack() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var source = new Source("source"); + var linkWasValid = true; + var fingerprint = new Fingerprint("fingerprint"); + var visitedAt = ZonedDateTime.now(); + var browser = new BrowserInfo("classType", "Name", "Version"); + var country = Country.AFGHANISTAN; + var doNotTrack = false; + var locale = new Locale("en", "US"); + var os = new OperatingSystemInfo("classType", "Name", "Version"); + var referrer = new Referrer("https://google.com", "/hello"); + var screenProperties = new ScreenProperties(1080, 1920, 1); + var userType = UserType.UNKNOWN; + + // WHEN + var builder = + LinkStatisticBuilder.make(source, linkWasValid, visitedAt) + .setCountry(country) + .setLocale(locale) + .setReferer(referrer) + .setOs(os) + .setBrowser(browser) + .setUserType(userType) + .setScreen(screenProperties) + .setDoNotTrack(doNotTrack) + .setFingerprint(fingerprint); + builder.make(); + }); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLinkImplTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLinkImplTest.java new file mode 100644 index 0000000..0d9cb24 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/links/VisitLinkImplTest.java @@ -0,0 +1,112 @@ +package tech.pegase.z3.statistics.domain.usecase.links; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.time.ZonedDateTime; +import java.util.Optional; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.entity.LinkStatistic; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.port.service.GeoIpService; +import tech.pegase.z3.statistics.domain.port.service.UserAgentService; +import tech.pegase.z3.statistics.domain.port.service.UserAgentService.UserAgentInfo; +import tech.pegase.z3.statistics.domain.usecase.links.VisitLink.Input; +import tech.pegase.z3.statistics.domain.usecase.links.VisitLink.Input.AddVisitFor.AdditionalInputs; +import tech.pegase.z3.statistics.domain.usecase.links.VisitLink.Output; +import tech.pegase.z3.statistics.domain.valueobject.BrowserInfo; +import tech.pegase.z3.statistics.domain.valueobject.Country; +import tech.pegase.z3.statistics.domain.valueobject.Fingerprint; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.OperatingSystemInfo; +import tech.pegase.z3.statistics.domain.valueobject.RawUserAgent; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +public class VisitLinkImplTest { + private final LinkStatisticRepository linkStatisticRepository; + private final GeoIpService geoIpService; + private final UserAgentService userAgentService; + private final VisitLink visitLink; + + VisitLinkImplTest() { + this.linkStatisticRepository = Mockito.mock(LinkStatisticRepository.class); + this.geoIpService = Mockito.mock(GeoIpService.class); + this.userAgentService = Mockito.mock(UserAgentService.class); + this.visitLink = new VisitLinkImpl(linkStatisticRepository, geoIpService, userAgentService); + } + + @Test + public void canAddVisit() { + // GIVEN + var source = new Source("source"); + var linkWasValid = false; + var visitedAt = ZonedDateTime.now(); + var additionalInputs = + new AdditionalInputs(null, null, null, null, null, null, null, null, null); + + Mockito.doNothing().when(linkStatisticRepository).persist(Mockito.any(LinkStatistic.class)); + + // WHEN + var output = + visitLink.run(new Input.AddVisitFor(source, linkWasValid, visitedAt, additionalInputs)); + + // THEN + Mockito.verify(linkStatisticRepository).persist(Mockito.any(LinkStatistic.class)); + + Assertions.assertInstanceOf(Output.VisitAdded.class, output); + } + + @Test + public void canAddVisitWithAdditionalInputs() throws UnknownHostException { + // GIVEN + var source = new Source("source"); + var linkWasValid = false; + var brand = "brand"; + var catalog = "catalog"; + var visitedAt = ZonedDateTime.now(); + var doNotTrack = false; + var fingerprint = new Fingerprint("fingerprint"); + var inetAddress = InetAddress.getByAddress("local", new byte[] {127, 0, 0, 1}); + var locale = new Locale("fr", "FR"); + var referrerUri = URI.create("https://google.com/path"); + var rawUserAgent = new RawUserAgent("userAgent"); + var screenProperties = new ScreenProperties(1920, 1080, 1); + var additionalInputs = + new AdditionalInputs( + brand, + catalog, + doNotTrack, + fingerprint, + inetAddress, + locale, + referrerUri, + rawUserAgent, + screenProperties); + + var browser = new BrowserInfo("classType", "name", "version"); + var operatingSystem = new OperatingSystemInfo("classType", "name", "version"); + var userType = UserType.BOT; + var userAgentInfo = new UserAgentInfo(browser, operatingSystem, userType); + + var country = Country.AFGHANISTAN; + + Mockito.when(userAgentService.getUserAgentFrom(rawUserAgent)).thenReturn(userAgentInfo); + Mockito.when(geoIpService.locateInetAddress(inetAddress)).thenReturn(Optional.of(country)); + Mockito.doNothing().when(linkStatisticRepository).persist(Mockito.any(LinkStatistic.class)); + + // WHEN + var output = + visitLink.run(new Input.AddVisitFor(source, linkWasValid, visitedAt, additionalInputs)); + + // THEN + Mockito.verify(userAgentService).getUserAgentFrom(rawUserAgent); + Mockito.verify(geoIpService).locateInetAddress(inetAddress); + Mockito.verify(linkStatisticRepository).persist(Mockito.any(LinkStatistic.class)); + + Assertions.assertInstanceOf(Output.VisitAdded.class, output); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeekTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeekTest.java new file mode 100644 index 0000000..656614c --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/GetInvalidLinksCountsPerWeekTest.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.usecase.statistics.GetInvalidLinksCountsPerWeek.Input.ByBrandForThePastYear; +import tech.pegase.z3.statistics.domain.usecase.statistics.GetInvalidLinksCountsPerWeek.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +public class GetInvalidLinksCountsPerWeekTest { + private final LinkStatisticRepository linkStatisticRepository; + private final GetInvalidLinksCountsPerWeek invalidLinksPerWeek; + + GetInvalidLinksCountsPerWeekTest() { + this.linkStatisticRepository = Mockito.mock(LinkStatisticRepository.class); + this.invalidLinksPerWeek = new GetInvalidLinksCountsPerWeekImpl(linkStatisticRepository); + } + + @Test + public void canGetCountsForThePastYear() { + // GIVEN + var statisticLinesPerInterval = List.of(); + + Mockito.when( + linkStatisticRepository.getInvalidLinksPerWeeksByBrandsAndFromNow( + Mockito.any(Duration.class))) + .thenReturn(statisticLinesPerInterval); + + // WHEN + var output = invalidLinksPerWeek.run(new ByBrandForThePastYear()); + + // THEN + Mockito.verify(linkStatisticRepository) + .getInvalidLinksPerWeeksByBrandsAndFromNow(Mockito.any(Duration.class)); + Assertions.assertInstanceOf(Output.GetCounts.class, output); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrersTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrersTest.java new file mode 100644 index 0000000..98901d2 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostFrequentReferrersTest.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostFrequentReferrers.Input; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostFrequentReferrers.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLineWithEntity; + +public class MostFrequentReferrersTest { + private final LinkStatisticRepository linkStatisticRepository; + private final MostFrequentReferrers mostFrequentReferrers; + + MostFrequentReferrersTest() { + this.linkStatisticRepository = Mockito.mock(LinkStatisticRepository.class); + this.mostFrequentReferrers = new MostFrequentReferrersImpl(linkStatisticRepository); + } + + @Test + public void canGetTop30() { + // GIVEN + var statisticLinesWithEntity = List.of(); + + Mockito.when( + linkStatisticRepository.getMostUsedReferrerWithBrandFromNow( + Mockito.any(Duration.class), Mockito.anyInt())) + .thenReturn(statisticLinesWithEntity); + + // WHEN + var output = mostFrequentReferrers.run(new Input.GetTop20()); + + // THEN + Mockito.verify(linkStatisticRepository) + .getMostUsedReferrerWithBrandFromNow(Mockito.any(Duration.class), Mockito.anyInt()); + Assertions.assertInstanceOf(Output.TopList.class, output); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSourceTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSourceTest.java new file mode 100644 index 0000000..3051df3 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/MostVisitedSourceTest.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostVisitedSource.Input; +import tech.pegase.z3.statistics.domain.usecase.statistics.MostVisitedSource.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLineWithEntity; + +public class MostVisitedSourceTest { + private final LinkStatisticRepository linkStatisticRepository; + private final MostVisitedSource mostVisitedSource; + + MostVisitedSourceTest() { + this.linkStatisticRepository = Mockito.mock(LinkStatisticRepository.class); + this.mostVisitedSource = new MostVisitedSourceImpl(linkStatisticRepository); + } + + @Test + public void canGetTop30() { + // GIVEN + var statisticLinesWithEntity = List.of(); + + Mockito.when( + linkStatisticRepository.getMostVisitedSourcesWithBrandFromNow( + Mockito.any(Duration.class), Mockito.anyInt())) + .thenReturn(statisticLinesWithEntity); + + // WHEN + var output = mostVisitedSource.run(new Input.GetTop30()); + + // THEN + Mockito.verify(linkStatisticRepository) + .getMostVisitedSourcesWithBrandFromNow(Mockito.any(Duration.class), Mockito.anyInt()); + Assertions.assertInstanceOf(Output.TopList.class, output); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrandTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrandTest.java new file mode 100644 index 0000000..1bc85d5 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByBrandTest.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByBrand.Input; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByBrand.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +public class UniqueVisitorsByBrandTest { + private final LinkStatisticRepository linkStatisticRepository; + private final UniqueVisitorsByBrand uniqueVisitorsByBrand; + + UniqueVisitorsByBrandTest() { + this.linkStatisticRepository = Mockito.mock(LinkStatisticRepository.class); + this.uniqueVisitorsByBrand = new UniqueVisitorsByBrandImpl(linkStatisticRepository); + } + + @Test + public void canGetCountsForThePastYear() { + // GIVEN + var statisticLinesPerInterval = List.of(); + + Mockito.when( + linkStatisticRepository.getUniqueVisitorsPerDayForEachBrand( + Mockito.any(Duration.class))) + .thenReturn(statisticLinesPerInterval); + + // WHEN + var output = uniqueVisitorsByBrand.run(new Input.GetCountsForThePastYear()); + + // THEN + Mockito.verify(linkStatisticRepository) + .getUniqueVisitorsPerDayForEachBrand(Mockito.any(Duration.class)); + Assertions.assertInstanceOf(Output.CountsForAll.class, output); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalogTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalogTest.java new file mode 100644 index 0000000..7e68a14 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/statistics/UniqueVisitorsByCatalogTest.java @@ -0,0 +1,40 @@ +package tech.pegase.z3.statistics.domain.usecase.statistics; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByCatalog.Input; +import tech.pegase.z3.statistics.domain.usecase.statistics.UniqueVisitorsByCatalog.Output; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; + +public class UniqueVisitorsByCatalogTest { + private final LinkStatisticRepository linkStatisticRepository; + private final UniqueVisitorsByCatalog uniqueVisitorsByCatalog; + + UniqueVisitorsByCatalogTest() { + this.linkStatisticRepository = Mockito.mock(LinkStatisticRepository.class); + this.uniqueVisitorsByCatalog = new UniqueVisitorsByCatalogImpl(linkStatisticRepository); + } + + @Test + public void canGetCountsForThePastYear() { + // GIVEN + var statisticLinesPerInterval = List.of(); + + Mockito.when( + linkStatisticRepository.getUniqueVisitorsPerDayForEachCatalog( + Mockito.any(Duration.class))) + .thenReturn(statisticLinesPerInterval); + + // WHEN + var output = uniqueVisitorsByCatalog.run(new Input.GetCountsForThePastYear()); + + // THEN + Mockito.verify(linkStatisticRepository) + .getUniqueVisitorsPerDayForEachCatalog(Mockito.any(Duration.class)); + Assertions.assertInstanceOf(Output.CountsForAll.class, output); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprintTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprintTest.java new file mode 100644 index 0000000..1acafa3 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/usecase/tracking/CreateFingerprintTest.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.statistics.domain.usecase.tracking; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import tech.pegase.z3.statistics.domain.port.service.HashService; +import tech.pegase.z3.statistics.domain.usecase.tracking.CreateFingerprint.Input; +import tech.pegase.z3.statistics.domain.usecase.tracking.CreateFingerprint.Output; +import tech.pegase.z3.statistics.domain.valueobject.Locale; +import tech.pegase.z3.statistics.domain.valueobject.ScreenProperties; + +public class CreateFingerprintTest { + private final HashService hashService; + private final CreateFingerprint createFingerPrint; + + CreateFingerprintTest() { + this.hashService = Mockito.mock(HashService.class); + this.createFingerPrint = new CreateFingerprintImpl(hashService); + } + + @Test + public void canCreateFingerprint() throws UnknownHostException { + // GIVEN + var ipAddress = InetAddress.getByAddress("local", new byte[] {127, 0, 0, 1}); + var userAgent = "userAgent"; + var screenProperties = new ScreenProperties(1920, 1080, 1); + var locale = new Locale("en", "US"); + var hashedValue = "hashedValue"; + + Mockito.when(hashService.hash(Mockito.anyString())).thenReturn(hashedValue); + + // WHEN + var output = + createFingerPrint.run(new Input.MakeFrom(ipAddress, userAgent, screenProperties, locale)); + + // THEN + Mockito.verify(hashService).hash(Mockito.anyString()); + + Assertions.assertInstanceOf(Output.GeneratedFingerprint.class, output); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/BrowserInfoTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/BrowserInfoTest.java new file mode 100644 index 0000000..d62c9e3 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/BrowserInfoTest.java @@ -0,0 +1,90 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class BrowserInfoTest { + + @Test + public void canCreateBrowserInfo() { + // Arrange + var classType = "Browser"; + var name = "Firefox"; + var version = "136b.0.2"; + + // Assert & Act + Assertions.assertDoesNotThrow(() -> new BrowserInfo(classType, name, version)); + } + + @Test + public void cannotCreateBrowserInfoWithEmptyClassType() { + // Arrange + var classType = ""; + var name = "Firefox"; + var version = "136b.0.2"; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new BrowserInfo(classType, name, version)); + } + + @Test + public void cannotCreateBrowserInfoWithTooLongClassType() { + // Arrange + var classType = "Browser".repeat(256); + var name = "Firefox"; + var version = "136b.0.2"; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new BrowserInfo(classType, name, version)); + } + + @Test + public void cannotCreateBrowserInfoWithNameType() { + // Arrange + var classType = "Browser"; + var name = ""; + var version = "136b.0.2"; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new BrowserInfo(classType, name, version)); + } + + @Test + public void cannotCreateBrowserInfoWithTooLongName() { + // Arrange + var classType = "Browser"; + var name = "Firefox".repeat(256); + var version = "136b.0.2"; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new BrowserInfo(classType, name, version)); + } + + @Test + public void cannotCreateBrowserInfoWithNameVersion() { + // Arrange + var classType = "Browser"; + var name = "Firefox"; + var version = ""; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new BrowserInfo(classType, name, version)); + } + + @Test + public void cannotCreateBrowserInfoWithTooLongVersion() { + // Arrange + var classType = "Browser"; + var name = "Firefox"; + var version = "136b.0.2".repeat(256); + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new BrowserInfo(classType, name, version)); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/CountryTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/CountryTest.java new file mode 100644 index 0000000..45be440 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/CountryTest.java @@ -0,0 +1,66 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class CountryTest { + @Test + public void canGetCountryFromIsoCode() { + // GIVEN + var isoCode = "US"; + + // WHEN + var country = Country.fromIsoCode(isoCode); + + // THEN + Assertions.assertEquals(Country.UNITED_STATES, country); + } + + @Test + public void canGetCountryFromNumericIsoCode() { + // GIVEN + var isoCode = 840; + + // WHEN + var country = Country.fromNumericCode(isoCode); + + // THEN + Assertions.assertEquals(Country.UNITED_STATES, country); + } + + @Test + public void canGetIsoNumericCodeFromCountry() { + // GIVEN + var country = Country.UNITED_STATES; + + // WHEN + var isoNumericCode = country.getIsoNumericCode(); + + // THEN + Assertions.assertEquals(840, isoNumericCode); + } + + @Test + public void canGetIsoCodeFromCountry() { + // GIVEN + var country = Country.UNITED_STATES; + + // WHEN + var isoNumericCode = country.getIsoAlpha2Code(); + + // THEN + Assertions.assertEquals("US", isoNumericCode); + } + + @Test + public void canGetLabelFromCountry() { + // GIVEN + var country = Country.UNITED_STATES; + + // WHEN + var isoNumericCode = country.getLabel(); + + // THEN + Assertions.assertEquals("United States", isoNumericCode); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/FingerprintTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/FingerprintTest.java new file mode 100644 index 0000000..bc0732f --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/FingerprintTest.java @@ -0,0 +1,25 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class FingerprintTest { + + @Test + public void fingerprintCanBeCreated() { + // Act + var rawFingerprint = "fingerprint"; + + // Act & Assert + Assertions.assertDoesNotThrow(() -> new Fingerprint(rawFingerprint)); + } + + @Test + public void cannotCreateFingerprintWithValue() { + // Act + var rawFingerprint = ""; + + // Act & Assert + Assertions.assertThrows(IllegalStateException.class, () -> new Fingerprint(rawFingerprint)); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/LocaleTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/LocaleTest.java new file mode 100644 index 0000000..8f1dc7b --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/LocaleTest.java @@ -0,0 +1,57 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class LocaleTest { + + @Test + public void localeCanBeCreated() { + // Act + var lang = "fr"; + var region = "FR"; + + // Act & Assert + Assertions.assertDoesNotThrow(() -> new Locale(lang, region)); + } + + @Test + public void cannotCreateLocaleWithEmptyLanguage() { + // Act + var lang = ""; + var region = "FR"; + + // Act & Assert + Assertions.assertThrows(IllegalStateException.class, () -> new Locale(lang, region)); + } + + @Test + public void cannotCreateLocaleWithWrongLengthLanguage() { + // Act + var lang = "fra"; + var region = "FR"; + + // Act & Assert + Assertions.assertThrows(IllegalStateException.class, () -> new Locale(lang, region)); + } + + @Test + public void cannotCreateLocaleWithEmptyRegion() { + // Act + var lang = "fr"; + var region = ""; + + // Act & Assert + Assertions.assertThrows(IllegalStateException.class, () -> new Locale(lang, region)); + } + + @Test + public void cannotCreateLocaleWithWrongLengthRegion() { + // Act + var lang = "fr"; + var region = "FRA"; + + // Act & Assert + Assertions.assertThrows(IllegalStateException.class, () -> new Locale(lang, region)); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/OperatingSystemInfoTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/OperatingSystemInfoTest.java new file mode 100644 index 0000000..891e13e --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/OperatingSystemInfoTest.java @@ -0,0 +1,89 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class OperatingSystemInfoTest { + @Test + public void canCreateOperatingSystemInfo() { + // Arrange + var classType = "Desktop"; + var name = "Linux"; + var version = "6.2"; + + // Assert & Act + Assertions.assertDoesNotThrow(() -> new OperatingSystemInfo(classType, name, version)); + } + + @Test + public void cannotCreateOperatingSystemInfoWithEmptyClassType() { + // Arrange + var classType = ""; + var name = "Linux"; + var version = "6.2"; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new OperatingSystemInfo(classType, name, version)); + } + + @Test + public void cannotCreateOperatingSystemInfoWithTooLongClassType() { + // Arrange + var classType = "Desktop".repeat(256); + var name = "Linux"; + var version = "6.2"; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new OperatingSystemInfo(classType, name, version)); + } + + @Test + public void cannotCreateOperatingSystemInfoWithNameType() { + // Arrange + var classType = "Desktop"; + var name = ""; + var version = "6.2"; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new OperatingSystemInfo(classType, name, version)); + } + + @Test + public void cannotCreateOperatingSystemInfoWithTooLongName() { + // Arrange + var classType = "Desktop"; + var name = "Linux".repeat(256); + var version = "6.2"; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new OperatingSystemInfo(classType, name, version)); + } + + @Test + public void cannotCreateOperatingSystemInfoWithNameVersion() { + // Arrange + var classType = "Desktop"; + var name = "Linux"; + var version = ""; + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new OperatingSystemInfo(classType, name, version)); + } + + @Test + public void cannotCreateOperatingSystemInfoWithTooLongVersion() { + // Arrange + var classType = "Desktop"; + var name = "Linux"; + var version = "6.2".repeat(256); + + // Assert & Act + Assertions.assertThrows( + IllegalStateException.class, () -> new OperatingSystemInfo(classType, name, version)); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/RawUserAgentTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/RawUserAgentTest.java new file mode 100644 index 0000000..471731a --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/RawUserAgentTest.java @@ -0,0 +1,33 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class RawUserAgentTest { + @Test + public void canCreateRawUserAgent() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var rawUserAgent = "userAgent"; + + // WHEN + new RawUserAgent(rawUserAgent); + }); + } + + @Test + public void cannotCreateRawUserAgentWithEmptyValue() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var rawUserAgent = ""; + + // WHEN + new RawUserAgent(rawUserAgent); + }); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/ReferrerTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/ReferrerTest.java new file mode 100644 index 0000000..09458e5 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/ReferrerTest.java @@ -0,0 +1,67 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import java.net.URI; +import java.net.URISyntaxException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ReferrerTest { + @Test + public void canCreateReferer() { + // GIVEN + var host = "google.com"; + var path = "/path"; + + // WHEN & THEN + Assertions.assertDoesNotThrow(() -> new Referrer(host, path)); + } + + @Test + public void canCreateRefererFromUri() throws URISyntaxException { + // GIVEN + var uri = new URI("http://google.com/chemin"); + + // WHEN & THEN + Assertions.assertDoesNotThrow(() -> Referrer.of(uri)); + } + + @Test + public void cannotCreateRefererWithEmptyHost() { + // GIVEN + var host = ""; + var path = "/path"; + + // WHEN & THEN + Assertions.assertThrows(IllegalStateException.class, () -> new Referrer(host, path)); + } + + @Test + public void canCreateRefererWithEmptyPath() { + // GIVEN + var host = "google.com"; + var path = ""; + + // WHEN & THEN + Assertions.assertDoesNotThrow(() -> new Referrer(host, path)); + } + + @Test + public void cannotCreateRefererWithTooLongHost() { + // GIVEN + var host = "too-long".repeat(255) + ".com"; + var path = "/path"; + + // WHEN & THEN + Assertions.assertThrows(IllegalStateException.class, () -> new Referrer(host, path)); + } + + @Test + public void cannotCreateRefererWithTooLongPath() { + // GIVEN + var host = "google.com"; + var path = "/path" + "too-long".repeat(255); + + // WHEN & THEN + Assertions.assertThrows(IllegalStateException.class, () -> new Referrer(host, path)); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/ScreenPropertiesTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/ScreenPropertiesTest.java new file mode 100644 index 0000000..80586bd --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/ScreenPropertiesTest.java @@ -0,0 +1,53 @@ +package tech.pegase.z3.statistics.domain.valueobject; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ScreenPropertiesTest { + @Test + public void canCreateScreenProperties() { + // GIVEN + var height = 1; + var width = 1; + var dpi = 1; + + // WHEN & THEN + Assertions.assertDoesNotThrow(() -> new ScreenProperties(height, width, dpi)); + } + + @Test + public void cannotCreateWithZeroHeight() { + // GIVEN + var height = 0; + var width = 1; + var dpi = 1; + + // WHEN & THEN + Assertions.assertThrows( + IllegalStateException.class, () -> new ScreenProperties(height, width, dpi)); + } + + @Test + public void cannotCreateWithZeroWidth() { + // GIVEN + var height = 1; + var width = 0; + var dpi = 1; + + // WHEN & THEN + Assertions.assertThrows( + IllegalStateException.class, () -> new ScreenProperties(height, width, dpi)); + } + + @Test + public void cannotCreateWithZeroDpi() { + // GIVEN + var height = 1; + var width = 1; + var dpi = 0; + + // WHEN & THEN + Assertions.assertThrows( + IllegalStateException.class, () -> new ScreenProperties(height, width, dpi)); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLinePerIntervalTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLinePerIntervalTest.java new file mode 100644 index 0000000..79f616b --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLinePerIntervalTest.java @@ -0,0 +1,38 @@ +package tech.pegase.z3.statistics.domain.valueobject.statistics; + +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class StatisticLinePerIntervalTest { + @Test + public void canCreateStatisticLinePerInterval() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var value = "value"; + var interval = ZonedDateTime.now(); + var count = 1; + + // WHEN + new StatisticLinePerInterval(value, interval, count); + }); + } + + @Test + public void cannotCreateStatisticLinePerIntervalWithEmptyName() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var value = ""; + var interval = ZonedDateTime.now(); + var count = 1; + + // WHEN + new StatisticLinePerInterval(value, interval, count); + }); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineTest.java new file mode 100644 index 0000000..2994ec2 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineTest.java @@ -0,0 +1,35 @@ +package tech.pegase.z3.statistics.domain.valueobject.statistics; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class StatisticLineTest { + @Test + public void canCreateStatisticLine() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var value = "value"; + var count = 1; + + // WHEN + new StatisticLine(value, count); + }); + } + + @Test + public void cannotCreateStatisticLineWithEmptyName() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var value = ""; + var count = 1; + + // WHEN + new StatisticLine(value, count); + }); + } +} diff --git a/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineWithEntityTest.java b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineWithEntityTest.java new file mode 100644 index 0000000..ba09929 --- /dev/null +++ b/z3-statistics-domain/src/test/java/tech/pegase/z3/statistics/domain/valueobject/statistics/StatisticLineWithEntityTest.java @@ -0,0 +1,53 @@ +package tech.pegase.z3.statistics.domain.valueobject.statistics; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class StatisticLineWithEntityTest { + @Test + public void canCreateStatisticLineWithEntity() { + // THEN + Assertions.assertDoesNotThrow( + () -> { + // GIVEN + var value = "value"; + var entity = "entity"; + var count = 1; + + // WHEN + new StatisticLineWithEntity(value, entity, count); + }); + } + + @Test + public void cannotCreateStatisticLineWithEntityUsingEmptyName() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var value = ""; + var entity = "entity"; + var count = 1; + + // WHEN + new StatisticLineWithEntity(value, entity, count); + }); + } + + @Test + public void cannotCreateStatisticLineWithEntityUsingEmptyEntity() { + // THEN + Assertions.assertThrows( + IllegalStateException.class, + () -> { + // GIVEN + var value = "value"; + var entity = ""; + var count = 1; + + // WHEN + new StatisticLineWithEntity(value, entity, count); + }); + } +} diff --git a/z3-statistics-infrastructure/pom.xml b/z3-statistics-infrastructure/pom.xml new file mode 100644 index 0000000..07da033 --- /dev/null +++ b/z3-statistics-infrastructure/pom.xml @@ -0,0 +1,174 @@ + + + z3-statistics-infrastructure + + + + + jacoco-maven-plugin + + + + + + true + ${project.build.directory}/coverage-reports/jacoco-ut.exec + surefire.jacoco.args + + + prepare-agent + + pre-unit-test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + ${project.build.directory}/coverage-jacoco/unit-tests + + + + report + + post-unit-test + test + + + + + + ${project.build.directory}/coverage-reports/jacoco-ut.exec + + + BUNDLE + + + LINE + 0.10 + COVEREDRATIO + + + + + + + check + + jacoco-check-ut + verify + + + + org.jacoco + ${jacoco.version} + + + maven-surefire-plugin + + + -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + ${surefire.jacoco.args} -XX:+EnableDynamicAgentLoading -Xmx1024m -Xshare:off + + + + **/*IT.java + + + org.jboss.logmanager.LogManager + ${maven.home} + + + ${surefire-plugin.version} + + + + + + + z3-statistics-domain + tech.pegase.z3 + ${z3.version} + + + z3-shared + tech.pegase.z3 + ${z3.version} + + + quarkus-hibernate-orm + io.quarkus + + + quarkus-junit5-mockito + io.quarkus + test + + + quarkus-jacoco + io.quarkus + test + + + quarkus-jdbc-postgresql + io.quarkus + + + blaze-persistence-integration-quarkus-3 + com.blazebit + ${blaze-persistence.version} + + + blaze-persistence-integration-hibernate-6.2 + com.blazebit + runtime + ${blaze-persistence.version} + + + yauaa + nl.basjes.parse.useragent + ${yauaa-useragent.version} + + + + log4j-core + org.apache.logging.log4j + ${log4j2.version} + + + + crawler-detect + org.nekosoft.utils + ${crawler-detect.version} + + + + geoip2 + com.maxmind.geoip2 + ${maxmind-geoip2.version} + + + + bcpkix-jdk18on + org.bouncycastle + ${bouncycastle-bcpkix.version} + + + + 4.0.0 + + + z3 + tech.pegase.z3 + 1.1.0-SNAPSHOT + + + + 22 + 22 + UTF-8 + + + \ No newline at end of file diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/repository/link/statistic/LinkStatisticDatabaseRepository.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/repository/link/statistic/LinkStatisticDatabaseRepository.java new file mode 100644 index 0000000..f779197 --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/repository/link/statistic/LinkStatisticDatabaseRepository.java @@ -0,0 +1,329 @@ +package tech.pegase.z3.statistics.infrastructure.adapter.repository.link.statistic; + +import com.blazebit.persistence.CriteriaBuilder; +import com.blazebit.persistence.CriteriaBuilderFactory; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheName; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.NoResultException; +import jakarta.persistence.Tuple; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import tech.pegase.z3.shared.valueobject.Source; +import tech.pegase.z3.statistics.domain.entity.LinkStatistic; +import tech.pegase.z3.statistics.domain.port.repository.LinkStatisticRepository; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLinePerInterval; +import tech.pegase.z3.statistics.domain.valueobject.statistics.StatisticLineWithEntity; + +@ApplicationScoped +public class LinkStatisticDatabaseRepository implements LinkStatisticRepository { + private final Cache cache; + private final EntityManager em; + private final CriteriaBuilderFactory cbf; + + LinkStatisticDatabaseRepository( + @CacheName("z3-statistics-link") Cache cache, EntityManager em, CriteriaBuilderFactory cbf) { + this.cache = cache; + this.em = em; + this.cbf = cbf; + } + + @Override + public Optional findByLinkSource(Source linkSource) { + return cache + .>get( + linkSource, + k -> { + var typedQuery = query().where("linkSource").eq(linkSource).getQuery(); + try { + return Optional.of(typedQuery.getSingleResult()); + } catch (NoResultException e) { + return Optional.empty(); + } + }) + .await() + .indefinitely(); + } + + @Override + public void persist(LinkStatistic linkStatistic) { + em.persist(linkStatistic); + clearCache(); + } + + @Override + public List getUniqueVisitorsPerDayForEachBrand(Duration duration) { + List results = new ArrayList<>(); + var dateLimit = ZonedDateTime.now().minus(duration.abs()); + var maxPastDate = ZonedDateTime.now().minusYears(1); + + if (dateLimit.isBefore(maxPastDate)) { + dateLimit = maxPastDate; + } + + /* + Wanted query is : + SELECT + "brand" as "value", + date_trunc('week', "visited_at" AT TIME ZONE 'UTC') AS "interval", + COUNT(*) + FROM "z3-statistics"."link_statistics" + WHERE "brand" IS NOT NULL AND visited_at > (CURRENT_TIMESTAMP - INTERVAL '1 year') + GROUP BY "brand", "interval" + ORDER BY "brand", "interval" ASC; + */ + var typedQuery = cbf.create(em, Tuple.class).from(LinkStatistic.class); + var query = + typedQuery + .select("brand", "value") + .select("date_trunc('day', visitedAt)", "interval") + .select("COUNT(*)") + .where("brand") + .isNotNull() + .where("visitedAt") + .gt(dateLimit) + .groupBy("value") + .groupBy("interval") + .orderByAsc("value") + .orderByAsc("interval") + .getQuery(); + + query + .getResultList() + .forEach( + tuple -> + results.add( + new StatisticLinePerInterval( + tuple.get(0, String.class), + tuple.get(1, ZonedDateTime.class), + tuple.get(2, Long.class).intValue()))); + + return results; + } + + @Override + public List getUniqueVisitorsPerDayForEachCatalog(Duration duration) { + List results = new ArrayList<>(); + var dateLimit = ZonedDateTime.now().minus(duration.abs()); + var maxPastDate = ZonedDateTime.now().minusYears(1); + + if (dateLimit.isBefore(maxPastDate)) { + dateLimit = maxPastDate; + } + + /* + Wanted query is : + SELECT + "catalog" as "value", + date_trunc('day', "visited_at" AT TIME ZONE 'UTC') AS "interval", + COUNT(*) + FROM "z3-statistics"."link_statistics" + WHERE "catalog" IS NOT NULL AND visited_at > (CURRENT_TIMESTAMP - INTERVAL '1 year') + GROUP BY "catalog", "interval" + ORDER BY "catalog", "interval" ASC; + */ + var typedQuery = cbf.create(em, Tuple.class).from(LinkStatistic.class); + var query = + typedQuery + .select("catalog", "value") + .select("date_trunc('day', visitedAt)", "interval") + .select("COUNT(*)") + .where("catalog") + .isNotNull() + .where("visitedAt") + .gt(dateLimit) + .groupBy("value") + .groupBy("interval") + .orderByAsc("value") + .orderByAsc("interval") + .getQuery(); + + query + .getResultList() + .forEach( + tuple -> + results.add( + new StatisticLinePerInterval( + tuple.get(0, String.class), + tuple.get(1, ZonedDateTime.class), + tuple.get(2, Long.class).intValue()))); + + return results; + } + + @Override + public List getMostVisitedSourcesWithBrandFromNow( + Duration duration, Integer amount) { + List results = new ArrayList<>(); + var minResults = 1; + var maxResults = 50; + var resultLimit = Math.min(maxResults, Math.max(minResults, amount)); + var dateLimit = ZonedDateTime.now().minus(duration.abs()); + var maxPastDate = ZonedDateTime.now().minusYears(1); + + if (dateLimit.isBefore(maxPastDate)) { + dateLimit = maxPastDate; + } + + /* + Wanted query is : + SELECT "source", "brand", COUNT(*) + FROM "z3-statistics"."link_statistics" + WHERE + visited_at > (CURRENT_TIMESTAMP - INTERVAL '30 days') + GROUP BY "brand", "source" + ORDER BY "count" desc + LIMIT 30; + */ + var typedQuery = cbf.create(em, Tuple.class).from(LinkStatistic.class); + var query = + typedQuery + .select("linkSource", "source") + .select("brand", "brand") + .select("COUNT(*)", "amount") + .where("visitedAt") + .gt(dateLimit) + .groupBy("brand") + .groupBy("source") + .orderByDesc("amount") + .setMaxResults(resultLimit) + .getQuery(); + + query + .getResultList() + .forEach( + tuple -> + results.add( + new StatisticLineWithEntity( + tuple.get(0, Source.class).value(), + // * Brand can be null. + Optional.ofNullable(tuple.get(1, String.class)).orElse("__No Brand__"), + tuple.get(2, Long.class).intValue()))); + + return results; + } + + @Override + public List getMostUsedReferrerWithBrandFromNow( + Duration duration, Integer amount) { + List results = new ArrayList<>(); + var minResults = 1; + var maxResults = 50; + var resultLimit = Math.min(maxResults, Math.max(minResults, amount)); + var dateLimit = ZonedDateTime.now().minus(duration.abs()); + var maxPastDate = ZonedDateTime.now().minusYears(1); + + if (dateLimit.isBefore(maxPastDate)) { + dateLimit = maxPastDate; + } + + /* + Wanted query is : + SELECT "referrer_host", "referrer_path", "brand", COUNT(*) + FROM "z3-statistics"."link_statistics" + WHERE + visited_at > (CURRENT_TIMESTAMP - INTERVAL '30 days') + AND ("referrer_host" IS NOT NULL OR "referrer_path" IS NOT NULL) + GROUP BY "referrer_host", "referrer_path", "brand" + ORDER BY "count" desc + LIMIT 20; + */ + var typedQuery = cbf.create(em, Tuple.class).from(LinkStatistic.class); + var query = + typedQuery + .select("referrer.host", "referrer_host") + .select("referrer.path", "referrer_path") + .select("brand", "brand") + .select("COUNT(*)", "amount") + .where("visitedAt") + .gt(dateLimit) + // AND Where OR + .whereOr() + .where("referrer_host") + .isNotNull() + .where("referrer_path") + .isNotNull() + .endOr() + .groupBy("referrer_host") + .groupBy("referrer_path") + .orderByDesc("amount") + .setMaxResults(resultLimit) + .getQuery(); + + query + .getResultList() + .forEach( + tuple -> + results.add( + new StatisticLineWithEntity( + tuple.get(0, String.class) + + "/" + + tuple.get(1, String.class).replaceFirst("^/", ""), + // * Brand can be null. + Optional.ofNullable(tuple.get(2, String.class)).orElse("__No Brand__"), + tuple.get(3, Long.class).intValue()))); + + return results; + } + + @Override + public List getInvalidLinksPerWeeksByBrandsAndFromNow( + Duration duration) { + List results = new ArrayList<>(); + var dateLimit = ZonedDateTime.now().minus(duration.abs()); + var maxPastDate = ZonedDateTime.now().minusYears(1); + + if (dateLimit.isBefore(maxPastDate)) { + dateLimit = maxPastDate; + } + + /* + Wanted query is : + SELECT "brand", date_trunc('month', "visited_at" AT TIME ZONE 'UTC') AS "month", COUNT(*) + FROM "z3-statistics"."link_statistics" + WHERE "was_valid" = false AND visited_at > (CURRENT_TIMESTAMP - INTERVAL '1 year') + GROUP BY "brand", "month" + ORDER BY "brand", "month" ASC; + */ + var typedQuery = cbf.create(em, Tuple.class).from(LinkStatistic.class); + var query = + typedQuery + .select("brand", "brand") + .select("date_trunc('week', visitedAt)", "week") + .select("COUNT(*)", "amount") + .where("visitedAt") + .gt(dateLimit) + .where("linkWasValid") + .eq(true) + .groupBy("brand") + .groupBy("week") + .orderByAsc("brand") + .orderByAsc("week") + .getQuery(); + + query + .getResultList() + .forEach( + tuple -> + results.add( + new StatisticLinePerInterval( + // * Brand can be null. + Optional.ofNullable(tuple.get(0, String.class)).orElse("__No Brand__"), + tuple.get(1, ZonedDateTime.class), + tuple.get(2, Long.class).intValue()))); + + return results; + } + + private void clearCache() { + cache.invalidateAll().await().indefinitely(); + } + + private CriteriaBuilder query() { + return cbf.create(em, LinkStatistic.class).from(LinkStatistic.class); + } +} diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/repository/link/statistic/package-info.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/repository/link/statistic/package-info.java new file mode 100644 index 0000000..fd3c53a --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/repository/link/statistic/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.infrastructure.adapter.repository.link.statistic; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/GeoIpServiceImpl.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/GeoIpServiceImpl.java new file mode 100644 index 0000000..8169388 --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/GeoIpServiceImpl.java @@ -0,0 +1,43 @@ +package tech.pegase.z3.statistics.infrastructure.adapter.service; + +import com.maxmind.geoip2.DatabaseReader; +import com.maxmind.geoip2.exception.GeoIp2Exception; +import com.maxmind.geoip2.model.CountryResponse; +import jakarta.enterprise.context.ApplicationScoped; +import java.io.IOException; +import java.net.InetAddress; +import java.util.Optional; +import org.jboss.logging.Logger; +import tech.pegase.z3.statistics.domain.port.service.GeoIpService; +import tech.pegase.z3.statistics.domain.valueobject.Country; + +@ApplicationScoped +public class GeoIpServiceImpl implements GeoIpService { + private static final String databasePathName = "GeoLite2-Country.mmdb"; + private final Logger logger = Logger.getLogger(GeoIpServiceImpl.class); + private final DatabaseReader reader; + + public GeoIpServiceImpl() { + var classloader = Thread.currentThread().getContextClassLoader(); + try (var inputStream = classloader.getResourceAsStream(databasePathName)) { + if (inputStream == null) { + throw new IllegalStateException("Database '%s' not found".formatted(databasePathName)); + } + this.reader = new DatabaseReader.Builder(inputStream).build(); + } catch (IOException e) { + throw new IllegalStateException("Failed to start geo ip database", e); + } + } + + @Override + public Optional locateInetAddress(InetAddress inetAddress) { + try { + return Optional.of(reader.country(inetAddress)) + .map(CountryResponse::getCountry) + .map(geoIpCountry -> Country.fromIsoCode(geoIpCountry.getIsoCode())); + } catch (GeoIp2Exception | IOException e) { + logger.warn("Failed to resolve a country using inet address '%s'".formatted(inetAddress)); + return Optional.empty(); + } + } +} diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/UserAgentServiceImpl.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/UserAgentServiceImpl.java new file mode 100644 index 0000000..d044e88 --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/UserAgentServiceImpl.java @@ -0,0 +1,143 @@ +package tech.pegase.z3.statistics.infrastructure.adapter.service; + +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; +import java.util.Set; +import nl.basjes.parse.useragent.UserAgent; +import nl.basjes.parse.useragent.UserAgentAnalyzer; +import org.jboss.logging.Logger; +import org.nekosoft.utils.CrawlerDetect; +import tech.pegase.z3.statistics.domain.port.service.UserAgentService; +import tech.pegase.z3.statistics.domain.valueobject.BrowserInfo; +import tech.pegase.z3.statistics.domain.valueobject.OperatingSystemInfo; +import tech.pegase.z3.statistics.domain.valueobject.RawUserAgent; +import tech.pegase.z3.statistics.domain.valueobject.UserType; + +@ApplicationScoped +public class UserAgentServiceImpl implements UserAgentService { + private static final Set userAgentFields = + Set.of( + UserAgent.OPERATING_SYSTEM_CLASS, + UserAgent.OPERATING_SYSTEM_NAME, + UserAgent.AGENT_CLASS, + UserAgent.AGENT_NAME, + UserAgent.AGENT_VERSION); + private final Logger logger = Logger.getLogger(UserAgentServiceImpl.class); + private final UserAgentAnalyzer userAgentAnalyzer; + private final CrawlerDetect detector; + + public UserAgentServiceImpl() { + this.userAgentAnalyzer = getUserAgentAnalyzer(); + this.detector = CrawlerDetect.newInstance(); + } + + @Override + public UserAgentInfo getUserAgentFrom(RawUserAgent rawUserAgent) { + var rawUserAgentValue = rawUserAgent.value(); + var userAgent = userAgentAnalyzer.parse(rawUserAgentValue); + return extractInfoFrom(userAgent); + } + + private UserAgentInfo extractInfoFrom(UserAgent userAgent) { + var browserInfo = getBrowserInfoFrom(userAgent).orElse(null); + var operatingSystem = getIdentifiedOsInfoFrom(userAgent).orElse(null); + var userType = getUserTypeFrom(userAgent); + + return new UserAgentInfo(browserInfo, operatingSystem, userType); + } + + private Optional getBrowserInfoFrom(UserAgent userAgent) { + if (userAgent.hasSyntaxError()) { + return Optional.empty(); + } + + var browserClass = userAgent.getValue(UserAgent.AGENT_CLASS); + var browserName = userAgent.getValue(UserAgent.AGENT_NAME); + var browserVersion = userAgent.getValue(UserAgent.AGENT_VERSION); + + if (browserClass == null || browserClass.isBlank()) { + logger.warn("Empty browser class found for user agent: " + userAgent.getUserAgentString()); + return Optional.empty(); + } + + if (browserName == null || browserName.isBlank()) { + logger.warn("Empty browser name found for user agent: " + userAgent.getUserAgentString()); + return Optional.empty(); + } + + if (browserVersion == null || browserVersion.isBlank()) { + logger.warn("Empty browser version found for user agent: " + userAgent.getUserAgentString()); + return Optional.empty(); + } + + return Optional.of(new BrowserInfo(browserClass, browserName, browserVersion)); + } + + private Optional getIdentifiedOsInfoFrom(UserAgent userAgent) { + if (userAgent.hasSyntaxError()) { + return Optional.empty(); + } + + var osClass = userAgent.getValue(UserAgent.OPERATING_SYSTEM_CLASS); + var osName = userAgent.getValue(UserAgent.OPERATING_SYSTEM_NAME); + var osVersion = userAgent.getValue(UserAgent.OPERATING_SYSTEM_VERSION); + + if (osClass == null || osClass.isBlank()) { + logger.warn("Empty systemInfo class found for user agent: " + userAgent.getUserAgentString()); + return Optional.empty(); + } + + if (osName == null || osName.isBlank()) { + logger.warn("Empty systemInfo name found for user agent: " + userAgent.getUserAgentString()); + return Optional.empty(); + } + + if (osVersion == null || osVersion.isBlank()) { + logger.warn( + "Empty systemInfo version found for user agent: " + userAgent.getUserAgentString()); + return Optional.empty(); + } + + return Optional.of(new OperatingSystemInfo(osClass, osName, osVersion)); + } + + private UserType getUserTypeFrom(UserAgent userAgent) { + if (userAgent.hasSyntaxError()) { + return UserType.UNKNOWN; + } + + var userAgentString = userAgent.getUserAgentString(); + var isHuman = !detector.isCrawler(userAgentString); + + if (isHuman) { + return UserType.HUMAN; + } + + return findMorePreciseBotUserType(userAgent); + } + + private UserType findMorePreciseBotUserType(UserAgent userAgent) { + var userAgentString = userAgent.getUserAgentString(); + var result = detector.getMatchingCrawler(userAgentString); + return switch (result) { + case "crawler" -> UserType.CRAWLER; + case "spider" -> UserType.SPIDER; + default -> UserType.BOT; + }; + } + + private UserAgentAnalyzer getUserAgentAnalyzer() { + var analyserBuilder = + UserAgentAnalyzer.newBuilder() + .dropDefaultResources() + .addResources("classpath:UserAgents/**.yaml") + .hideMatcherLoadStats() + .withCache(10000); + + for (var analyserField : userAgentFields) { + analyserBuilder.withField(analyserField); + } + + return analyserBuilder.build(); + } +} diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/HashServiceProducer.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/HashServiceProducer.java new file mode 100644 index 0000000..40adf8d --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/HashServiceProducer.java @@ -0,0 +1,14 @@ +package tech.pegase.z3.statistics.infrastructure.adapter.service.hash; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import tech.pegase.z3.statistics.domain.port.service.HashService; + +public class HashServiceProducer { + + @Produces + @ApplicationScoped + public HashService getHashService() { + return new HashServiceWithBlake128Impl(); + } +} diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/HashServiceWithBlake128Impl.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/HashServiceWithBlake128Impl.java new file mode 100644 index 0000000..e8596e8 --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/HashServiceWithBlake128Impl.java @@ -0,0 +1,22 @@ +package tech.pegase.z3.statistics.infrastructure.adapter.service.hash; + +import java.nio.charset.StandardCharsets; +import org.bouncycastle.crypto.digests.Blake2bDigest; +import org.bouncycastle.util.encoders.Hex; +import tech.pegase.z3.statistics.domain.port.service.HashService; + +public class HashServiceWithBlake128Impl implements HashService { + + @Override + public String hash(String rawValue) { + var inputBytes = rawValue.getBytes(StandardCharsets.UTF_8); + + var digest = new Blake2bDigest(128); + digest.update(inputBytes, 0, inputBytes.length); + + var output = new byte[digest.getDigestSize()]; + digest.doFinal(output, 0); + + return Hex.toHexString(output); + } +} diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/package-info.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/package-info.java new file mode 100644 index 0000000..ab53761 --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/hash/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.infrastructure.adapter.service.hash; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/package-info.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/package-info.java new file mode 100644 index 0000000..12e2009 --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/adapter/service/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.infrastructure.adapter.service; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/HqlDateTrunc.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/HqlDateTrunc.java new file mode 100644 index 0000000..44000ec --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/HqlDateTrunc.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.statistics.infrastructure.hibernate.functions; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; +import org.hibernate.type.StandardBasicTypes; + +public class HqlDateTrunc implements FunctionContributor { + + @Override + public void contributeFunctions(FunctionContributions functionContributions) { + var resultType = + functionContributions + .getTypeConfiguration() + .getBasicTypeRegistry() + .resolve(StandardBasicTypes.TIMESTAMP); + functionContributions + .getFunctionRegistry() + .registerPattern("date_trunc", "date_trunc('day', (?1 AT TIME ZONE 'UTC'))", resultType); + } +} diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/HqlDayOfYear.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/HqlDayOfYear.java new file mode 100644 index 0000000..312b4dd --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/HqlDayOfYear.java @@ -0,0 +1,20 @@ +package tech.pegase.z3.statistics.infrastructure.hibernate.functions; + +import org.hibernate.boot.model.FunctionContributions; +import org.hibernate.boot.model.FunctionContributor; +import org.hibernate.type.StandardBasicTypes; + +public class HqlDayOfYear implements FunctionContributor { + + @Override + public void contributeFunctions(FunctionContributions functionContributions) { + var resultType = + functionContributions + .getTypeConfiguration() + .getBasicTypeRegistry() + .resolve(StandardBasicTypes.LONG); + functionContributions + .getFunctionRegistry() + .registerPattern("doy", "EXTRACT(DOY FROM ?1)", resultType); + } +} diff --git a/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/package-info.java b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/package-info.java new file mode 100644 index 0000000..aed4904 --- /dev/null +++ b/z3-statistics-infrastructure/src/main/java/tech/pegase/z3/statistics/infrastructure/hibernate/functions/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package tech.pegase.z3.statistics.infrastructure.hibernate.functions; + +import org.jspecify.annotations.NullMarked; diff --git a/z3-statistics-infrastructure/src/main/resources/GeoLite2-Country.mmdb b/z3-statistics-infrastructure/src/main/resources/GeoLite2-Country.mmdb new file mode 100644 index 0000000..b700c7a Binary files /dev/null and b/z3-statistics-infrastructure/src/main/resources/GeoLite2-Country.mmdb differ diff --git a/z3-statistics-infrastructure/src/main/resources/META-INF/beans.xml b/z3-statistics-infrastructure/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000..e69de29 diff --git a/z3-statistics-infrastructure/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor b/z3-statistics-infrastructure/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor new file mode 100644 index 0000000..94ae58b --- /dev/null +++ b/z3-statistics-infrastructure/src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor @@ -0,0 +1,2 @@ +tech.pegase.z3.statistics.infrastructure.hibernate.functions.HqlDayOfYear +tech.pegase.z3.statistics.infrastructure.hibernate.functions.HqlDateTrunc \ No newline at end of file