Browse Source

Merge branch 'feature/V8-4063_services_unit_tests' into develop

Olivier Massot 2 năm trước cách đây
mục cha
commit
0b45587a0f
100 tập tin đã thay đổi với 5121 bổ sung486 xóa
  1. 4 4
      components/Layout/AlertBar/Cotisation.vue
  2. 2 2
      components/Layout/AlertBar/SuperAdmin.vue
  3. 6 6
      components/Layout/Header/Notification.vue
  4. 2 2
      components/Layout/SubHeader/Breadcrumbs.vue
  5. 2 2
      components/Ui/Input/AutocompleteWithAPI.vue
  6. 2 2
      components/Ui/Input/DatePicker.vue
  7. 2 2
      components/Ui/Input/Image.vue
  8. 2 2
      components/Ui/ItemFromUri.vue
  9. 2 2
      composables/form/useValidation.ts
  10. 1 1
      composables/layout/useMenu.ts
  11. 0 0
      composables/layout/useRedirectToLogin.ts
  12. 2 2
      composables/utils/useRedirectToLogout.ts
  13. 20 0
      doc/unittests.md
  14. 2 2
      layouts/error.vue
  15. 1 1
      models/ApiResource.ts
  16. 9 4
      package.json
  17. 3 3
      pages/organization/index.vue
  18. 26 1
      plugins/ability.ts
  19. 1 1
      plugins/init.server.ts
  20. 6 4
      services/data/apiRequestService.ts
  21. 57 40
      services/data/entityManager.ts
  22. 3 3
      services/data/enumManager.ts
  23. 1 1
      services/data/fileManager.ts
  24. 24 6
      services/data/imageManager.ts
  25. 1 0
      services/data/normalizer/fileNormalizer.ts
  26. 72 0
      services/data/normalizer/hydraDenormalizer.ts
  27. 0 144
      services/data/serializer/denormalizer/hydraDenormalizer.ts
  28. 0 21
      services/data/serializer/denormalizer/yamlDenormalizer.ts
  29. 0 17
      services/data/serializer/normalizer/modelNormalizer.ts
  30. 36 0
      services/encoder/yamlEncoder.ts
  31. 6 3
      services/layout/menuBuilder/abstractMenuBuilder.ts
  32. 3 3
      services/layout/menuBuilder/accessMenuBuilder.ts
  33. 1 1
      services/layout/menuBuilder/accountMenuBuilder.ts
  34. 1 1
      services/layout/menuBuilder/admin2iosMenuBuilder.ts
  35. 2 2
      services/layout/menuBuilder/agendaMenuBuilder.ts
  36. 1 1
      services/layout/menuBuilder/billingMenuBuilder.ts
  37. 1 1
      services/layout/menuBuilder/communicationMenuBuilder.ts
  38. 1 1
      services/layout/menuBuilder/configurationMenuBuilder.ts
  39. 1 1
      services/layout/menuBuilder/cotisationsMenuBuilder.ts
  40. 1 1
      services/layout/menuBuilder/donorsMenuBuilder.ts
  41. 1 1
      services/layout/menuBuilder/educationalMenuBuilder.ts
  42. 1 1
      services/layout/menuBuilder/equipmentMenuBuilder.ts
  43. 13 13
      services/layout/menuBuilder/mainMenuBuilder.ts
  44. 1 1
      services/layout/menuBuilder/medalsMenuBuilder.ts
  45. 3 2
      services/layout/menuBuilder/myAccessesMenuBuilder.ts
  46. 4 3
      services/layout/menuBuilder/myFamilyMenuBuilder.ts
  47. 1 1
      services/layout/menuBuilder/parametersMenuBuilder.ts
  48. 2 2
      services/layout/menuBuilder/statsMenuBuilder.ts
  49. 2 2
      services/layout/menuBuilder/websiteAdminMenuBuilder.ts
  50. 7 5
      services/layout/menuBuilder/websiteListMenuBuilder.ts
  51. 62 0
      services/layout/menuComposer.ts
  52. 0 56
      services/menuBuilder/menuComposer.ts
  53. 11 38
      services/rights/abilityBuilder.ts
  54. 10 3
      services/rights/roleUtils.ts
  55. 7 7
      services/sse/sseSource.ts
  56. 15 7
      services/utils/arrayUtils.ts
  57. 8 10
      services/utils/dateUtils.ts
  58. 5 5
      services/utils/i18nUtils.ts
  59. 4 6
      services/utils/imageUtils.ts
  60. 9 4
      services/utils/objectUtils.ts
  61. 21 24
      services/utils/urlUtils.ts
  62. 2 2
      stores/accessProfile.ts
  63. 2 3
      stores/organizationProfile.ts
  64. 19 0
      tests/alias.ts
  65. 57 0
      tests/readme.md
  66. 4 0
      tests/units/readme.md
  67. 175 0
      tests/units/services/data/apiRequestService.test.ts
  68. 842 0
      tests/units/services/data/entityManager.test.ts
  69. 57 0
      tests/units/services/data/enumManager.test.ts
  70. 110 0
      tests/units/services/data/imageManager.test.ts
  71. 216 0
      tests/units/services/data/normalizer/hydraDenormalizer.test.ts
  72. 23 0
      tests/units/services/encoder/yamlDenormalizer.test.ts
  73. 10 0
      tests/units/services/error/unauthorizedError.test.ts
  74. 133 0
      tests/units/services/layout/menuBuilder/abstractMenuBuilder.test.ts
  75. 108 0
      tests/units/services/layout/menuBuilder/accessMenuBuilder.test.ts
  76. 208 0
      tests/units/services/layout/menuBuilder/accountMenuBuilder.test.ts
  77. 106 0
      tests/units/services/layout/menuBuilder/admin2iosMenuBuilder.test.ts
  78. 67 0
      tests/units/services/layout/menuBuilder/agendaMenuBuilder.test.ts
  79. 115 0
      tests/units/services/layout/menuBuilder/billingMenuBuilder.test.ts
  80. 76 0
      tests/units/services/layout/menuBuilder/communicationMenuBuilder.test.ts
  81. 124 0
      tests/units/services/layout/menuBuilder/configurationMenuBuilder.test.ts
  82. 188 0
      tests/units/services/layout/menuBuilder/cotisationsMenuBuilder.test.ts
  83. 48 0
      tests/units/services/layout/menuBuilder/donorsMenuBuilder.test.ts
  84. 99 0
      tests/units/services/layout/menuBuilder/educationalMenuBuilder.test.ts
  85. 50 0
      tests/units/services/layout/menuBuilder/equipmentMenuBuilder.test.ts
  86. 105 0
      tests/units/services/layout/menuBuilder/mainMenuBuilder.test.ts
  87. 50 0
      tests/units/services/layout/menuBuilder/medalsMenuBuilder.test.ts
  88. 59 0
      tests/units/services/layout/menuBuilder/myAccessesMenuBuilder.test.ts
  89. 72 0
      tests/units/services/layout/menuBuilder/myFamilyMenuBuilder.test.ts
  90. 104 0
      tests/units/services/layout/menuBuilder/parametersMenuBuilder.test.ts
  91. 76 0
      tests/units/services/layout/menuBuilder/statsMenuBuilder.test.ts
  92. 55 0
      tests/units/services/layout/menuBuilder/websiteAdminMenuBuilder.test.ts
  93. 96 0
      tests/units/services/layout/menuBuilder/websiteListMenuBuilder.test.ts
  94. 87 0
      tests/units/services/layout/menuComposer.test.ts
  95. 674 0
      tests/units/services/rights/abilityBuilder.test.ts
  96. 44 0
      tests/units/services/rights/roleUtils.test.ts
  97. 173 0
      tests/units/services/sse/sseSource.test.ts
  98. 70 0
      tests/units/services/utils/arrayUtils.test.ts
  99. 54 0
      tests/units/services/utils/dateUtils.test.ts
  100. 69 0
      tests/units/services/utils/i18nUtils.test.ts

+ 4 - 4
components/Layout/AlertBar/Cotisation.vue

@@ -48,7 +48,7 @@ Barre d'alerte qui s'affiche pour donner l'état de la cotisation
 <script setup lang="ts">
 import {useOrganizationProfileStore} from "~/stores/organizationProfile";
 import {Ref} from "vue";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {ALERT_STATE_COTISATION} from "~/types/enum/enums";
 import {useAsyncData} from "#app";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
@@ -113,19 +113,19 @@ const goOn = (type: ALERT_STATE_COTISATION) => {
       if (!organizationProfile.id) {
         throw new Error('missing organization id')
       }
-      window.location.href = Url.join(baseLegacyUrl, '/cotisation/cotisation_steps', organizationProfile.id, 'steps/1')
+      window.location.href = UrlUtils.join(baseLegacyUrl, '/cotisation/cotisation_steps', organizationProfile.id, 'steps/1')
       break;
     case ALERT_STATE_COTISATION.INVOICE :
       if (!cotisationYear.value) {
         throw new Error('no cotisation year defined')
       }
       window.open(
-          Url.join(baseLegacyUrl, 'cotisation/invoice', cotisationYear.value),
+          UrlUtils.join(baseLegacyUrl, 'cotisation/invoice', cotisationYear.value),
           '_blank'
       )
       break;
     case ALERT_STATE_COTISATION.INSURANCE :
-      window.location.href = Url.join(baseLegacyUrl, 'cotisation/insuranceedit')
+      window.location.href = UrlUtils.join(baseLegacyUrl, 'cotisation/insuranceedit')
       break;
     case ALERT_STATE_COTISATION.ADVERTISINGINSURANCE :
       window.open(

+ 2 - 2
components/Layout/AlertBar/SuperAdmin.vue

@@ -23,7 +23,7 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur est un super admin en mode sw
 
 <script setup lang="ts">
   import {useAccessProfileStore} from "~/stores/accessProfile";
-  import Url from "~/services/utils/url";
+  import UrlUtils from "~/services/utils/urlUtils";
   import {ComputedRef} from "@vue/reactivity";
 
   const runtimeConfig = useRuntimeConfig()
@@ -41,7 +41,7 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur est un super admin en mode sw
     const originalAccessId = accessProfile.originalAccess ? accessProfile.originalAccess.id : null
 
     if (show && orgId && originalAccessId) {
-      return Url.join(baseLegacyUrl, 'switch_user', orgId, originalAccessId, 'exit')
+      return UrlUtils.join(baseLegacyUrl, 'switch_user', orgId, originalAccessId, 'exit')
     }
     return ''
   })

+ 6 - 6
components/Layout/Header/Notification.vue

@@ -101,7 +101,7 @@ import {ComputedRef, Ref, ref} from "@vue/reactivity";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
 import {Pagination} from "~/types/data";
 import {useEntityManager} from "~/composables/data/useEntityManager";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import ArrayUtils from "~/services/utils/arrayUtils";
 
 const accessProfileStore = useAccessProfileStore()
@@ -125,7 +125,7 @@ let { data: collection, pending, refresh } = await fetchCollection(Notification)
  */
 const notifications: ComputedRef = computed(() => {
   const items = collection.value !== null ? collection.value.items : []
-  return ArrayUtils.sortArrayOfObject(items, 'id')
+  return ArrayUtils.sortObjectsByProp(items, 'id')
 })
 
 /**
@@ -241,14 +241,14 @@ const download = (link: string) => {
   const url_parts: Array<string> = link.split('/api');
 
   if(accessProfileStore.originalAccess)
-    url_parts[0] = Url.join('api', String(accessProfileStore.originalAccess.id), String(accessProfileStore.id))
+    url_parts[0] = UrlUtils.join('api', String(accessProfileStore.originalAccess.id), String(accessProfileStore.id))
   else
-    url_parts[0] = Url.join('api', String(accessProfileStore.id))
+    url_parts[0] = UrlUtils.join('api', String(accessProfileStore.id))
 
-  window.open(Url.join(runtimeConfig.baseUrlLegacy, url_parts.join('')));
+  window.open(UrlUtils.join(runtimeConfig.baseUrlLegacy, url_parts.join('')));
 }
 
-const notificationUrl = Url.join(runtimeConfig.baseUrlAdminLegacy, 'notifications/list/')
+const notificationUrl = UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, 'notifications/list/')
 
 </script>
 

+ 2 - 2
components/Layout/SubHeader/Breadcrumbs.vue

@@ -9,7 +9,7 @@ import {useRouter, useRuntimeConfig} from "#app";
 import {computed, ComputedRef} from "@vue/reactivity";
 import {AnyJson} from "~/types/data";
 import {useI18n} from "vue-i18n";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 
 const runtimeConfig = useRuntimeConfig()
 const i18n = useI18n()
@@ -23,7 +23,7 @@ const items: ComputedRef<Array<AnyJson>> = computed(() => {
     href: runtimeConfig.baseUrlAdminLegacy
   })
 
-  const pathPart: Array<string> = Url.split(router.currentRoute.value.path)
+  const pathPart: Array<string> = UrlUtils.split(router.currentRoute.value.path)
 
   let path: string = ''
 

+ 2 - 2
components/Ui/Input/AutocompleteWithAPI.vue

@@ -29,7 +29,7 @@ d'une api)
 <script setup lang="ts">
 
 import {Ref, ref, toRefs} from "@vue/reactivity";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {FetchOptions} from "ohmyfetch";
 import {useFetch} from "#app";
 import {watch} from "@vue/runtime-core";
@@ -112,7 +112,7 @@ if (props.data) {
   const ids:Array<any> = []
 
   for(const uri of props.remoteUri){
-    ids.push(Url.extractIdFromUri(uri as string))
+    ids.push(UrlUtils.extractIdFromUri(uri as string))
   }
 
   const options: FetchOptions = { method: 'GET', query: {key: 'id', value: ids.join(',')} }

+ 2 - 2
components/Ui/Input/DatePicker.vue

@@ -136,13 +136,13 @@ const datesFormatted: ComputedRef<string|null> = computed(() => {
   if (props.range && datesParsed.value && datesParsed.value.length < 2) {
     return null
   }
-  return datesParsed.value ? DateUtils.formatDatesAndConcat(datesParsed.value, props.format) :  null
+  return datesParsed.value ? DateUtils.formatAndConcat(datesParsed.value, props.format) :  null
 })
 
 const unwatch: WatchStopHandle = watch(datesParsed, (newValue, oldValue) => {
   if (newValue === oldValue) { return }
   if (props.range && newValue && newValue.length < 2) { return }
-  updateViolationState(Array.isArray(newValue) ? DateUtils.sortDate(newValue) : newValue)
+  updateViolationState(Array.isArray(newValue) ? DateUtils.sort(newValue) : newValue)
 })
 
 onUnmounted(() => {

+ 2 - 2
components/Ui/Input/Image.vue

@@ -64,7 +64,7 @@ import {ref, Ref} from "@vue/reactivity";
 import {AnyJson} from "~/types/enum/data";
 import {File} from '~/models/Core/File'
 import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {useImageFetch} from "~/composables/data/useImageFetch";
 import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
 import {useEntityManager} from "~/composables/data/useEntityManager";
@@ -111,7 +111,7 @@ const defaultSize = ({ imageSize, visibleArea }: any) => {
 // Si l'id est renseigné, on récupère l'Item File afin d'avoir les informations de config, le nom, etc.
 if (props.imageId && props.imageId > 0) {
   const { apiRequestService } = useAp2iRequestService()
-  const result: any = await apiRequestService.get(Url.join('api/files', '' + props.imageId))
+  const result: any = await apiRequestService.get(UrlUtils.join('api/files', '' + props.imageId))
 
   const config = JSON.parse(result.data.config)
   coordinates.value.left = config.x

+ 2 - 2
components/Ui/ItemFromUri.vue

@@ -18,7 +18,7 @@ Espace permettant de récupérer un item via une uri et de gérer son affichage
 // TODO: renommer en EntityFromUri? voir si ce component est encore nécessaire, ou si ça ne peut pas être une méthode de l'entity manager
 
 import {Query} from "pinia-orm";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
 import {object} from "@ucast/core";
 import {computed, ComputedRef} from "@vue/reactivity";
@@ -46,7 +46,7 @@ const props = defineProps({
   }
 })
 
-const id = Url.extractIdFromUri(props.uri)
+const id = UrlUtils.extractIdFromUri(props.uri)
 if (id === null) {
   throw new Error('Uri parsing error : no id found')
 }

+ 2 - 2
composables/form/useValidation.ts

@@ -1,6 +1,6 @@
 import  {useI18n} from 'vue-i18n'
 import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {Ref} from "@vue/reactivity";
 
 /**
@@ -19,7 +19,7 @@ export function useValidation() {
     const validateSiret = async (siret: string) => {
 
       const { apiRequestService } = useAp2iRequestService()
-      const response: any = await apiRequestService.get(Url.join('/api/siret-checking', siret))
+      const response: any = await apiRequestService.get(UrlUtils.join('/api/siret-checking', siret))
 
       if (typeof response === 'undefined') {
         siretError.value = false

+ 1 - 1
composables/layout/useMenu.ts

@@ -7,7 +7,7 @@ import {MenuGroup, MenuItem} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 import {AccessProfile} from "~/types/interfaces";
 import {useLayoutStore} from "~/stores/layout";
-import MenuComposer from "~/services/menuBuilder/menuComposer";
+import MenuComposer from "~/services/layout/menuComposer";
 
 /**
  * Renvoie des méthodes pour interagir avec les menus

+ 0 - 0
tests/.gitkeep → composables/layout/useRedirectToLogin.ts


+ 2 - 2
composables/utils/useRedirectToLogout.ts

@@ -1,5 +1,5 @@
 import {navigateTo, useRuntimeConfig} from "#app";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 
 export const useRedirectToLogout = () => {
     const runtimeConfig = useRuntimeConfig()
@@ -8,6 +8,6 @@ export const useRedirectToLogout = () => {
         if (!runtimeConfig.baseUrlAdminLegacy) {
             throw new Error('Configuration error : no redirection target')
         }
-        navigateTo(Url.join(runtimeConfig.baseUrlAdminLegacy, '#/logout'), {external: true})
+        navigateTo(UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#/logout'), {external: true})
     }
 }

+ 20 - 0
doc/unittests.md

@@ -0,0 +1,20 @@
+
+# Tests unitaires
+
+## Exécuter les tests
+
+Pour exécuter les tests unitaires, lancer : 
+
+    yarn test
+
+
+## Vitest
+
+Les tests unitaires sont exécutés avec [Vitest](https://vitest.dev/), qui est recommandé par 
+la documentation [Vue3](https://vuejs.org/guide/scaling-up/testing.html#recommendation-1).
+
+
+## Coverage
+
+La couverture des tests peut être retrouvée dans le fichier `./coverage/index.html`
+

+ 2 - 2
layouts/error.vue

@@ -8,7 +8,7 @@
 
 <script setup lang="ts">
   import {navigateTo} from "#app";
-  import Url from "~/services/utils/url";
+  import UrlUtils from "~/services/utils/urlUtils";
 
   const props = defineProps({
     error: {
@@ -19,7 +19,7 @@
 
   if(process.client && props.error.statusCode === 404 && process.env.NODE_ENV === 'production') {
     const runtimeConfig = useRuntimeConfig()
-    navigateTo(Url.join(runtimeConfig.baseUrlAdminLegacy, 'dashboard'), {external: true})
+    navigateTo(UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, 'dashboard'), {external: true})
   }
 
   const otherError = ref('Une erreur est parvenue')

+ 1 - 1
models/ApiResource.ts

@@ -11,7 +11,7 @@ export class ApiResource extends Model {
         return this._model
     }
 
-    public setModel(model: typeof ApiResource ) {
+    public setModel(model: typeof ApiResource) {
         this._model = model
     }
 

+ 9 - 4
package.json

@@ -14,12 +14,13 @@
     "build:prod": "yarn build --dotenv .env.prod",
     "prepare": "nuxt prepare",
     "start": "nuxt start --hostname '127.0.0.1' --port 3003",
+    "deploy": "git pull && yarn install & yarn build",
     "lint": "eslint --ext \".ts,.js,.vue\" --ignore-path .gitignore .",
     "lint-fix": "eslint --fix --ext \".ts,.js,.vue\" --ignore-path .gitignore .",
-    "deploy": "git pull && yarn install && yarn build && pm2 start app",
-    "infos": "npx nuxi info"
+    "test": "vitest run"
   },
   "devDependencies": {
+    "@nuxt/test-utils": "^3.0.0",
     "@nuxt/test-utils-edge": "^3.0.1-rc.0-27810184.d991a55",
     "@nuxtjs/eslint-config": "^11.0.0",
     "@nuxtjs/eslint-config-typescript": "^11.0.0",
@@ -31,18 +32,22 @@
     "@types/uuid": "^8.3.4",
     "@typescript-eslint/eslint-plugin": "^5.43.0",
     "@typescript-eslint/parser": "^5.43.0",
+    "@vitejs/plugin-vue": "^4.0.0",
+    "@vitest/coverage-c8": "^0.28.3",
     "@vue/eslint-config-standard": "^8.0.1",
+    "@vue/test-utils": "^2.2.7",
+    "blob-polyfill": "^7.0.20220408",
     "eslint": "^8.27.0",
     "eslint-config-prettier": "^8.5.0",
     "eslint-plugin-nuxt": "^4.0.0",
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-vue": "^9.7.0",
-    "jest": "^29.3.1",
+    "jsdom": "^21.0.0",
     "nuxt": "^3.0.0",
     "prettier": "^2.7.1",
     "ts-jest": "^29.0.3",
     "typescript": "4.9.4",
-    "vitest": "0.27.1",
+    "vitest": "0.28.3",
     "vue-jest": "^3.0.7"
   },
   "dependencies": {

+ 3 - 3
pages/organization/index.vue

@@ -454,7 +454,7 @@ import {useExtensionPanel} from "~/composables/layout/useExtensionPanel";
 import {useRoute} from "#app";
 import { useValidation } from "~/composables/form/useValidation";
 import {useEntityManager} from "~/composables/data/useEntityManager";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {Organization} from "~/models/Organization/Organization";
 import {useI18nUtils} from "~/composables/utils/useI18nUtils";
 import {ContactPoint} from "~/models/Core/ContactPoint";
@@ -502,13 +502,13 @@ const formatPhoneNumber = (number: string): string => {
 const getIdsFromUris = (uris: Array<string>) => {
   const ids:Array<any> = []
   for(const uri of uris){
-    ids.push(Url.extractIdFromUri(uri))
+    ids.push(UrlUtils.extractIdFromUri(uri))
   }
   return ids
 }
 
 // TODO: voir si l'extraction de cette id ne pourrait pas être faite en amont, au niveau des post-processors
-const getIdFromUri = (uri: string) => Url.extractIdFromUri(uri)
+const getIdFromUri = (uri: string) => UrlUtils.extractIdFromUri(uri)
 
 const models = () => {
   return {

+ 26 - 1
plugins/ability.ts

@@ -10,7 +10,32 @@ export default defineNuxtPlugin(() => {
     const accessProfile = useAccessProfileStore()
     const organizationProfile = useOrganizationProfileStore()
 
+    // Initialisation, nécessaire pour que l'update des habilités soit correcte après la phase SSR
+    ability.update(accessProfile.abilities)
+
     const abilityUtils = new AbilityBuilder(ability, accessProfile, organizationProfile)
 
-    abilityUtils.setupAbilities()
+    // Au moment où l'on effectue une action organizationProfileStore.setProfile, il faut aller récupérer
+    // les différentes habilités que l'utilisateur peut effectuer. (Tout cela se passe en SSR)
+    // TODO: voir si on peut se passer du listener
+    // TODO: clarifier l'ordre des opérations de maj des profils et des droits
+    const unsubscribe = organizationProfile.$onAction(({
+                                                                name, // name of the action
+                                                                store, // store instance, same as `someStore`
+                                                                args, // array of parameters passed to the action
+                                                                after, // hook after the action returns or resolves
+                                                                onError, // hook if the action throws or rejects
+                                                            }: any) => {
+        after((result: any)=>{
+            if (name === 'setProfile'){
+                // On construit les habilités et on les enregistre dans le store
+                // noinspection UnnecessaryLocalVariableJS
+                const abilities = abilityUtils.buildAbilities();
+                accessProfile.abilities = abilities
+
+                // Unsubscribe pour éviter les memory leaks
+                unsubscribe()
+            }
+        })
+    })
 })

+ 1 - 1
plugins/init.server.ts

@@ -4,7 +4,7 @@ import {useEntityManager} from "~/composables/data/useEntityManager";
 import UnauthorizedError from "~/services/error/UnauthorizedError";
 import {useRedirectToLogout} from "~/composables/utils/useRedirectToLogout";
 
-export default defineNuxtPlugin(async ({ssrContext}) => {
+export default defineNuxtPlugin(async () => {
     const redirectToLogout = useRedirectToLogout()
 
     const bearer = useCookie('BEARER')

+ 6 - 4
services/data/apiRequestService.ts

@@ -4,11 +4,12 @@
  * It will send basic http requests and returns raw results
  */
 import {AssociativeArray} from "~/types/data";
-import {$Fetch} from "nitropack";
-import {FetchOptions} from "ohmyfetch";
 import {HTTP_METHOD} from "~/types/enum/data";
+import {$Fetch, FetchOptions} from "ohmyfetch";
 
 class ApiRequestService {
+    // TODO: fusionner les paramètres `query` et `params` des méthodes, puisque l'un est un alias de l'autre
+    //       dans ohmyfetch : https://github.com/unjs/ofetch#%EF%B8%8F-adding-query-search-params
     private readonly fetch: $Fetch
 
     public constructor(
@@ -85,9 +86,9 @@ class ApiRequestService {
      * @param body
      * @param params
      * @param query
-     * @private
+     * @protected
      */
-    private async request(
+    protected async request(
         method: HTTP_METHOD,
         url: string,
         body: string | null = null,
@@ -105,6 +106,7 @@ class ApiRequestService {
             config.body = body
         }
 
+        // @ts-ignore
         return this.fetch(url, config)
     }
 }

+ 57 - 40
services/data/entityManager.ts

@@ -1,15 +1,15 @@
-import ApiRequestService from "./apiRequestService";
-import {Repository, useRepo} from "pinia-orm";
-import Url from "~/services/utils/url";
-import ModelNormalizer from "./serializer/normalizer/modelNormalizer";
-import HydraDenormalizer from "./serializer/denormalizer/hydraDenormalizer";
-import ApiModel from "~/models/ApiModel";
-import ApiResource from "~/models/ApiResource";
-import {MyProfile} from "~/models/Access/MyProfile";
-import {v4 as uuid4} from 'uuid';
-import {AssociativeArray, Collection} from "~/types/data.d";
+import ApiRequestService from "./apiRequestService"
+import {Repository, useRepo} from "pinia-orm"
+import UrlUtils from "~/services/utils/urlUtils"
+import HydraDenormalizer from "./normalizer/hydraDenormalizer"
+import ApiModel from "~/models/ApiModel"
+import ApiResource from "~/models/ApiResource"
+import {MyProfile} from "~/models/Access/MyProfile"
+import {v4 as uuid4} from 'uuid'
+import {AssociativeArray, Collection} from "~/types/data.d"
 import models from "~/models/models";
-import {useAccessProfileStore} from "~/stores/accessProfile";
+import {useAccessProfileStore} from "~/stores/accessProfile"
+import _ from "lodash"
 
 /**
  * Entity manager: make operations on the models defined with the Pinia-Orm library
@@ -17,9 +17,9 @@ import {useAccessProfileStore} from "~/stores/accessProfile";
  * @see https://pinia-orm.codedredd.de/
  */
 class EntityManager {
-    private CLONE_PREFIX = '_clone_'
+    protected CLONE_PREFIX = '_clone_'
 
-    private apiRequestService: ApiRequestService
+    protected apiRequestService: ApiRequestService
 
     public constructor(
         apiRequestService: ApiRequestService
@@ -33,11 +33,20 @@ class EntityManager {
      * @param model
      */
     public getRepository(model: typeof ApiResource): Repository<ApiResource> {
+        // TODO: voir si possible de passer par une injection de dépendance plutôt que par un use
         return useRepo(model)
     }
 
+    /**
+     * Cast an object as an ApiResource
+     * This in used internally to ensure the object is recognized as an ApiResource
+     *
+     * @param model
+     * @param entity
+     * @protected
+     */
     // noinspection JSMethodCanBeStatic
-    private cast(model: typeof ApiResource, entity: ApiResource): ApiResource {
+    protected cast(model: typeof ApiResource, entity: ApiResource): ApiResource {
         return new model(entity)
     }
 
@@ -72,11 +81,9 @@ class EntityManager {
     public newInstance(model: typeof ApiResource, properties: object = {}): ApiResource {
         const repository = this.getRepository(model)
 
-        //@todo : make renvoi un model donc peut etre une confusion ?
-        //@todo: pourquoi faire un make ? pourquoi ne pas utiliser le new ? En plus si les propriétés ne sont pas nulles, on peut directement passer au save
         let entity = repository.make(properties)
 
-        // Keep track of the entitie's model
+        // Keep track of the entity's model
         entity.setModel(model)
 
         // @ts-ignore
@@ -101,9 +108,9 @@ class EntityManager {
         return this.getRepository(model).save(entity)
     }
 
-
     /**
      * Find the entity into the store
+     * TODO: comment réagit la fonction si l'id n'existe pas?
      *
      * @param model
      * @param id
@@ -133,18 +140,15 @@ class EntityManager {
         }
 
         // Else, get the object from the API
-        const url = Url.join('api', model.entity, String(id))
-
+        const url = UrlUtils.join('api', model.entity, String(id))
         const response = await this.apiRequestService.get(url)
 
         // deserialize the response
         const attributes = HydraDenormalizer.denormalize(response).data as object
-
         return this.newInstance(model, attributes)
     }
 
     /**
-     * @todo: avec la nouvelle version de API Platform ça va pas mal avec la nouvelle gestion des sous resources...
      * Fetch a collection of entity
      * The content of `query` is converted into a query-string in the request URL
      *
@@ -154,10 +158,11 @@ class EntityManager {
      */
     public async fetchCollection(model: typeof ApiResource, parent: ApiResource | null, query: AssociativeArray = []): Promise<Collection> {
         let url
+
         if (parent !== null) {
-            url = Url.join('api', parent.entity, '' + parent.id, model.entity)
+            url = UrlUtils.join('api', parent.entity, '' + parent.id, model.entity)
         } else {
-            url = Url.join('api', model.entity)
+            url = UrlUtils.join('api', model.entity)
         }
 
         const response = await this.apiRequestService.get(url, query)
@@ -165,7 +170,6 @@ class EntityManager {
         // deserialize the response
         const apiCollection = HydraDenormalizer.denormalize(response)
 
-        //@todo: le map ne doit pas être nécessaire car on peut passer directement une collection au save.
         const items = apiCollection.data.map((attributes: object) => {
             return this.newInstance(model, attributes)
         })
@@ -182,7 +186,15 @@ class EntityManager {
         }
     }
 
-    private async saveResponseAsEntity(model: typeof ApiModel, response: Response) {
+    /**
+     * Créé une entité à partir d'une réponse de l'api au format Hydra, l'enregistre
+     * dans le store et la retourne
+     *
+     * @param model
+     * @param response
+     * @protected
+     */
+    protected async saveResponseAsEntity(model: typeof ApiModel, response: Response) {
         const repository = this.getRepository(model)
 
         const hydraResponse = await HydraDenormalizer.denormalize(response)
@@ -206,13 +218,13 @@ class EntityManager {
         // Recast in case class definition has been "lost"
         entity = this.cast(model, entity)
 
-        let url = Url.join('api', model.entity)
+        let url = UrlUtils.join('api', model.entity)
         let response
 
-        const data = ModelNormalizer.normalize(entity)
+        const data: any = entity.$toJson()
 
         if (!entity.isNew()) {
-            url = Url.join(url, String(entity.id))
+            url = UrlUtils.join(url, String(entity.id))
             response = await this.apiRequestService.put(url, data)
         } else {
             delete data.id
@@ -236,7 +248,7 @@ class EntityManager {
      * @param data
      */
     public async patch(model: typeof ApiModel, id: number, data: AssociativeArray) {
-        let url = Url.join('api', model.entity, ''+id)
+        let url = UrlUtils.join('api', model.entity, ''+id)
 
         const body = JSON.stringify(data)
         const response = await this.apiRequestService.put(url, body)
@@ -255,7 +267,7 @@ class EntityManager {
 
         // If object has been persisted to the datasource, send a delete request
         if (!entity.isNew()) {
-            const url = Url.join('api', model.entity, String(entity.id))
+            const url = UrlUtils.join('api', model.entity, String(entity.id))
             await this.apiRequestService.delete(url)
         }
 
@@ -292,8 +304,8 @@ class EntityManager {
         const profile = await this.fetch(MyProfile, accessId)
 
         // On met à jour le store accessProfile
-        const accessProfileStore = useAccessProfileStore()
-        accessProfileStore.setProfile(profile)
+        const accessProfile = useAccessProfileStore()
+        accessProfile.setProfile(profile)
     }
 
     /**
@@ -301,7 +313,7 @@ class EntityManager {
      *
      * @param model
      */
-    public async flush(model: typeof ApiModel) {
+    public flush(model: typeof ApiModel) {
         const repository = this.getRepository(model)
         repository.flush()
     }
@@ -309,6 +321,9 @@ class EntityManager {
     /**
      * Is the entity a new one, or does it already exist in the data source (=API)
      *
+     * This is a convenient way of testing an entity you did not already fetch, else prefer the use of the
+     * isNew() method of ApiResource
+     *
      * @param model
      * @param id
      */
@@ -317,7 +332,8 @@ class EntityManager {
 
         const item = repository.find(id)
         if (!item || typeof item === 'undefined') {
-            console.error(model.entity + '/' + id, ' does not exist!')
+            // TODO: est-ce qu'il ne faudrait pas lever une erreur ici plutôt?
+            console.error(model.entity + '/' + id + ' does not exist!')
             return false
         }
 
@@ -331,11 +347,11 @@ class EntityManager {
      * @param entity
      * @private
      */
-    private saveInitialState(model: typeof ApiResource, entity: ApiResource) {
+    protected saveInitialState(model: typeof ApiResource, entity: ApiResource) {
         const repository = this.getRepository(model)
 
         // Clone and prefix id
-        const clone = useCloneDeep(entity)
+        const clone = _.cloneDeep(entity)
         clone.id = this.CLONE_PREFIX + clone.id
 
         repository.save(clone)
@@ -348,7 +364,7 @@ class EntityManager {
      * @param id
      * @private
      */
-    private getInitialStateOf(model: typeof ApiResource, id: string | number): ApiResource | null {
+    protected getInitialStateOf(model: typeof ApiResource, id: string | number): ApiResource | null {
         const repository = this.getRepository(model)
 
         // Find the clone by id
@@ -370,12 +386,13 @@ class EntityManager {
      * @param tempEntityId
      * @private
      */
-    private removeTempAfterPersist(model: typeof ApiResource, tempEntityId: number) {
+    protected removeTempAfterPersist(model: typeof ApiResource, tempEntityId: number | string) {
         const repository = this.getRepository(model)
 
         const entity = repository.find(tempEntityId)
         if (!entity || typeof entity === 'undefined') {
-            console.error(model.entity + '/' + tempEntityId, ' does not exist!')
+            // TODO: il vaudrait peut-être mieux lever une erreur ici?
+            console.error(model.entity + '/' + tempEntityId + ' does not exist!')
             return
         }
         if (!entity.isNew()) {

+ 3 - 3
services/data/enumManager.ts

@@ -1,6 +1,6 @@
 import ApiRequestService from "./apiRequestService";
-import Url from "~/services/utils/url";
-import HydraDenormalizer from "~/services/data/serializer/denormalizer/hydraDenormalizer";
+import UrlUtils from "~/services/utils/urlUtils";
+import HydraDenormalizer from "~/services/data/normalizer/hydraDenormalizer";
 import {Enum} from "~/types/data.d";
 import {VueI18n} from "vue-i18n";
 
@@ -14,7 +14,7 @@ class EnumManager {
     }
 
     public async fetch(enumName: string): Promise<Enum> {
-        const url = Url.join('api', 'enum', enumName)
+        const url = UrlUtils.join('api', 'enum', enumName)
 
         const response = await this.apiRequestService.get(url)
 

+ 1 - 1
services/data/fileManager.ts

@@ -8,7 +8,7 @@ class FileManager {
     }
 
     public fetch() {
-
+        // TODO: récupérer le contenu de fileNormalizer ici
     }
 }
 

+ 24 - 6
services/data/imageManager.ts

@@ -6,7 +6,7 @@ import ImageUtils from "~/services/utils/imageUtils";
  */
 class ImageManager {
     private apiRequestService: ApiRequestService;
-    public readonly defaultImage = '/images/default/picture.jpeg'
+    public static readonly defaultImage = '/images/default/picture.jpeg'
 
     public constructor(apiRequestService: ApiRequestService) {
         this.apiRequestService = apiRequestService
@@ -31,7 +31,7 @@ class ImageManager {
         width: number = 0
     ): Promise<string | ArrayBuffer> {
 
-        const defaultUrl = defaultImage ?? this.defaultImage
+        const defaultUrl = defaultImage ?? ImageManager.defaultImage
 
         if (id === null) {
             return defaultUrl
@@ -43,11 +43,11 @@ class ImageManager {
         if (height > 0 || width > 0) {
             // @see https://thumbor.readthedocs.io/en/latest/crop_and_resize_algorithms.html
             // TODO: ajouter le support de ces options dans ap2i
-            // url = Url.join(url, `${height}x${width}`)
+            // url = UrlUtils.join(url, `${height}x${width}`)
         }
 
         // Une image doit toujours avoir le time en options pour éviter les problèmes de cache
-        const query = [new Date().getTime().toString()]
+        const query = [this.getCacheKey()]
 
         const response: any = await this.apiRequestService.get(imageUrl, query)
 
@@ -56,8 +56,26 @@ class ImageManager {
             return defaultUrl
         }
 
-        const blob = await ImageUtils.newBlob(response)
-        return await ImageUtils.blobToBase64(blob) ?? ''
+        return await this.toBase64(response)
+    }
+
+    /**
+     * Convert the API response into base64
+     * @param data
+     * @protected
+     */
+    protected async toBase64(data: string) {
+        const blob = ImageUtils.newBlob(data)
+        return  await ImageUtils.blobToBase64(blob) ?? ''
+    }
+
+    /**
+     * On passe cette clé en paramètres des requêtes pour éviter les problèmes de cache
+     *
+     * @protected
+     */
+    protected getCacheKey() {
+        return new Date().getTime().toString()
     }
 }
 

+ 1 - 0
services/data/serializer/normalizer/fileNormalizer.ts → services/data/normalizer/fileNormalizer.ts

@@ -11,6 +11,7 @@ class FileNormalizer {
    * @param file
    */
   public static normalize (data: any, file: string): any {
+    // TODO : intégrer au FileManager
     const fileData = new FormData();
     for(const key in data){
       fileData.set(key, data[key])

+ 72 - 0
services/data/normalizer/hydraDenormalizer.ts

@@ -0,0 +1,72 @@
+import {AnyJson, ApiResponse, HydraMetadata} from '~/types/data'
+import UrlUtils from '~/services/utils/urlUtils'
+import {METADATA_TYPE} from '~/types/enum/data'
+
+/**
+ * Normalisation et dé-normalisation ddu format de données Hydra
+ */
+class HydraDenormalizer {
+
+  /**
+   * Parse une réponse Hydra et retourne un objet ApiResponse
+   *
+   * @param {AnyJson} data
+   * @return {AnyJson} réponse parsée
+   */
+  public static denormalize(data: AnyJson): ApiResponse {
+    return {
+      data: HydraDenormalizer.getData(data),
+      metadata: HydraDenormalizer.getMetadata(data)
+    }
+  }
+
+  protected static getData(hydraData: AnyJson): AnyJson {
+    return hydraData['@type'] === 'hydra:Collection' ? hydraData['hydra:member'] : hydraData
+  }
+
+  /**
+   * Génère les métadonnées d'un item ou d'une collection
+   *
+   * @param data
+   * @protected
+   */
+  protected static getMetadata(data: AnyJson): HydraMetadata {
+    if (data['@type'] !== 'hydra:Collection') {
+      // A single item, no metadata
+      return { type: METADATA_TYPE.ITEM }
+    }
+
+    const metadata: HydraMetadata = {
+      totalItems: data['hydra:totalItems']
+    }
+
+    if (data['hydra:view']) {
+      /**
+       * Extract the page number from the IRIs in the hydra:view section
+       */
+      const extractPageNumber = (pos: string, default_: number | undefined=undefined): number | undefined => {
+        const iri = data['hydra:view']['hydra:' + pos]
+        if (!iri) {
+          return default_
+        }
+        return UrlUtils.getParameter(
+            data['hydra:view']['hydra:' + pos],
+            'page',
+            default_
+        ) as number | undefined
+      }
+
+      // TODO: utile d'ajouter la page en cours?
+      metadata.firstPage = extractPageNumber('first', 1)
+      metadata.lastPage = extractPageNumber('last', 1)
+      metadata.nextPage = extractPageNumber('next')
+      metadata.previousPage = extractPageNumber('previous')
+    }
+
+    metadata.type = METADATA_TYPE.COLLECTION
+
+    return metadata
+  }
+}
+
+export default HydraDenormalizer

+ 0 - 144
services/data/serializer/denormalizer/hydraDenormalizer.ts

@@ -1,144 +0,0 @@
-import {AnyJson, ApiResponse, HydraMetadata} from "~/types/data";
-import Url from "~/services/utils/url";
-import {METADATA_TYPE} from "~/types/enum/data";
-
-/**
- * Classe permettant d'assurer la dé-normalization d'un objet Hydra en JSON
- */
-class HydraDenormalizer {
-  /**
-   * Parse une réponse Hydra pour retourner son équivalent en Json
-   *
-   * @param {AnyJson} data
-   * @return {AnyJson} réponse parsée
-   */
-  public static denormalize (data: AnyJson): ApiResponse {
-    if (data['hydra:member']) {
-      return HydraDenormalizer.parseCollection(data)
-    }
-
-    return HydraDenormalizer.parseItem(data)
-  }
-
-  /**
-   * Parse une réponse Hydra contenant un item
-   *
-   * @param hydraData
-   * @private
-   */
-  private static parseItem (hydraData: AnyJson): ApiResponse {
-    return {
-      data: hydraData,
-      metadata: HydraDenormalizer.constructMetadataForItem(hydraData)
-    }
-  }
-
-  /**
-   * Méthode de parsing appelé si on est dans un GET Collection
-   *
-   * @param {AnyJson} hydraData
-   */
-  private static parseCollection (hydraData: AnyJson): ApiResponse {
-    const collectionResponse:ApiResponse = {
-      data:hydraData['hydra:member'],
-      metadata : HydraDenormalizer.constructMetadataForCollection(hydraData)
-    }
-
-    // collectionResponse.order = {}
-    // collectionResponse.search = {}
-
-    // Populate href property for all elements of the collection
-    for (const key in collectionResponse.data) {
-      const value = collectionResponse.data[key]
-      HydraDenormalizer.populateAllData(value)
-    }
-
-    // if (typeof (hydraData['hydra:search']) !== 'undefined') {
-    //   const collectionSearch = hydraData['hydra:search']['hydra:mapping']
-    //   for (const key in collectionSearch) {
-    //     const value = collectionSearch[key]
-    //     if (value.variable.indexOf('filter[order]') === 0) {
-    //       collectionResponse.order[value.property] = value
-    //     } else if (value.variable.indexOf('filter[where]') === 0) {
-    //       collectionResponse.search[value.property] = value
-    //     }
-    //   }
-    // }
-
-    return collectionResponse
-  }
-
-  /**
-   * Génère les metadonnées d'un item
-   *
-   * @param {AnyJson} hydraData
-   */
-  private static constructMetadataForItem (hydraData: AnyJson): AnyJson {
-    const metadata: HydraMetadata = {}
-
-    // if (hydraData['hydra:previous']) {
-    //   const iriParts = hydraData['hydra:previous'].split('/')
-    //   hydraData.previous = iriParts[iriParts.length - 1]
-    // }
-    // if (hydraData['hydra:next']) {
-    //   const iriParts = hydraData['hydra:next'].split('/')
-    //   hydraData.next = iriParts[iriParts.length - 1]
-    // }
-    // if (hydraData['hydra:totalItems']) {
-    //   hydraData.totalItems = hydraData['hydra:totalItems']
-    // }
-    // if (hydraData['hydra:itemPosition']) {
-    //   hydraData.itemPosition = hydraData['hydra:itemPosition']
-    // }
-
-    metadata.type = METADATA_TYPE.ITEM
-
-    return metadata
-
-  }
-
-  /**
-   * Génère les métadonnées d'une collection
-   *
-   * @param data
-   * @private
-   */
-  private static  constructMetadataForCollection(data:AnyJson){
-    const metadata: HydraMetadata = {
-      totalItems: data['hydra:totalItems']
-    }
-
-    if(data['hydra:view']){
-      metadata.firstPage = HydraDenormalizer.getPageFromUri(data['hydra:view']['hydra:first'], 1) as number
-      metadata.lastPage = HydraDenormalizer.getPageFromUri(data['hydra:view']['hydra:last'], 1) as number
-      metadata.nextPage = HydraDenormalizer.getPageFromUri(data['hydra:view']['hydra:next']) ?? undefined
-      metadata.previousPage = HydraDenormalizer.getPageFromUri(data['hydra:view']['hydra:previous']) ?? undefined
-    }
-
-    metadata.type = METADATA_TYPE.COLLECTION
-
-    return metadata
-  }
-
-  /**
-   * Hydrate l'objet JSON de façon récursive (afin de gérer les objet nested)
-   *
-   * @param {AnyJson} data
-   */
-  private static populateAllData (data: AnyJson): void {
-    for (const key in data) {
-      const value = data[key]
-      if (value instanceof Object) {
-        HydraDenormalizer.populateAllData(value)
-      }
-    }
-  }
-
-  private static getPageFromUri(uri: string, default_: number | null = null): number | null {
-    const url = 'https://foo' + uri // Pour que l'uri partielle soit parsée, on doit y ajouter une url de base bidon
-    const page = Url.getParameter(url, 'page')
-    return page ? parseInt(page) : default_
-  }
-}
-
-export default HydraDenormalizer

+ 0 - 21
services/data/serializer/denormalizer/yamlDenormalizer.ts

@@ -1,21 +0,0 @@
-import { read } from 'yaml-import'
-import {dump, load} from 'js-yaml';
-import {AnyJson} from "~/types/data";
-
-
-/**
- * Classe permettant d'assurer la dénormalization d'un fichier JSON en JSON
- */
-class YamlDenormalizer {
-  /**
-   * Parse un fichier Yaml pour en retourner son équivalent en Json
-   *
-   * @param {AnyJson} data
-   * @return {AnyJson}
-   */
-  public static denormalize (data: AnyJson): AnyJson {
-      return load(dump(read(data.path))) as AnyJson
-  }
-}
-
-export default YamlDenormalizer

+ 0 - 17
services/data/serializer/normalizer/modelNormalizer.ts

@@ -1,17 +0,0 @@
-import ApiModel from "~/models/ApiModel";
-
-/**
- * Classe assurant la normalization d'un Model vers un fichier JSON
- */
-class ModelNormalizer {
-  /**
-   * Récupération de l'Item souhaité puis transformation en JSON
-   *
-   * @return {any} réponse
-   * @param subject
-   */
-  public static normalize (subject: ApiModel): any {
-    return subject.$toJson()
-  }
-}
-export default ModelNormalizer

+ 36 - 0
services/encoder/yamlEncoder.ts

@@ -0,0 +1,36 @@
+import {dump, load} from 'js-yaml';
+import {AnyJson} from "~/types/data";
+import {Encoder} from "~/types/interfaces";
+
+/**
+ * Encodage / décodage de données au format Yaml
+ */
+class YamlEncoder implements Encoder {
+  //   TODO: voir si toujours utile de le garder? ou peut-être en faire un YamlUtils et ajouter la fonction 'readFile'
+  //       utilisée dans le abilityBuilder
+  /**
+   * Convertit l'objet suivant au format Yaml
+   *
+   * @param {AnyJson} data
+   * @return {AnyJson}
+   */
+  public encode (data: AnyJson): string {
+      return dump(data)
+  }
+
+  /**
+   * Parse une chaine de caractères au format Yaml et
+   * retourne son équivalent sous forme d'objet
+   *
+   * @param {AnyJson} data
+   * @return {AnyJson}
+   */
+  public decode (data: string): AnyJson | null {
+      if (!data) {
+          return null
+      }
+      return load(data) as AnyJson
+  }
+}
+
+export default new YamlEncoder()

+ 6 - 3
services/menuBuilder/abstractMenuBuilder.ts → services/layout/menuBuilder/abstractMenuBuilder.ts

@@ -1,7 +1,7 @@
 import {IconItem, MenuBuilder, MenuGroup, MenuItem, MenuItems} from '~/types/layout'
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 import {RuntimeConfig} from "@nuxt/schema";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {AnyAbility} from "@casl/ability";
 import {AccessProfile, organizationState} from "~/types/interfaces";
 
@@ -86,8 +86,11 @@ abstract class AbstractMenuBuilder implements MenuBuilder {
 
     switch(type) {
       case MENU_LINK_TYPE.V1:
-        const v1BaseURL = this.runtimeConfig.baseUrlAdminLegacy ?? this.runtimeConfig.public.baseUrlAdminLegacy
-        url = Url.join(v1BaseURL, to ?? '')
+        const v1BaseURL = this.runtimeConfig.baseUrlAdminLegacy || this.runtimeConfig.public.baseUrlAdminLegacy
+        url = UrlUtils.join(v1BaseURL, to)
+        break;
+      case MENU_LINK_TYPE.EXTERNAL:
+        url = UrlUtils.prependHttps(to)
         break;
       default:
         url = to

+ 3 - 3
services/menuBuilder/accessMenuBuilder.ts → services/layout/menuBuilder/accessMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 import {useOrganizationProfileStore} from "~/stores/organizationProfile";
@@ -16,8 +16,8 @@ export default class AccessMenuBuilder extends AbstractMenuBuilder {
     const children: MenuItems = []
 
     if (this.ability.can('display', 'accesses_page')) {
-      const organizationProfile = useOrganizationProfileStore()
-      const to = organizationProfile.isSchool ? '/students/list/' : '/adherent/list/'
+      // @ts-ignore
+      const to = this.organizationProfile.isSchool ? '/students/list/' : '/adherent/list/'
       children.push(this.createItem('person', {name: 'fas fa-user'}, to, MENU_LINK_TYPE.V1))
     }
 

+ 1 - 1
services/menuBuilder/accountMenuBuilder.ts → services/layout/menuBuilder/accountMenuBuilder.ts

@@ -1,6 +1,6 @@
 import {MenuGroup, MenuItem, MenuItems} from '~/types/layout'
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 
 /**
  * Menu Mon compte

+ 1 - 1
services/menuBuilder/admin2iosMenuBuilder.ts → services/layout/menuBuilder/admin2iosMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 2 - 2
services/menuBuilder/agendaMenuBuilder.ts → services/layout/menuBuilder/agendaMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 
@@ -24,7 +24,7 @@ export default class AgendaMenuBuilder extends AbstractMenuBuilder {
 
     if (children.length > 1) {
       // Plusieurs éléments, on retourne un groupe
-      this.createGroup('schedule', {name: 'fas fa-calendar-alt'}, children)
+      return this.createGroup('schedule', {name: 'fas fa-calendar-alt'}, children)
     }
     else if (children.length === 1) {
       // Un seul élément, on retourne cet élément seul

+ 1 - 1
services/menuBuilder/billingMenuBuilder.ts → services/layout/menuBuilder/billingMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 1 - 1
services/menuBuilder/communicationMenuBuilder.ts → services/layout/menuBuilder/communicationMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 1 - 1
services/menuBuilder/configurationMenuBuilder.ts → services/layout/menuBuilder/configurationMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 1 - 1
services/menuBuilder/cotisationsMenuBuilder.ts → services/layout/menuBuilder/cotisationsMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuItems, MenuGroup, MenuItem} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 1 - 1
services/menuBuilder/donorsMenuBuilder.ts → services/layout/menuBuilder/donorsMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuItem} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 1 - 1
services/menuBuilder/educationalMenuBuilder.ts → services/layout/menuBuilder/educationalMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 1 - 1
services/menuBuilder/equipmentMenuBuilder.ts → services/layout/menuBuilder/equipmentMenuBuilder.ts

@@ -1,6 +1,6 @@
 import {MenuItem} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
-import AbstractMenuBuilder from "~/services/menuBuilder/abstractMenuBuilder";
+import AbstractMenuBuilder from "~/services/layout/menuBuilder/abstractMenuBuilder";
 
 /**
  * Menu Matériel

+ 13 - 13
services/menuBuilder/mainMenuBuilder.ts → services/layout/menuBuilder/mainMenuBuilder.ts

@@ -1,17 +1,17 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
-import AccessMenuBuilder from "~/services/menuBuilder/accessMenuBuilder";
-import AgendaMenuBuilder from "~/services/menuBuilder/agendaMenuBuilder";
-import EquipmentMenuBuilder from "~/services/menuBuilder/equipmentMenuBuilder";
-import EducationalMenuBuilder from "~/services/menuBuilder/educationalMenuBuilder";
-import BillingMenuBuilder from "~/services/menuBuilder/billingMenuBuilder";
-import CommunicationMenuBuilder from "~/services/menuBuilder/communicationMenuBuilder";
-import MedalsMenuBuilder from "~/services/menuBuilder/medalsMenuBuilder";
-import DonorsMenuBuilder from "~/services/menuBuilder/donorsMenuBuilder";
-import WebsiteAdminMenuBuilder from "~/services/menuBuilder/websiteAdminMenuBuilder";
-import CotisationsMenuBuilder from "~/services/menuBuilder/cotisationsMenuBuilder";
-import StatsMenuBuilder from "~/services/menuBuilder/statsMenuBuilder";
-import Admin2iosMenuBuilder from "~/services/menuBuilder/admin2iosMenuBuilder";
+import AccessMenuBuilder from "~/services/layout/menuBuilder/accessMenuBuilder";
+import AgendaMenuBuilder from "~/services/layout/menuBuilder/agendaMenuBuilder";
+import EquipmentMenuBuilder from "~/services/layout/menuBuilder/equipmentMenuBuilder";
+import EducationalMenuBuilder from "~/services/layout/menuBuilder/educationalMenuBuilder";
+import BillingMenuBuilder from "~/services/layout/menuBuilder/billingMenuBuilder";
+import CommunicationMenuBuilder from "~/services/layout/menuBuilder/communicationMenuBuilder";
+import MedalsMenuBuilder from "~/services/layout/menuBuilder/medalsMenuBuilder";
+import DonorsMenuBuilder from "~/services/layout/menuBuilder/donorsMenuBuilder";
+import WebsiteAdminMenuBuilder from "~/services/layout/menuBuilder/websiteAdminMenuBuilder";
+import CotisationsMenuBuilder from "~/services/layout/menuBuilder/cotisationsMenuBuilder";
+import StatsMenuBuilder from "~/services/layout/menuBuilder/statsMenuBuilder";
+import Admin2iosMenuBuilder from "~/services/layout/menuBuilder/admin2iosMenuBuilder";
 
 /**
  * Menu principal (ou menu lateral)

+ 1 - 1
services/menuBuilder/medalsMenuBuilder.ts → services/layout/menuBuilder/medalsMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuItem} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 3 - 2
services/menuBuilder/myAccessesMenuBuilder.ts → services/layout/menuBuilder/myAccessesMenuBuilder.ts

@@ -1,6 +1,7 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import _ from "lodash"
 
 /**
  * Menu Mon Profil
@@ -14,7 +15,7 @@ export default class MyAccessesMenuBuilder extends AbstractMenuBuilder {
   build(): MenuGroup | null {
     const children: MenuItems = []
 
-    useEach(this.accessProfile.multiAccesses, (access) => {
+    _.each(this.accessProfile.multiAccesses, (access) => {
       children.push(this.createItem(access.name as string, undefined, '/switch/' + access.id, MENU_LINK_TYPE.V1))
     })
 

+ 4 - 3
services/menuBuilder/myFamilyMenuBuilder.ts → services/layout/menuBuilder/myFamilyMenuBuilder.ts

@@ -1,6 +1,7 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import _ from "lodash"
 
 /**
  * Menu Famille
@@ -15,7 +16,7 @@ export default class MyFamilyMenuBuilder extends AbstractMenuBuilder {
     const children: MenuItems = []
 
     // Si Access des membres de la familles (enfants)
-    useEach(this.accessProfile.familyAccesses, (access) => {
+    _.each(this.accessProfile.familyAccesses, (access) => {
       const url = `/switch_user/${this.organizationProfile.id}/${this.accessProfile.id}/${access.id}`
       const icon = {
         avatarId: access.avatarId,
@@ -33,7 +34,7 @@ export default class MyFamilyMenuBuilder extends AbstractMenuBuilder {
     }
 
     if (children.length > 0) {
-      this.createGroup('familyAccesses', {name: 'fas fa-users'}, children)
+      return this.createGroup('familyAccesses', {name: 'fas fa-users'}, children)
     }
 
     return null

+ 1 - 1
services/menuBuilder/parametersMenuBuilder.ts → services/layout/menuBuilder/parametersMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 

+ 2 - 2
services/menuBuilder/statsMenuBuilder.ts → services/layout/menuBuilder/statsMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItem, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 
@@ -19,7 +19,7 @@ export default class StatsMenuBuilder extends AbstractMenuBuilder {
     }
 
     if (this.ability.can('display', 'education_quotas_page')) {
-      children.push(this.createItem('educations_quotas_by_education', {name: 'fas fa-user-circle'},  '/educations_quotas_by_education_year/list/', MENU_LINK_TYPE.V1))
+      children.push(this.createItem('educations_quotas_by_education', {name: 'fas fa-user-circle'}, '/educations_quotas_by_education_year/list/', MENU_LINK_TYPE.V1))
     }
 
     if (this.ability.can('display', 'fede_stats_page')) {

+ 2 - 2
services/menuBuilder/websiteAdminMenuBuilder.ts → services/layout/menuBuilder/websiteAdminMenuBuilder.ts

@@ -1,4 +1,4 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuItem} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
 
@@ -14,7 +14,7 @@ export default class WebsiteAdminMenuBuilder extends AbstractMenuBuilder {
   build(): MenuItem | null {
     if (this.organizationProfile.website && this.accessProfile.isAdminAccess) {
       const url = this.organizationProfile.website + '/typo3'
-      return this.createItem('advanced_modification', {name: 'fas fa-globe-americas'}, url, MENU_LINK_TYPE.V1)
+      return this.createItem('advanced_modification', {name: 'fas fa-globe-americas'}, url, MENU_LINK_TYPE.EXTERNAL)
     }
 
     return null

+ 7 - 5
services/menuBuilder/websiteListMenuBuilder.ts → services/layout/menuBuilder/websiteListMenuBuilder.ts

@@ -1,6 +1,7 @@
-import AbstractMenuBuilder from '~/services/menuBuilder/abstractMenuBuilder'
+import AbstractMenuBuilder from '~/services/layout/menuBuilder/abstractMenuBuilder'
 import {MenuGroup, MenuItems} from "~/types/layout";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import _ from "lodash"
 
 /**
  * Menu : Liste des sites internet de la structure et de ses structures parentes
@@ -14,10 +15,11 @@ export default class WebsiteListMenuBuilder extends AbstractMenuBuilder {
   build(): MenuGroup | null {
     const children: MenuItems = []
 
-    const url = this.organizationProfile.website + '/typo3'
-    children.push(this.createItem(this.organizationProfile.name as string, undefined, url, MENU_LINK_TYPE.V1))
-
-    useEach(this.organizationProfile.parents, (parent:any) => {
+    if (this.organizationProfile.website) {
+      const url = this.organizationProfile.website + '/typo3'
+      children.push(this.createItem(this.organizationProfile.name as string, undefined, url, MENU_LINK_TYPE.EXTERNAL))
+    }
+    _.each(this.organizationProfile.parents, (parent:any) => {
       if(parent.id != this.runtimeConfig.OPENTALENT_MANAGER_ID){
         children.push(this.createItem(parent.name, undefined, parent.website, MENU_LINK_TYPE.EXTERNAL))
       }

+ 62 - 0
services/layout/menuComposer.ts

@@ -0,0 +1,62 @@
+import MainMenuBuilder from "~/services/layout/menuBuilder/mainMenuBuilder";
+import {AccessProfile, LayoutState, organizationState} from "~/types/interfaces";
+import WebsiteListMenuBuilder from "~/services/layout/menuBuilder/websiteListMenuBuilder";
+import MyAccessesMenuBuilder from "~/services/layout/menuBuilder/myAccessesMenuBuilder";
+import MyFamilyMenuBuilder from "~/services/layout/menuBuilder/myFamilyMenuBuilder";
+import ConfigurationMenuBuilder from "~/services/layout/menuBuilder/configurationMenuBuilder";
+import AccountMenuBuilder from "~/services/layout/menuBuilder/accountMenuBuilder";
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability";
+import {MenuBuilder, MenuGroup} from "~/types/layout";
+
+/**
+ * Gestionnaire principal des menus de l'application
+ */
+export default class MenuComposer {
+
+    // @ts-ignore TODO: mieux typer cette prop
+    protected static readonly builders: Array<any> = [
+        MainMenuBuilder,
+        WebsiteListMenuBuilder,
+        MyAccessesMenuBuilder,
+        MyFamilyMenuBuilder,
+        ConfigurationMenuBuilder,
+        AccountMenuBuilder,
+    ]
+
+    /**
+     * Construit l'ensemble des menus et les insère dans le store Layout
+     *
+     * @param runtimeConfig
+     * @param ability
+     * @param organizationProfile
+     * @param accessProfile
+     * @param layoutState
+     */
+    public static build(
+        runtimeConfig: RuntimeConfig,
+        ability: AnyAbility,
+        organizationProfile: organizationState,
+        accessProfile: AccessProfile,
+        layoutState: LayoutState
+    ) {
+        for (const builderClass of MenuComposer.builders) {
+
+            const builder: MenuBuilder = new builderClass(runtimeConfig, ability, organizationProfile, accessProfile)
+            const menuName = builder.getMenuName()
+            const menu = builder.build();
+
+            if (!menu) {
+                continue
+            }
+
+            // Store a ref to the menu in the store
+            layoutState.menus[menuName] = menu
+
+            // On enregistre l'état du menu dans le store de la page
+            if ( ((menu as MenuGroup).children ?? []).length > 0 ) {
+                layoutState.menusOpened[menuName] = false
+            }
+        }
+    }
+}

+ 0 - 56
services/menuBuilder/menuComposer.ts

@@ -1,56 +0,0 @@
-import MainMenuBuilder from "~/services/menuBuilder/mainMenuBuilder";
-import {AccessProfile, LayoutState, organizationState} from "~/types/interfaces";
-import WebsiteListMenuBuilder from "~/services/menuBuilder/websiteListMenuBuilder";
-import MyAccessesMenuBuilder from "~/services/menuBuilder/myAccessesMenuBuilder";
-import MyFamilyMenuBuilder from "~/services/menuBuilder/myFamilyMenuBuilder";
-import ConfigurationMenuBuilder from "~/services/menuBuilder/configurationMenuBuilder";
-import AccountMenuBuilder from "~/services/menuBuilder/accountMenuBuilder";
-import {RuntimeConfig} from "@nuxt/schema";
-import {AnyAbility} from "@casl/ability";
-import {MenuGroup} from "~/types/layout";
-
-/**
- * Gestionnaire principal des menus de l'application
- */
-export default class MenuComposer {
-
-    /**
-     * Construit l'ensemble des menus et les insère dans le store Layout
-     *
-     * @param runtimeConfig
-     * @param ability
-     * @param organizationProfile
-     * @param accessProfile
-     * @param layoutState
-     */
-    public static build(
-        runtimeConfig: RuntimeConfig,
-        ability: AnyAbility,
-        organizationProfile: organizationState,
-        accessProfile: AccessProfile,
-        layoutState: LayoutState
-    ) {
-        const builders = [
-            MainMenuBuilder,
-            WebsiteListMenuBuilder,
-            MyAccessesMenuBuilder,
-            MyFamilyMenuBuilder,
-            ConfigurationMenuBuilder,
-            AccountMenuBuilder
-        ]
-        const menus = builders.map(builder => new builder(runtimeConfig, ability, organizationProfile, accessProfile));
-
-        for(let menu of menus){
-            const menuBuilt = menu.build();
-
-            if(menuBuilt){
-                layoutState.menus[menu.getMenuName()] = menuBuilt
-
-                // On enregistre l'état du menu dans le store de la page
-                if ( ((menuBuilt as MenuGroup).children ?? []).length > 0 ) {
-                    layoutState.menusOpened[menu.getMenuName()] = false
-                }
-            }
-        }
-    }
-}

+ 11 - 38
services/rights/abilityBuilder.ts

@@ -1,8 +1,9 @@
 import RoleUtils from '~/services/rights/roleUtils'
 import {AbilitiesType} from '~/types/interfaces'
-import YamlDenormalizer from "~/services/data/serializer/denormalizer/yamlDenormalizer";
 import {MongoAbility} from "@casl/ability/dist/types/Ability";
 import {ABILITIES} from "~/types/enum/enums";
+import yaml from "yaml-import";
+import _ from "lodash"
 
 interface Condition {
     function: string
@@ -19,7 +20,7 @@ class AbilityBuilder {
     private readonly accessProfile: any
     private readonly organizationProfile: any
 
-    private readonly configDir = './config/abilities/config.yaml'
+    private readonly configFile = './config/abilities/config.yaml'
 
     private abilities: Array<AbilitiesType> = []
 
@@ -36,44 +37,13 @@ class AbilityBuilder {
         this.organizationProfile = organizationProfile
     }
 
-    /**
-     * Construit les habilités de l'utilisateur selon son profil et met à jour MongoAbility en fonction
-     */
-    setupAbilities() {
-        // Nécessaire pour que l'update des habilités soit correcte après la phase SSR
-        this.ability.update(this.accessProfile.abilities)
-
-        // Au moment où l'on effectue une action organizationProfileStore.setProfile, il faut aller récupérer
-        // les différentes habilités que l'utilisateur peut effectuer. (Tout cela se passe en SSR)
-        const unsubscribe = this.organizationProfile.$onAction(({
-                                                                    name, // name of the action
-                                                                    store, // store instance, same as `someStore`
-                                                                    args, // array of parameters passed to the action
-                                                                    after, // hook after the action returns or resolves
-                                                                    onError, // hook if the action throws or rejects
-                                                                }: any) => {
-            after((result: any)=>{
-                if (name === 'setProfile'){
-                    //On construit les habilités
-                    this.buildAbilities();
-
-                    //On les store puis on update le service ability pour le mettre à jour.
-                    this.accessProfile.abilities = this.abilities
-
-                    // Unsubscribe pour éviter les memory leaks
-                    unsubscribe()
-                }
-            })
-        })
-    }
-
     /**
      * Construit et renvoie l'ensemble des habilités de l'utilisateur, qu'elles soient issues de ses roles
      * ou de la configuration
      *
      * @return {Array<AbilitiesType>}
      */
-    buildAbilities() {
+    buildAbilities(): Array<AbilitiesType> {
         // Build from roles
         this.abilities = this.buildAbilitiesFromRoles()
         this.ability.update(this.abilities)
@@ -81,6 +51,8 @@ class AbilityBuilder {
         // Build from config
         this.abilities = this.abilities.concat(this.buildAbilitiesFromConfig())
         this.ability.update(this.abilities)
+
+        return this.abilities
     }
 
     /**
@@ -96,10 +68,10 @@ class AbilityBuilder {
     buildAbilitiesFromConfig() {
         const abilitiesByConfig: Array<AbilitiesType> = []
 
-        const doc = YamlDenormalizer.denormalize({path: this.configDir})
+        const doc = yaml.read(this.configFile)
         const fromConfig = doc.abilities
 
-        useEach(fromConfig, (ability: { action: ABILITIES, conditions: Array<Condition> }, subject: string) => {
+        _.each(fromConfig, (ability: { action: ABILITIES, conditions: Array<Condition> }, subject: string) => {
             let { action, conditions } = ability
 
             if (!Array.isArray(conditions)) {
@@ -126,6 +98,7 @@ class AbilityBuilder {
         return conditions.every((condition) => this.execAndValidateCondition(condition, subject))
     }
 
+    // noinspection JSUnusedGlobalSymbols
     /**
      * Correspondances entre les noms des fonctions définies dans les conditions des fichiers de configuration et
      * les méthodes correspondantes
@@ -136,7 +109,7 @@ class AbilityBuilder {
         accessHasAllRoleAbilities: (parameters: any) => this.hasAllRoleAbilities(parameters),
         accessHasAnyRoleAbility: (parameters: any) => this.hasAnyRoleAbility(parameters),
         accessHasAnyProfile: (parameters: any) => parameters === null || this.hasAnyProfile(parameters),
-        accessHasAllModules: (parameters: any) => this.hasAllModules(parameters),
+        organizationHasAllModules: (parameters: any) => this.hasAllModules(parameters),
         organizationHasAnyModule: (parameters: any) => this.hasAnyModule(parameters),
         accessIsAdminAccount: (parameters: any) => this.accessProfile.isAdminAccount,
         organizationIsSchool: (parameters: any) => this.organizationProfile.isSchool,
@@ -156,7 +129,7 @@ class AbilityBuilder {
      * @param subject  For debugging purpose only
      * @private
      */
-    private execAndValidateCondition(
+    protected execAndValidateCondition(
         condition: Condition,
         subject: string = ''
     ) {

+ 10 - 3
services/rights/roleUtils.ts

@@ -1,5 +1,6 @@
 import { AbilitiesType } from '~/types/interfaces'
-import {AnyJson} from "~/types/data";
+import {AnyJson} from "~/types/data"
+import _ from "lodash"
 
 // TODO: peut-être passer ces constantes dans la config?
 const rolesByFunction: Array<string> = [
@@ -59,9 +60,11 @@ class RoleUtils {
    */
   static isA (profileName: string, roles: Array<string>): boolean {
     profileName = profileName.toUpperCase()
-    if (!profileName.match(/[A-Z_]+/)) {
+    if (!profileName.match(/^[A-Z_]+$/)) {
       throw new Error('invalid role name')
     }
+    // TODO: actuellement, passer un profil ne corresondant à aucun rôle ne lèvera aucune erreur, et se contentera de
+    //       retourner false; ce serait pas mal de lever une erreur, ce ne serait pas normal de demander un rôle inexistant
     return roles.includes('ROLE_' + profileName + '_CORE')
   }
 
@@ -86,7 +89,9 @@ class RoleUtils {
    * @return {Array<string>}
    */
   static transformUnderscoreToHyphen (roles: Array<string>): Array<string> {
+    // TODO: clarifier le fonctionnement de cette méthode et de la regex, qu'est-ce qui sépare le groupe 2 et 3 au final?
     const regex = /(ROLE_)([A-Z]*_[A-Z]*)([A-Z_]*)*/i
+
     let match
     roles = roles.map((role) => {
       if (rolesToChange.includes(role)) {
@@ -118,10 +123,12 @@ class RoleUtils {
 
     roles = RoleUtils.transformUnderscoreToHyphen(roles)
 
+    // TODO: on pourrait peut-être faciliter la lecture en réécrivant la regex en `ROLE_([A-Z-]*)(_[A-Z]+)?`, ou
+    //       même en faisant un simple split sur le '_'
     const regex = /(ROLE_)([A-Z-]*)([_A-Z]*)/i
     let match
 
-    useEach(roles, (role) => {
+    _.each(roles, (role) => {
       if ((match = regex.exec(role)) !== null) {
         const subject = match[2]
         const actionName = match[3] ?? ''

+ 7 - 7
services/sse/sseSource.ts

@@ -1,12 +1,12 @@
 import { EventSourcePolyfill } from "event-source-polyfill";
 
 class SseSource {
-  private readonly url: URL
-  private readonly onOpen: (() => void)
-  private readonly onMessage: ((eventData: Array<any>) => void)
-  private readonly onClose: (() => void)
-  private readonly withCredentials: boolean
-  protected eventSource: EventSource | null = null
+  protected readonly url: URL
+  protected readonly onOpen: (() => void)
+  protected readonly onMessage: ((eventData: Array<any>) => void)
+  protected readonly onClose: (() => void)
+  protected readonly withCredentials: boolean
+  protected eventSource: EventSourcePolyfill | null = null
 
   constructor(
     mercureHubBaseUrl: string,
@@ -62,7 +62,7 @@ class SseSource {
   }
 
   public unsubscribe () {
-    if (this.eventSource === null || this.eventSource.readyState === EventSource.CLOSED) {
+    if (this.eventSource === null || this.eventSource.readyState === EventSourcePolyfill.CLOSED) {
       return
     }
     this.eventSource.close()

+ 15 - 7
services/utils/arrayUtils.ts

@@ -3,19 +3,27 @@ export default class ArrayUtils {
 
   /**
    * Trie un tableau
+   *
    * @param array
+   * @param reverse
    */
-  public static sort(array: Array<any>): Array<Object> {
-    return array.sort((a: any, b: any) => a - b)
+  public static sort(array: Array<any>, reverse: boolean = false): Array<Object> {
+    return array.sort((a, b) => {
+      return ((a < b) ? -1 : (a > b ? 1 : 0)) * (reverse ? -1 : 1)
+    })
   }
 
   /**
-   * Trie un tableau d'objets
-   * @param array
-   * @param param
+   * Trie un tableau d'objets selon une propriété commune
+   *
+   * @param array Une array d'objets
+   * @param property Le nom d'une propriété possédée par tous les objets
+   * @param reverse
    */
-  public static sortArrayOfObject(array: Array<Object>, param: any): Array<Object> {
-    return array.sort((a: any, b: any) => a[param] - b[param])
+  public static sortObjectsByProp(array: Array<Object>, property: string, reverse: boolean = false): Array<Object> {
+    return array.sort((a: any, b: any) => {
+      return ((a[property] < b[property]) ? -1 : (a[property] > b[property] ? 1 : 0)) * (reverse ? -1 : 1)
+    })
   }
 
 }

+ 8 - 10
services/utils/dateUtils.ts

@@ -1,8 +1,9 @@
 import { format } from 'date-fns';
+import ArrayUtils from "~/services/utils/arrayUtils";
 
 export default class DateUtils {
 
-  public static format(date: any, fmt: string): string {
+  public static format(date: Date, fmt: string): string {
     return format(date, fmt)
   }
 
@@ -13,21 +14,18 @@ export default class DateUtils {
    * @param fmt
    * @param sep
    */
-  public static formatDatesAndConcat (dates: any, fmt: string, sep: string = ' - '): string {
+  public static formatAndConcat (dates: Date | Array<Date>, fmt: string, sep: string = ' - '): string {
     dates = Array.isArray(dates) ? dates : [dates]
-
-    const dFormat: Array<string> = Array.isArray(dates) ? dates : [dates]
-    for (const date of dates) {
-      dFormat.push(this.format(date, fmt))
-    }
-    return dFormat.join(sep)
+    return dates.map((d) => this.format(d, fmt)).join(sep)
   }
 
   /**
    * Trie les dates par ordre chronologique
+   *
    * @param dates
+   * @param reverse
    */
-  public static sortDate (dates: Array<string>): Array<string> {
-    return dates.sort()
+  public static sort(dates: Array<Date>, reverse: boolean = false): Array<Date> {
+    return ArrayUtils.sort(dates, reverse) as Array<Date>
   }
 }

+ 5 - 5
services/utils/i18nUtils.ts

@@ -1,6 +1,6 @@
 import {VueI18n} from "vue-i18n";
 import {EnumChoice, EnumChoices} from "~/types/interfaces";
-import {parsePhoneNumber} from "libphonenumber-js";
+import {CountryCode, parsePhoneNumber} from "libphonenumber-js";
 import ArrayUtils from "~/services/utils/arrayUtils";
 
 export default class I18nUtils {
@@ -21,19 +21,19 @@ export default class I18nUtils {
     public translateEnum(enum_: EnumChoices, sort: boolean = true): EnumChoices {
         enum_ = enum_.map(
             (item: EnumChoice) => {
-                return {value: item.value, label: this.i18n.t(item.value) as string}
+                return {value: item.value, label: this.i18n.t(item.label) as string}
             }
         )
 
         if (sort) {
-            enum_ = ArrayUtils.sortArrayOfObject(enum_, 'label') as EnumChoices
+            enum_ = ArrayUtils.sortObjectsByProp(enum_, 'label') as EnumChoices
         }
 
         return enum_
     }
 
-    public formatPhoneNumber (number: string): string {
-        const parsed = parsePhoneNumber(number)
+    public formatPhoneNumber (number: string, defaultCountry?: CountryCode): string {
+        const parsed = parsePhoneNumber(number, defaultCountry)
         return parsed ? parsed.formatNational() : ''
     }
 }

+ 4 - 6
services/utils/imageUtils.ts

@@ -17,12 +17,10 @@ class ImageUtils {
      * Transforme un Blob en Base64
      * @param {Blob} blob
      */
-    public static async blobToBase64(blob: Blob): Promise<string | ArrayBuffer | null> {
-        return new Promise((resolve, _) => {
-            const reader = new FileReader();
-            reader.onloadend = () => resolve(reader.result);
-            reader.readAsDataURL(blob);
-        });
+    public static async blobToBase64(blob: Blob): Promise<string> {
+        // /!\ Attention: lors de tests unitaires, 'blob.text' plantera si utilisée en même temps que vi.useFakeTimers()
+        const content = Buffer.from(await blob.text()).toString('base64');
+        return `data:${blob.type};base64,${content}`
     }
 }
 export default ImageUtils

+ 9 - 4
services/utils/objectUtils.ts

@@ -9,6 +9,7 @@ export default class ObjectUtils {
   /**
    * Flatten un objet nested en un objet avec un seul niveau avec des noms de propriétés transformées comme cela 'foo.bar'
    * L'objet passé en paramètre reste inchangé car il est cloné
+   *
    * @example  cloneAndFlatten({ a: 1, b: { c: 2 }, d: { e: 3, f: { g: 4, h: 5 } }, i: { j: 6 } }, ['i']) => { a: 1, 'b.c': 2, 'd.e': 3, 'd.f.g': 4, 'd.f.h': 5, i: { j: 6 } } }
    * @param {AnyJson} object
    * @param {Array<string>} excludedProperties
@@ -46,6 +47,7 @@ export default class ObjectUtils {
 
   /**
    * Transforme un objet flattened en un objet nested. L'objet passé en paramètre reste inchangé
+   *
    * @example cloneAndNest({ a: 1, 'b.c': 2, 'd.e': 3, 'd.f.g': 4, 'd.f.h': 5 } ) => { a: 1, b: { c: 2 }, d: { e: 3, f: { g: 4, h: 5 } } }
    * @param {AnyJson} object
    * @return {AnyJson}
@@ -75,6 +77,7 @@ export default class ObjectUtils {
 
   /**
    * Teste si le paramètre est un objet
+   *
    * @param {AnyJson} value
    * @return {boolean}
    */
@@ -86,7 +89,8 @@ export default class ObjectUtils {
   }
 
   /**
-   * Clône l'objet et ses propriétés.
+   * Clone l'objet et ses propriétés.
+   *
    * @param {ObjectUtils} object
    * @return {ObjectUtils}
    */
@@ -100,11 +104,12 @@ export default class ObjectUtils {
   }
 
   /**
-   * Tri un objet par rapport à ses clés (par ordre alpha)
-   * @example sortObjectByKey({b:1, d:2, c:3, a:4}) => {a:4, b:1, c:3, d:2}
+   * Trie un objet selon ses clés (par ordre alphanumérique)
+   *
+   * @example sortObjectsByKey({b:1, d:2, c:3, a:4}) => {a:4, b:1, c:3, d:2}
    * @param toSort
    */
-  static sortObjectByKey (toSort: any): any {
+  static sortObjectsByKey (toSort: AnyJson): any {
     if (typeof toSort !== 'object') {
       throw new TypeError('Expecting an object parameter')
     }

+ 21 - 24
services/utils/url.ts → services/utils/urlUtils.ts

@@ -1,7 +1,7 @@
 /**
  * Classe permettant de construire une URL pour l'interrogation d'une API externe
  */
-export default class Url {
+class UrlUtils {
   /**
    * Concatenate a base url and a tail
    * @param base
@@ -17,7 +17,7 @@ export default class Url {
   }
 
   /**
-   * Prepend the 'https://' part if neither 'http://' of 'https://' is present, else: does nothing
+   * Prepend the 'https://' part if neither 'http://' nor 'https://' prefixes are present, else does nothing
    *
    * @param url
    */
@@ -29,52 +29,47 @@ export default class Url {
   }
 
   /**
-   * Parse an URI to retrieve the parameter
+   * Parse an URI to retrieve a parameter
    *
    * @param uri
    * @param parameter
    * @param default_
    * @private
    */
-  public static getParameter(
+  public static getParameter (
       uri: string,
       parameter: string,
-      default_: string | null = null
-  ): string | null {
+      default_: string | number | null = null
+  ): string | number | null {
+    uri = UrlUtils.prependHttps(uri)
 
     const urlParams = new URL(uri).searchParams;
-    const res = urlParams.get(parameter);
+    let value: string | number | null = urlParams.get(parameter);
 
-    return res ?? default_
+    if (value && (default_ === null || Number.isInteger(default_)) && /^\d+$/.test(value)) {
+      // On convertit automatiquement si et seulement la valeur par défaut est elle-même un entier ou n'est pas définie
+      value = parseInt(value)
+    }
+
+    return value ?? default_
   }
 
   /**
    * Extrait l'ID de l'URI passée en paramètre
-   * L'uri est supposée être de la forme `.../foo/bar/{id}`, où l'id est un identifiant numérique
+   * L'URI est supposée être de la forme `.../foo/bar/{id}`, où l'id est un identifiant numérique
    *
    * @param uri
    */
   public static extractIdFromUri (uri: string): number|null {
     const partUri: Array<string> = uri.split('/')
-    const id:any = partUri.pop()
-
-    if(isNaN(id))
-      throw new Error('id is not a number')
+    const id: any = partUri.pop()
 
+    if (!id || isNaN(id)) {
+      throw new Error('no id found')
+    }
     return parseInt(id)
   }
 
-  /**
-   * Extrait l'Uuid de l'URI passée en paramètre
-   * L'uri est supposée être de la forme `.../foo/bar/{uuid}`
-   *
-   * @param uri
-   */
-  public static extractUuidFromUri (uri: string): string|null {
-    const partUri: Array<string> = uri.split('/')
-    return String(partUri.pop())
-  }
-
   /**
    * Découpe une URI au niveau des '/'
    * Utilisé entre autres pour le breadcrumb
@@ -92,3 +87,5 @@ export default class Url {
     return uri.split('/').filter((s) => s.length > 0)
   }
 }
+
+export default UrlUtils

+ 2 - 2
stores/accessProfile.ts

@@ -130,8 +130,8 @@ export const useAccessProfileStore = defineStore('accessProfile', () => {
     setFamilyAccesses(profile.familyAccesses)
 
     // Time to set Organization Profile
-    const organizationProfileStore = useOrganizationProfileStore()
-    organizationProfileStore.setProfile(profile.organization)
+    const organizationProfile = useOrganizationProfileStore()
+    organizationProfile.setProfile(profile.organization)
   }
 
   const refreshProfile = (profile: any) => {

+ 2 - 3
stores/organizationProfile.ts

@@ -1,7 +1,6 @@
 import { BaseOrganizationProfile } from '~/types/interfaces'
 import { defineStore } from "pinia";
 import {computed, Ref} from "@vue/reactivity";
-import {useEach} from "#imports";
 
 export const useOrganizationProfileStore = defineStore('organizationProfile', () => {
 
@@ -23,7 +22,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
 
   // Getters
   /**
-   * L'organization fait-elle partie du réseau CMF?
+   * L'organization fait-elle partie du réseau CMF ?
    *
    * @return {boolean}
    */
@@ -34,7 +33,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
   })
 
   /**
-   * L'organization fait-elle partie du réseau FFEC?
+   * L'organization fait-elle partie du réseau FFEC ?
    *
    * @return {boolean}
    */

+ 19 - 0
tests/alias.ts

@@ -0,0 +1,19 @@
+import { resolve } from 'path'
+
+/**
+ * Nécessaire pour permettre à vitest de résoudre les imports relatifs
+ * @see https://github.com/vitest-dev/vitest/discussions/1285
+ */
+
+const r = (p: string) => resolve(__dirname, '..', p)
+
+export const alias: Record<string, string> = {
+    '~': r('.'),
+    '~~': r('.'),
+    '~~/': r('./'),
+    '@@': r('.'),
+    '@@/': r('./'),
+    assets: r('./assets'),
+    public: r('./public'),
+    'public/': r('./public/'),
+}

+ 57 - 0
tests/readme.md

@@ -0,0 +1,57 @@
+# Tests unitaires
+
+Ce répertoire contient les tests unitaires du front Vue.js
+
+> Plus d'infos:
+> 
+> * https://vuejs.org/guide/scaling-up/testing.html#unit-testing
+> * https://test-utils.vuejs.org/
+> * https://plugins.jetbrains.com/plugin/19220-vitest-runner
+
+## Exécuter les tests
+
+Pour exécuter les tests, lancer :
+
+    yarn test
+
+## Mocking
+
+#### Différentes façons pour Mocker
+
+    import axios from 'axios'
+    import { NuxtAxiosInstance } from '@nuxtjs/axios'
+    jest.mock('axios')
+    const mockNuxtAxios = axios as jest.Mocked<NuxtAxiosInstance>
+    mockNuxtAxios.$get = jest.fn().mockReturnValue({})
+
+Ou
+
+    import DataProvider from "~/services/dataProvider/dataProvider";
+    jest.mock('~/services/dataProvider/dataProvider');
+    const DataProviderMock = DataProvider as jest.MockedClass<typeof DataProvider>;
+    DataProviderMock.prototype.invoke.mockImplementation(async () => true)
+
+Ou
+
+    import VueI18n from "vue-i18n";
+    const VueI18nMock = VueI18n as jest.MockedClass<typeof VueI18n>;
+    VueI18nMock.prototype.t = jest.fn().mockReturnValue('siret_error')
+
+Ou, si on définit le fichier ~/services/dataProvider/__mocks__/dataProvider.ts avec l'implémenation de invoke :
+
+    const mock = jest.fn().mockImplementation(() => {
+        return {invoke: () => {
+            return true
+        }};
+    });
+    export default mock;
+
+Alors on appelle useHandleSiret comme cela : 
+
+    const siretResponse = UseChecker.useHandleSiret(new DataProviderMock())
+
+
+ATTENTION : dans ce cas cette déclaration sera prioritaire sur les autres façons de mocker l'implémentation et on ne peut pas avoir 2 types de mock différents pour
+cette classe dans l'application.
+
+Pour pouvoir gérer l'implémentation à la volée, il ne faut pas qu'il y ait le fichier dans le dossier __mocks__

+ 4 - 0
tests/units/readme.md

@@ -0,0 +1,4 @@
+
+# Tests unitaires
+
+> Voir doc/unittest.md

+ 175 - 0
tests/units/services/data/apiRequestService.test.ts

@@ -0,0 +1,175 @@
+import {describe, expect, test} from 'vitest'
+import ApiRequestService from "~/services/data/apiRequestService";
+import {$Fetch, FetchOptions} from "ohmyfetch";
+import {HTTP_METHOD} from "~/types/enum/data";
+import {AssociativeArray} from "~/types/data";
+
+class TestableApiRequestService extends ApiRequestService {
+    public async request(
+        method: HTTP_METHOD,
+        url: string,
+        body: string | null = null,
+        params: AssociativeArray | null = null,
+        query: AssociativeArray | null = null
+    ): Promise<Response> {
+        return super.request(method, url, body, params, query)
+    }
+}
+
+let fetcher: $Fetch
+let apiRequestService: TestableApiRequestService
+
+beforeEach(() => {
+    // @ts-ignore
+    fetcher = vi.fn((url: string, config: FetchOptions) => 'fetch_response') as $Fetch
+    apiRequestService = new TestableApiRequestService(fetcher)
+})
+
+const mockedRequestMethod = (
+    method: HTTP_METHOD,
+    url: string,
+    body: string | null = null,
+    params: AssociativeArray | null = null,
+    query: AssociativeArray | null = null
+) => 'a_response'
+
+describe('get', () => {
+    test('simple call', async () => {
+        // @ts-ignore
+        apiRequestService.request = vi.fn(mockedRequestMethod)
+
+        const result = await apiRequestService.get('https://myapi.com/api/item', { a: 1 })
+
+        expect(result).toEqual('a_response')
+        expect(apiRequestService.request).toHaveBeenCalledWith(
+            HTTP_METHOD.GET, 'https://myapi.com/api/item', null, null, { a: 1 }
+        )
+    })
+})
+
+describe('post', () => {
+    test('simple call', async () => {
+        // @ts-ignore
+        apiRequestService.request = vi.fn(mockedRequestMethod)
+
+        const result = await apiRequestService.post(
+            'https://myapi.com/api/item',
+            'request_body',
+            { a: 1 },
+            { b: 2 },
+        )
+
+        expect(result).toEqual('a_response')
+        expect(apiRequestService.request).toHaveBeenCalledWith(
+            HTTP_METHOD.POST,
+            'https://myapi.com/api/item',
+            'request_body',
+            { a: 1 },
+            { b: 2 }
+        )
+    })
+})
+
+describe('put', () => {
+    test('simple call', async () => {
+        // @ts-ignore
+        apiRequestService.request = vi.fn(mockedRequestMethod)
+
+        const result = await apiRequestService.put(
+            'https://myapi.com/api/item',
+            'request_body',
+            { a: 1 },
+            { b: 2 },
+        )
+
+        expect(result).toEqual('a_response')
+        expect(apiRequestService.request).toHaveBeenCalledWith(
+            HTTP_METHOD.PUT,
+            'https://myapi.com/api/item',
+            'request_body',
+            { a: 1 },
+            { b: 2 }
+        )
+    })
+})
+
+describe('delete', () => {
+    test('simple call', async () => {
+        // @ts-ignore
+        apiRequestService.request = vi.fn(mockedRequestMethod)
+
+        const result = await apiRequestService.delete(
+            'https://myapi.com/api/item',
+            { a: 1 },
+        )
+
+        expect(result).toEqual('a_response')
+        expect(apiRequestService.request).toHaveBeenCalledWith(
+            HTTP_METHOD.DELETE,
+            'https://myapi.com/api/item',
+            null,
+            null,
+            { a: 1 },
+        )
+    })
+})
+
+describe('request', () => {
+    test('simple call', async () => {
+
+        const result = await apiRequestService.request(HTTP_METHOD.GET, 'https://myapi.com/api/item')
+
+        expect(result).toEqual('fetch_response')
+        // @ts-ignore
+        expect(fetcher).toHaveBeenCalledWith('https://myapi.com/api/item', {method: 'GET'})
+    })
+
+    test('post with body', async () => {
+
+        const result = await apiRequestService.request(HTTP_METHOD.POST, 'https://myapi.com/api/item', 'a_body')
+
+        expect(result).toEqual('fetch_response')
+        // @ts-ignore
+        expect(fetcher).toHaveBeenCalledWith('https://myapi.com/api/item', {method: 'POST', body: 'a_body'})
+    })
+
+    test('put with body', async () => {
+
+        const result = await apiRequestService.request(HTTP_METHOD.PUT, 'https://myapi.com/api/item', 'a_body')
+
+        expect(result).toEqual('fetch_response')
+        // @ts-ignore
+        expect(fetcher).toHaveBeenCalledWith('https://myapi.com/api/item', {method: 'PUT', body: 'a_body'})
+    })
+
+    test('get : body must be ignored even if provided', async () => {
+
+        const result = await apiRequestService.request(HTTP_METHOD.GET, 'https://myapi.com/api/item', 'a_body')
+
+        expect(result).toEqual('fetch_response')
+        // @ts-ignore
+        expect(fetcher).toHaveBeenCalledWith('https://myapi.com/api/item', {method: 'GET'})
+    })
+
+    test('with query and params', async () => {
+        const result = await apiRequestService.request(
+            HTTP_METHOD.PUT,
+            'https://myapi.com/api/item',
+            'a_body',
+            { a: 1 },
+            { b: 2 }
+        )
+
+        expect(result).toEqual('fetch_response')
+        // @ts-ignore
+        expect(fetcher).toHaveBeenCalledWith(
+            'https://myapi.com/api/item',
+            {
+                method: 'PUT',
+                body: 'a_body',
+                params: { a: 1 },
+                query: { b: 2 },
+            }
+        )
+    })
+})

+ 842 - 0
tests/units/services/data/entityManager.test.ts

@@ -0,0 +1,842 @@
+import { describe, test, it, expect } from 'vitest'
+import EntityManager from "~/services/data/entityManager";
+import ApiResource from "~/models/ApiResource";
+import ApiModel from "~/models/ApiModel";
+import ApiRequestService from "~/services/data/apiRequestService";
+import {Element, Repository} from "pinia-orm";
+import {ApiResponse} from "~/types/data";
+import HydraDenormalizer from "~/services/data/normalizer/hydraDenormalizer";
+
+class TestableEntityManager extends EntityManager {
+    public cast(model: typeof ApiResource, entity: ApiResource): ApiResource { return super.cast(model, entity) }
+    public async saveResponseAsEntity(model: typeof ApiModel, response: Response) { return super.saveResponseAsEntity(model, response) }
+    public saveInitialState(model: typeof ApiResource, entity: ApiResource) { return super.saveInitialState(model, entity) }
+    public getInitialStateOf(model: typeof ApiResource, id: string | number): ApiResource | null { return super.getInitialStateOf(model, id) }
+    public removeTempAfterPersist(model: typeof ApiResource, tempEntityId: number | string) { return super.removeTempAfterPersist(model, tempEntityId) }
+}
+
+class DummyApiResource extends ApiResource {
+    static entity = 'dummyResource'
+
+}
+
+class DummyApiModel extends ApiModel {
+    static entity = 'dummyModel'
+}
+
+let apiRequestService: ApiRequestService
+let entityManager: TestableEntityManager
+
+beforeEach(() => {
+    // @ts-ignore
+    apiRequestService = vi.fn() as ApiRequestService
+
+    entityManager = new TestableEntityManager(apiRequestService)
+
+    // TODO: s'assurer que les mocks globaux sont bien réinitialisés après les tests, en particulier les fonctions de console
+})
+
+describe('getRepository', () => {
+    // TODO: à revoir
+})
+
+describe('cast', () => {
+    test('simple cast', () => {
+        // @ts-ignore
+        const result = entityManager.cast(DummyApiResource, { id: 1 })
+
+        expect(result instanceof DummyApiResource).toEqual(true)
+    })
+})
+
+describe('getModelFor', () => {
+    // TODO: à revoir
+})
+
+describe('getModelFromIri', () => {
+    test('simple call', () => {
+        // @ts-ignore
+        entityManager.getModelFor = vi.fn((entityName: string) => entityName === 'dummy' ? DummyApiResource : null)
+
+        // @ts-ignore
+        const result = entityManager.getModelFromIri('/api/dummy/123')
+
+        expect(result).toEqual(DummyApiResource)
+    })
+    test('invalide Iri', () => {
+        expect(() => entityManager.getModelFromIri('/invalid')).toThrowError('cannot parse the IRI')
+    })
+})
+
+describe('newInstance', () => {
+
+    test('simple call', () => {
+        const properties = { 'id': 1 }
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        entityManager.saveInitialState = vi.fn((model: typeof ApiResource, entity: ApiResource) => null)
+
+        // @ts-ignore
+        const entity = new DummyApiResource(properties)
+        entity.setModel = vi.fn((model: typeof ApiResource) => null)
+
+        // @ts-ignore
+        repo.make = vi.fn((properties: object) => {
+            // @ts-ignore
+            entity.id = properties.id
+            return entity
+        })
+        // @ts-ignore
+        repo.save = vi.fn((record: Element) => entity)
+
+        const result = entityManager.newInstance(DummyApiResource, properties)
+
+        expect(repo.make).toHaveBeenCalledWith(properties)
+        expect(entity.setModel).toHaveBeenCalledWith(DummyApiResource)
+        expect(repo.save).toHaveBeenCalledWith(entity)
+        expect(entityManager.saveInitialState).toHaveBeenCalledWith(DummyApiResource, entity)
+
+        expect(result.id).toEqual(properties.id)
+    })
+
+    test('with no id provided', () => {
+        const properties = { 'name': 'bob' }
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        entityManager.saveInitialState = vi.fn((model: typeof ApiResource, entity: ApiResource) => null)
+
+        // @ts-ignore
+        const entity = new DummyApiResource(properties)
+        entity.setModel = vi.fn((model: typeof ApiResource) => null)
+
+        // @ts-ignore
+        repo.make = vi.fn((properties: object) => {
+            // @ts-ignore
+            entity.name = properties.name
+            return entity
+        })
+        // @ts-ignore
+        repo.save = vi.fn((record: Element) => entity)
+
+        const result = entityManager.newInstance(DummyApiResource, properties)
+
+        expect(
+            result.id,
+            'id is \'tmp\' followed by a valid uuid-V4'
+        ).toMatch(/tmp[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}/)
+
+        expect(result.name).toEqual(properties.name)
+    })
+})
+
+describe('save', () => {
+    test('simple call', () => {
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        // @ts-ignore
+        repo.save = vi.fn((record: Element) => entity)
+
+        const entity = new DummyApiResource()
+        entityManager.save(DummyApiResource, entity)
+
+        expect(repo.save).toHaveBeenCalledWith(entity)
+    })
+})
+
+describe('find', () => {
+    test('simple call', () => {
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        const entity = new DummyApiResource({ id: 1 })
+        // @ts-ignore
+        repo.find = vi.fn((id: string | number) => entity)
+
+        entityManager.find(DummyApiResource, 1)
+
+        expect(entityManager.getRepository).toHaveBeenCalledWith(DummyApiResource)
+        expect(repo.find).toHaveBeenCalledWith(1)
+    })
+})
+
+describe('fetch', () => {
+    test('not in store, no force refresh', async () => {
+
+        const properties = { id: 1 }
+
+        const entity = new DummyApiResource({ id: 1 })
+
+        // @ts-ignore
+        entityManager.find = vi.fn((model: typeof ApiResource, id: number) => undefined)
+
+        // @ts-ignore
+        apiRequestService.get = vi.fn(async (url: string) => properties)
+
+        // @ts-ignore
+        entityManager.newInstance = vi.fn((model: typeof ApiResource, props: object) => entity)
+
+        const result = await entityManager.fetch(DummyApiResource, 1)
+
+        expect(entityManager.find).toHaveBeenCalledWith(DummyApiResource, 1)
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource/1')
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, properties)
+
+        expect(result).toEqual(entity)
+    })
+
+    test('in store, no force refresh', async () => {
+
+        const properties = { id: 1 }
+
+        const entity = new DummyApiResource({ id: 1 })
+
+        // @ts-ignore
+        entityManager.find = vi.fn((model: typeof ApiResource, id: number) => entity)
+
+        const result = await entityManager.fetch(DummyApiResource, 1)
+
+        expect(entityManager.find).toHaveBeenCalledWith(DummyApiResource, 1)
+        expect(result).toEqual(entity)
+    })
+
+    test('in store, but with force refresh', async () => {
+
+        const properties = { id: 1 }
+
+        const entity = new DummyApiResource({ id: 1 })
+
+        // @ts-ignore
+        entityManager.find = vi.fn((model: typeof ApiResource, id: number) => undefined)
+
+        // @ts-ignore
+        apiRequestService.get = vi.fn(async (url: string) => properties)
+
+        // @ts-ignore
+        entityManager.newInstance = vi.fn((model: typeof ApiResource, props: object) => entity)
+
+        const result = await entityManager.fetch(DummyApiResource, 1, true)
+
+        expect(entityManager.find).toHaveBeenCalledTimes(0)
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource/1')
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, properties)
+
+        expect(result).toEqual(entity)
+    })
+})
+
+describe('fetchCollection', () => {
+    test('simple call', async () => {
+
+        const collection = {
+            '@type': 'hydra:Collection',
+            'hydra:totalItems': 3,
+            'hydra:member': [
+                {id: 1},
+                {id: 2},
+                {id: 3},
+            ]
+        }
+
+        // @ts-ignore
+        apiRequestService.get = vi.fn(async (url: string) => collection)
+
+        // @ts-ignore
+        entityManager.newInstance = vi.fn((model: typeof ApiResource, props: object) => {
+            return new DummyApiResource(props)
+        })
+
+        const result = await entityManager.fetchCollection(DummyApiResource, null)
+
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource', [])
+        expect(entityManager.newInstance).toHaveBeenCalledTimes(3)
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, {id: 1})
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, {id: 2})
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, {id: 3})
+
+        expect(result.items).toEqual([
+            new DummyApiResource({id: 1}),
+            new DummyApiResource({id: 2}),
+            new DummyApiResource({id: 3})
+        ])
+
+        expect(result.pagination, 'default pagination').toEqual({
+            first: 1,
+            last: 1,
+            next: undefined,
+            previous: undefined
+        })
+    })
+
+    test('with a parent', async () => {
+
+        const collection = {
+            '@type': 'hydra:Collection',
+            'hydra:totalItems': 3,
+            'hydra:member': [
+                {id: 1},
+                {id: 2},
+                {id: 3},
+            ]
+        }
+
+        // @ts-ignore
+        apiRequestService.get = vi.fn(async (url: string) => collection)
+
+        // @ts-ignore
+        entityManager.newInstance = vi.fn((model: typeof ApiResource, props: object) => {
+            return new DummyApiResource(props)
+        })
+
+        const parent = new DummyApiModel()
+        parent.id = 100
+        parent.entity = 'dummyModel'  // TODO: je ne comprend pas pqoi cette ligne est nécessaire...
+
+        await entityManager.fetchCollection(DummyApiResource, parent)
+
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyModel/100/dummyResource', [])
+    })
+
+    test('with a query', async () => {
+
+        const collection = {
+            '@type': 'hydra:Collection',
+            'hydra:totalItems': 3,
+            'hydra:member': [
+                {id: 1},
+                {id: 2},
+                {id: 3},
+            ]
+        }
+
+        // @ts-ignore
+        apiRequestService.get = vi.fn(async (url: string) => collection)
+
+        // @ts-ignore
+        entityManager.newInstance = vi.fn((model: typeof ApiResource, props: object) => {
+            return new DummyApiResource(props)
+        })
+
+        await entityManager.fetchCollection(DummyApiResource, null, { page: 10 })
+
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource', { page: 10 })
+    })
+
+    test('with pagination', async () => {
+
+        const collection = {
+            '@type': 'hydra:Collection',
+            'hydra:totalItems': 1000,
+            'hydra:member': [],
+            'hydra:view': {
+                "@id": "/api/subdomains?organization=498&page=50",
+                'hydra:first': '/api/subdomains?organization=498&page=1',
+                'hydra:last': '/api/subdomains?organization=498&page=100',
+                'hydra:next': '/api/subdomains?organization=498&page=51',
+                'hydra:previous': '/api/subdomains?organization=498&page=49'
+            }
+        }
+
+        // @ts-ignore
+        apiRequestService.get = vi.fn(async (url: string) => collection)
+
+        const result = await entityManager.fetchCollection(DummyApiResource, null)
+
+        expect(result.totalItems).toEqual(1000)
+        expect(result.pagination.first).toEqual(1)
+        expect(result.pagination.last).toEqual(100)
+        expect(result.pagination.previous).toEqual(49)
+        expect(result.pagination.next).toEqual(51)
+    })
+})
+
+describe('saveResponseAsEntity', () => {
+    test('simple call', async () => {
+
+        const entity = new DummyApiModel({id: 1})
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiModel ? repo : null
+        })
+
+        // @ts-ignore
+        const response = {id: 1} as Response
+
+        entityManager.newInstance = vi.fn((model: typeof ApiResource, properties: object) => {
+            return entity
+        })
+
+        entityManager.saveInitialState = vi.fn((model: typeof ApiResource, entity: ApiResource) => null)
+
+        // @ts-ignore
+        repo.save = vi.fn((data: any) => null)
+
+        const result = await entityManager.saveResponseAsEntity(DummyApiModel, response)
+
+        expect(entityManager.getRepository).toHaveBeenCalledWith(DummyApiModel)
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiModel, {id: 1})
+        expect(entityManager.saveInitialState).toHaveBeenCalledWith(DummyApiModel, entity)
+        expect(repo.save).toHaveBeenCalledWith(entity)
+
+        expect(result).toEqual(entity)
+    })
+})
+
+
+describe('persist', () => {
+    test('new entity (POST)', async () => {
+        const entity = new DummyApiModel({id: 'tmp1', name: 'bob'})
+        entity.isNew = vi.fn(() => true)
+
+        // @ts-ignore
+        entity.$toJson = vi.fn(() => {
+            return {id: 'tmp1', name: 'bob'}
+        })
+
+        entityManager.cast = vi.fn((model: typeof ApiResource, entity: ApiResource): ApiResource => entity)
+
+        const response = { id: 1, name: 'bob' }
+        // @ts-ignore
+        apiRequestService.post = vi.fn((url, data) => response)
+
+        // @ts-ignore
+        entityManager.saveResponseAsEntity = vi.fn((model, response) => {
+            const newEntity = new DummyApiModel(response)
+            // @ts-ignore
+            newEntity.id = response.id
+            // @ts-ignore
+            newEntity.name = response.name
+
+            return newEntity
+        })
+
+        entityManager.removeTempAfterPersist = vi.fn()
+
+        const result = await entityManager.persist(DummyApiModel, entity)
+
+        // temp id should have been purged from the posted data
+        expect(apiRequestService.post).toHaveBeenCalledWith('api/dummyModel', {name: 'bob'})
+        expect(entityManager.saveResponseAsEntity).toHaveBeenCalledWith(DummyApiModel, response)
+        expect(entityManager.removeTempAfterPersist).toHaveBeenCalledWith(DummyApiModel, entity.id)
+
+        expect(result.id).toEqual(1)
+        expect(result.name).toEqual('bob')
+    })
+
+    test('existing entity (PUT)', async () => {
+        const props = {id: 1, name: 'bob'}
+        const entity = new DummyApiModel(props)
+        entity.id = 1
+        entity.isNew = vi.fn(() => false)
+
+        // @ts-ignore
+        entity.$toJson = vi.fn(() => props)
+
+        entityManager.cast = vi.fn((model: typeof ApiResource, entity: ApiResource): ApiResource => entity)
+
+        // @ts-ignore
+        apiRequestService.put = vi.fn((url, data) => props)
+
+        // @ts-ignore
+        entityManager.saveResponseAsEntity = vi.fn((model, response) => {
+            const newEntity = new DummyApiModel(response)
+            // @ts-ignore
+            newEntity.id = response.id
+            // @ts-ignore
+            newEntity.name = response.name
+
+            return newEntity
+        })
+
+        entityManager.removeTempAfterPersist = vi.fn()
+
+        const result = await entityManager.persist(DummyApiModel, entity)
+
+        expect(apiRequestService.put).toHaveBeenCalledWith('api/dummyModel/1', {id: 1, name: 'bob'})
+        expect(entityManager.saveResponseAsEntity).toHaveBeenCalledWith(DummyApiModel, props)
+        expect(entityManager.removeTempAfterPersist).toHaveBeenCalledTimes(0)
+
+        expect(result.id).toEqual(1)
+        expect(result.name).toEqual('bob')
+    })
+})
+
+
+describe('patch', () => {
+    test('simple call', async () => {
+        const props = {id: 1, name: 'bobby'}
+
+        // @ts-ignore
+        apiRequestService.put = vi.fn((url, data) => props)
+
+        // @ts-ignore
+        entityManager.saveResponseAsEntity = vi.fn((model, response) => {
+            const newEntity = new DummyApiModel(response)
+            // @ts-ignore
+            newEntity.id = response.id
+            // @ts-ignore
+            newEntity.name = response.name
+
+            return newEntity
+        })
+
+        const result = await entityManager.patch(DummyApiModel, 1, {name: 'bobby'})
+
+        expect(apiRequestService.put).toHaveBeenCalledWith('api/dummyModel/1', '{"name":"bobby"}')
+        expect(entityManager.saveResponseAsEntity).toHaveBeenCalledWith(DummyApiModel, {id: 1, name: 'bobby'})
+
+        expect(result.id).toEqual(1)
+        expect(result.name).toEqual('bobby')
+    })
+})
+
+
+describe('delete', () => {
+    test('delete non persisted entity', () => {
+        const entity = new DummyApiModel()
+        entity.isNew = vi.fn(() => true)
+        entity.id = 'tmp123'
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiModel ? repo : null
+        })
+
+        apiRequestService.delete = vi.fn()
+
+        // @ts-ignore
+        repo.destroy = vi.fn((id: number) => null)
+
+        entityManager.delete(DummyApiModel, entity)
+
+        expect(entityManager.getRepository).toHaveBeenCalledWith(DummyApiModel)
+        expect(apiRequestService.delete).toHaveBeenCalledTimes(0)
+        expect(repo.destroy).toHaveBeenCalledWith('tmp123')
+    })
+
+    test('delete persisted entity', async () => {
+        const entity = new DummyApiModel()
+        entity.isNew = vi.fn(() => false)
+        entity.id = 1
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiModel ? repo : null
+        })
+
+        // @ts-ignore
+        apiRequestService.delete = vi.fn((id: number) => null)
+
+        // @ts-ignore
+        repo.destroy = vi.fn((id: number) => null)
+
+        await entityManager.delete(DummyApiModel, entity)
+
+        expect(entityManager.getRepository).toHaveBeenCalledWith(DummyApiModel)
+        expect(apiRequestService.delete).toHaveBeenCalledWith('api/dummyModel/1')
+        expect(repo.destroy).toHaveBeenCalledWith(1)
+    })
+})
+
+describe('reset', () => {
+    test('simple call', () => {
+        const entity = new DummyApiModel()
+        entity.id = 1
+        entity.name = 'paul'
+
+        const initialEntity = new DummyApiModel()
+        initialEntity.id = 1
+        initialEntity.name = 'serges'
+
+        entityManager.getInitialStateOf = vi.fn((model: typeof ApiResource, id: string | number) => initialEntity)
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiModel ? repo : null
+        })
+
+        // @ts-ignore
+        repo.save = vi.fn((data: any) => null)
+
+        const result = entityManager.reset(DummyApiModel, entity)
+
+        expect(entityManager.getInitialStateOf).toHaveBeenCalledWith(DummyApiModel, 1)
+        expect(repo.save).toHaveBeenCalledWith(initialEntity)
+        expect(result).toEqual(initialEntity)
+    })
+
+    test('no initial state stored', () => {
+        const entity = new DummyApiModel()
+        entity.id = 1
+
+        entityManager.getInitialStateOf = vi.fn((model: typeof ApiResource, id: string | number) => null)
+
+        expect(() => entityManager.reset(DummyApiModel, entity)).toThrowError(
+            'no initial state recorded for this object - abort [dummyModel/1]'
+        )
+    })
+})
+
+describe('flush', () => {
+    test('simple call', () => {
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiModel ? repo : null
+        })
+
+        repo.flush = vi.fn()
+
+        entityManager.flush(DummyApiModel)
+
+        expect(repo.flush).toHaveBeenCalled()
+    })
+})
+
+describe('isNewEntity', () => {
+    test('with new entity', () => {
+        const entity = new DummyApiModel()
+        entity.isNew = vi.fn(() => true)
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiModel ? repo : null
+        })
+
+        // @ts-ignore
+        repo.find = vi.fn((id: number) => entity)
+
+        const result = entityManager.isNewEntity(DummyApiModel, 1)
+
+        expect(result).toBeTruthy()
+    })
+
+    test('with existing entity', () => {
+        const entity = new DummyApiModel()
+        entity.isNew = vi.fn(() => false)
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiModel ? repo : null
+        })
+
+        // @ts-ignore
+        repo.find = vi.fn((id: number) => entity)
+
+        const result = entityManager.isNewEntity(DummyApiModel, 1)
+
+        expect(result).toBeFalsy()
+    })
+
+    test('non-existing entity', () => {
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiModel ? repo : null
+        })
+
+        // @ts-ignore
+        repo.find = vi.fn((id: number) => null)
+
+        console.error = vi.fn()
+
+        const result = entityManager.isNewEntity(DummyApiModel, 1)
+
+        expect(result).toBeFalsy()
+
+        expect(console.error).toHaveBeenCalledWith('dummyModel/1 does not exist!')
+    })
+})
+
+describe('saveInitialState', () => {
+    test('simple call', () => {
+        // @ts-ignore
+        const entity = { id: 1, name: 'bob' } as DummyApiResource
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        // @ts-ignore
+        repo.save = vi.fn((record: Element) => null)
+
+        entityManager.saveInitialState(DummyApiResource, entity)
+
+        expect(repo.save).toHaveBeenCalledWith({ id: '_clone_1', name: 'bob' })
+        expect(entity.id).toEqual(1)
+    })
+})
+
+describe('getInitialStateOf', () => {
+    test('with initial state', () => {
+        // @ts-ignore
+        const entity = { id: 1, name: 'bob' } as DummyApiResource
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        // @ts-ignore
+        repo.find = vi.fn((id: number | string) => {
+            // @ts-ignore
+            return { id: 1, name: 'robert' } as DummyApiResource
+        })
+
+        const result = entityManager.getInitialStateOf(DummyApiResource, 1) as DummyApiResource
+
+        expect(repo.find).toHaveBeenCalledWith('_clone_1')
+        expect(result.id).toEqual(1)
+        expect(result.name).toEqual('robert')
+    })
+
+    test('without initial state', () => {
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        // @ts-ignore
+        repo.find = vi.fn((id: number | string) => null)
+
+        const result = entityManager.getInitialStateOf(DummyApiResource, 1) as DummyApiResource
+
+        expect(repo.find).toHaveBeenCalledWith('_clone_1')
+        expect(result).toEqual(null)
+    })
+})
+
+describe('removeTempAfterPersist', () => {
+    test('simple call', () => {
+        // @ts-ignore
+        const entity = new DummyApiResource()
+        entity.id = 'tmp123'
+        entity.isNew = vi.fn(() => true)
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        // @ts-ignore
+        repo.find = vi.fn((id: number | string) => entity)
+
+        // @ts-ignore
+        repo.destroy = vi.fn()
+
+        entityManager.removeTempAfterPersist(DummyApiResource, 'tmp123')
+
+        expect(repo.destroy).toHaveBeenCalledWith('tmp123')
+        expect(repo.destroy).toHaveBeenCalledWith('_clone_tmp123')
+    })
+
+    test('entity is not temporary', () => {
+        // @ts-ignore
+        const entity = new DummyApiResource()
+        entity.id = 1
+        entity.isNew = vi.fn(() => false)
+
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        // @ts-ignore
+        repo.find = vi.fn((id: number | string) => entity)
+
+        // @ts-ignore
+        repo.destroy = vi.fn()
+
+        expect(() => entityManager.removeTempAfterPersist(DummyApiResource, 'tmp123')).toThrowError(
+            'Error: Can not remove a non-temporary entity'
+        )
+
+        expect(repo.destroy).toHaveBeenCalledTimes(0)
+    })
+
+    test('entity does not exist', () => {
+        // @ts-ignore
+        const repo = vi.fn() as Repository<ApiResource>
+
+        // @ts-ignore
+        entityManager.getRepository = vi.fn((model: typeof ApiResource) => {
+            return model === DummyApiResource ? repo : null
+        })
+
+        // @ts-ignore
+        repo.find = vi.fn((id: number | string) => null)
+
+        // @ts-ignore
+        repo.destroy = vi.fn()
+
+        console.error = vi.fn()
+
+        entityManager.removeTempAfterPersist(DummyApiResource, 'tmp123')
+
+        expect(repo.destroy).toHaveBeenCalledTimes(0)
+        expect(console.error).toHaveBeenCalledWith('dummyResource/tmp123 does not exist!')
+    })
+})

+ 57 - 0
tests/units/services/data/enumManager.test.ts

@@ -0,0 +1,57 @@
+import { describe, test, it, expect } from 'vitest'
+import ApiRequestService from "~/services/data/apiRequestService";
+import EnumManager from "~/services/data/enumManager";
+import {VueI18n} from "vue-i18n";
+
+let apiRequestService: ApiRequestService
+let i18n: VueI18n
+let enumManager: EnumManager
+
+beforeEach(() => {
+    // @ts-ignore
+    apiRequestService = vi.fn() as ApiRequestService
+    // @ts-ignore
+    i18n = vi.fn() as VueI18n
+
+    enumManager = new EnumManager(apiRequestService, i18n)
+})
+
+describe('fetch', () => {
+    test('simple call', async () => {
+
+        const hydraData = {
+            "@context": "/api/contexts/Enum",
+            "@id": "/api/enum/contact_point_type",
+            "@type": "Enum",
+            "name": "contact_point_type",
+            "items": {
+                "PRINCIPAL": "PRINCIPAL",
+                "BILL": "BILL",
+                "OTHER": "OTHER",
+                "CONTACT": "CONTACT"
+            }
+        }
+
+        const translationOf = {
+            "PRINCIPAL": "Principal",
+            "BILL": "Facture",
+            "OTHER": "Autre",
+            "CONTACT": "Contact"
+        }
+
+        // @ts-ignore
+        apiRequestService.get = vi.fn((url: string) => hydraData)
+        // @ts-ignore
+        i18n.t = vi.fn((key: string) => translationOf[key])
+
+        const result = await enumManager.fetch('contact_point_type')
+
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/enum/contact_point_type')
+        expect(result).toEqual([
+            { "label": "Principal", "value": "PRINCIPAL" },
+            { "label": "Facture", "value": "BILL" },
+            { "label": "Autre", "value": "OTHER" },
+            { "label": "Contact", "value": "CONTACT" },
+        ])
+    })
+})

+ 110 - 0
tests/units/services/data/imageManager.test.ts

@@ -0,0 +1,110 @@
+import { describe, test, it, expect } from 'vitest'
+import ApiRequestService from "~/services/data/apiRequestService";
+import ImageManager from "~/services/data/imageManager";
+import 'blob-polyfill';
+
+let apiRequestService: ApiRequestService
+let imageManager: TestableImageManager
+
+class TestableImageManager extends ImageManager {
+    public async toBase64(data: string) { return super.toBase64(data) }
+    public getCacheKey() { return super.getCacheKey() }
+}
+
+let init_console_error: any
+
+beforeEach(() => {
+    // @ts-ignore
+    apiRequestService = vi.fn() as ApiRequestService
+
+    imageManager = new TestableImageManager(apiRequestService)
+
+    // Important : Restore console error after mocking
+    init_console_error = console.error
+})
+
+afterEach(() => {
+    vi.restoreAllMocks()
+    console.error = init_console_error
+})
+
+describe('get', () => {
+    test('simple call', async () => {
+        const data = "azerty"
+
+        // @ts-ignore
+        apiRequestService.get = vi.fn((url: string) => data)
+
+        imageManager.getCacheKey = vi.fn(() => '123456')
+
+        // @ts-ignore
+        imageManager.toBase64 = vi.fn((data: string) => 'base64:' + data)
+
+        const result = await imageManager.get(1)
+
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/download/1', ['123456'])
+
+        expect(result).toEqual('base64:azerty')
+    })
+
+    test('no id, no default image provided', async () => {
+        expect(await imageManager.get(null)).toEqual(ImageManager.defaultImage)
+    })
+
+    test('no id, default image provided', async () => {
+        const defaultImage = 'a_picture.jpg'
+
+        expect(await imageManager.get(null, defaultImage)).toEqual(defaultImage)
+    })
+
+    test('no response, no default image', async () => {
+        // @ts-ignore
+        apiRequestService.get = vi.fn((url: string) => '')
+
+        imageManager.getCacheKey = vi.fn(() => '123456')
+        // @ts-ignore
+        imageManager.toBase64 = vi.fn()
+
+        console.error = vi.fn()
+
+        const result = await imageManager.get(1)
+
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/download/1', ['123456'])
+        expect(console.error).toHaveBeenCalledWith('Error: image 1 not found or invalid')
+        expect(imageManager.toBase64).toHaveBeenCalledTimes(0)
+
+        expect(result).toEqual(ImageManager.defaultImage)
+    })
+
+    test('no response, default image', async () => {
+        // @ts-ignore
+        apiRequestService.get = vi.fn((url: string) => '')
+
+        imageManager.getCacheKey = vi.fn(() => '123456')
+
+        // @ts-ignore
+        imageManager.toBase64 = vi.fn()
+
+        console.error = vi.fn()
+
+        const result = await imageManager.get(1, 'some_default.jpg')
+
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/download/1', ['123456'])
+        expect(console.error).toHaveBeenCalledWith('Error: image 1 not found or invalid')
+        expect(imageManager.toBase64).toHaveBeenCalledTimes(0)
+
+        expect(result).toEqual('some_default.jpg')
+    })
+})
+
+describe('toBase64', () => {
+    test('simple call', async () => {
+        expect(await imageManager.toBase64('some_data')).toEqual('data:image/jpeg;base64,c29tZV9kYXRh')
+    })
+})
+
+describe('getCacheKey', () => {
+    test('simple call', () => {
+        expect(imageManager.getCacheKey()).toMatch(/\d{10,}/)
+    })
+})

+ 216 - 0
tests/units/services/data/normalizer/hydraDenormalizer.test.ts

@@ -0,0 +1,216 @@
+import { describe, test, it, expect } from 'vitest'
+import {AnyJson, ApiResponse, HydraMetadata} from "~/types/data";
+import HydraDenormalizer from "~/services/data/normalizer/hydraDenormalizer";
+import {METADATA_TYPE} from "~/types/enum/data";
+
+class TestableHydraDenormalizer extends HydraDenormalizer {
+    public static denormalize(hydraData: AnyJson): ApiResponse { return super.denormalize(hydraData) }
+    public static getData(hydraData: AnyJson): AnyJson { return super.getData(hydraData) }
+    public static getMetadata(data: AnyJson): HydraMetadata { return super.getMetadata(data) }
+}
+
+describe('denormalize', () => {
+
+    test('should parse a API Item response and return a JSON Object', () => {
+        const data: AnyJson = {
+            '@context': '/api/contexts/Access',
+            '@id': '/api/accesses/7351',
+            '@type': 'Access',
+            organization: '/api/organizations/37306',
+            id: 7351,
+            person: {
+                '@type': 'Person',
+                id: 11344,
+                name: 'BRUEL',
+                givenName: 'Patrick'
+            }
+        }
+
+        const result = HydraDenormalizer.denormalize(data)
+
+        expect(result.data).toEqual(TestableHydraDenormalizer.getData(data))
+        expect(result.metadata).toEqual(TestableHydraDenormalizer.getMetadata(data))
+
+        const expected = {
+            "data": {
+                "@context": "/api/contexts/Access",
+                "@id": "/api/accesses/7351",
+                "@type": "Access",
+                "id": 7351,
+                "organization": "/api/organizations/37306",
+                "person": {
+                    "@type": "Person",
+                    "givenName": "Patrick",
+                    "id": 11344,
+                    "name": "BRUEL"
+                }
+            },
+            "metadata": {
+                "type": METADATA_TYPE.ITEM
+            }
+        }
+
+        expect(result).toStrictEqual<AnyJson>(expected)
+    })
+
+    it('should parse a API Collection response and return a JSON Object', () => {
+        const data: AnyJson = {
+            '@context': '/api/contexts/Access',
+            '@id': '/api/accesses',
+            '@type': 'hydra:Collection',
+            'hydra:member': [{
+                '@id': '/api/accesses/7351',
+                organization: '/api/organizations/37306',
+                id: 7351,
+                person: {
+                    '@type': 'Person',
+                    id: 11344,
+                    name: 'BRUEL',
+                    givenName: 'Patrick'
+                }
+            }, {
+                '@id': '/api/accesses/7352',
+                organization: '/api/organizations/37306',
+                id: 7352,
+                person: {
+                    '@type': 'Person',
+                    id: 11345,
+                    name: 'BRASSENS',
+                    givenName: 'George'
+                }
+            }
+            ],
+            "hydra:view": {
+                "@id": "/api/accesses?page=3",
+                "@type": "hydra:PartialCollectionView",
+                "hydra:first": "/api/accesses?page=1",
+                "hydra:last": "/api/accesses?page=5",
+                "hydra:next": "/api/accesses?page=4",
+                "hydra:previous": "/api/accesses?page=2"
+            }
+        }
+
+        const result = HydraDenormalizer.denormalize(data)
+
+        expect(result.data).toEqual(TestableHydraDenormalizer.getData(data))
+        expect(result.metadata).toEqual(TestableHydraDenormalizer.getMetadata(data))
+
+        const expected = JSON.stringify(
+            {"data":[
+                {
+                    '@id': '/api/accesses/7351',
+                    organization: '/api/organizations/37306',
+                    id: 7351,
+                    person:
+                        {
+                            '@type': 'Person',
+                            id: 11344,
+                            name: 'BRUEL',
+                            givenName: 'Patrick'
+                        }
+                },
+                {
+                    '@id': '/api/accesses/7352',
+                    organization: '/api/organizations/37306',
+                    id: 7352,
+                    person:
+                        {
+                            '@type': 'Person',
+                            id: 11345,
+                            name: 'BRASSENS',
+                            givenName: 'George'
+                        }
+                }
+            ],
+            'metadata': {
+                'firstPage': 1,
+                'lastPage': 5,
+                'nextPage': 4,
+                'previousPage': 2,
+                'type': 1
+            }
+        })
+
+        expect(JSON.stringify(result)).toEqual(expected)
+    })
+})
+
+describe('getData', () => {
+    test('With collection', () => {
+        const data = {
+            "@context": "/api/contexts/Foo",
+            "@id": "/api/foo",
+            "@type": "hydra:Collection",
+            "hydra:member": [ 'foo' ],
+        }
+
+        expect(TestableHydraDenormalizer.getData(data)).toEqual([ 'foo' ])
+    })
+
+    test('With item', () => {
+        const data = {
+            "@context": "/api/contexts/Foo",
+            "@id": "/api/foo",
+            "@type": "Foo",
+            "param1": 'a',
+        }
+
+        expect(TestableHydraDenormalizer.getData(data)).toEqual(data)
+    })
+})
+
+describe('getMetadata', () => {
+    test('With valid collection metadata', () => {
+        const data = {
+            "@context": "/api/contexts/Foo",
+            "@id": "/api/foo",
+            "@type": "hydra:Collection",
+            "hydra:member": [ 'foo' ],
+            "hydra:totalItems": 10,
+            "hydra:view": {
+                "@id": "/api/foo?page=3",
+                "@type": "hydra:PartialCollectionView",
+                "hydra:first": "/api/foo?page=1",
+                "hydra:last": "/api/foo?page=5",
+                "hydra:next": "/api/foo?page=4",
+                "hydra:previous": "/api/foo?page=2"
+            }
+        }
+
+        const metadata = TestableHydraDenormalizer.getMetadata(data)
+
+        expect(metadata.totalItems).toEqual(10)
+        expect(metadata.firstPage).toEqual(1)
+        expect(metadata.lastPage).toEqual(5)
+        expect(metadata.nextPage).toEqual(4)
+        expect(metadata.previousPage).toEqual(2)
+        expect(metadata.type).toEqual(METADATA_TYPE.COLLECTION)
+    })
+    test('With partial collection metadata', () => {
+        const data = {
+            "@context": "/api/contexts/Foo",
+            "@id": "/api/foo",
+            "@type": "hydra:Collection",
+            "hydra:member": [ 'foo' ],
+            "hydra:totalItems": 10,
+            "hydra:view": {
+                "@id": "/api/foo?page=3",
+                "@type": "hydra:PartialCollectionView",
+                "hydra:first": "/api/foo?page=1",
+            }
+        }
+
+        const metadata = TestableHydraDenormalizer.getMetadata(data)
+
+        expect(metadata.totalItems).toEqual(10)
+        expect(metadata.firstPage).toEqual(1)
+        expect(metadata.lastPage).toEqual(1)
+        expect(metadata.nextPage).toEqual(undefined)
+        expect(metadata.previousPage).toEqual(undefined)
+    })
+
+    test('With item metadata', () => {
+        const metadata = TestableHydraDenormalizer.getMetadata({})
+        expect(metadata.type).toEqual(METADATA_TYPE.ITEM)
+    })
+})

+ 23 - 0
tests/units/services/encoder/yamlDenormalizer.test.ts

@@ -0,0 +1,23 @@
+import {describe, test, expect} from "vitest";
+import YamlEncoder from "~/services/encoder/yamlEncoder";
+import {load} from "js-yaml";
+
+
+describe('encode', () => {
+    test('simple conversion', () => {
+        const result = YamlEncoder.encode({title: {a: 1, b: 2, c: ['foo', 'bar']}})
+        expect(result).toEqual(`title:\n  a: 1\n  b: 2\n  c:\n    - foo\n    - bar\n`)
+    })
+})
+
+describe('decode', () => {
+    test('with empty data', () => {
+        const result = YamlEncoder.decode("")
+
+        expect(result).toBeNull()
+    })
+    test('with data', () => {
+        const result = YamlEncoder.decode(`title:\n a: 1\n b: 2\n c: [foo, bar]`)
+        expect(result).toEqual({title: {a: 1, b: 2, c: ['foo', 'bar']}})
+    })
+})

+ 10 - 0
tests/units/services/error/unauthorizedError.test.ts

@@ -0,0 +1,10 @@
+import { describe, test, it, expect } from 'vitest'
+import UnauthorizedError from "~/services/error/UnauthorizedError";
+
+describe('UnauthorizedError', () => {
+    test('throw', () => {
+        expect(() => {
+            throw new UnauthorizedError
+        }).toThrowError('Unauthorized')
+    })
+});

+ 133 - 0
tests/units/services/layout/menuBuilder/abstractMenuBuilder.test.ts

@@ -0,0 +1,133 @@
+import { describe, test, it, expect } from 'vitest'
+import AbstractMenuBuilder from "~/services/layout/menuBuilder/abstractMenuBuilder";
+import {IconItem, MenuGroup, MenuItem, MenuItems} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+
+class TestableAbstractMenuBuilder extends AbstractMenuBuilder {
+    static readonly menuName = 'TestableMenu'
+
+    public build(): MenuItem | MenuGroup | null {
+        return { label: 'my_menu' };
+    }
+
+    public createGroup(
+        label: string,
+        icon?: IconItem,
+        children: MenuItems = [],
+        actions: Array<MenuItem> = []
+    ): MenuGroup {
+        return super.createGroup(label, icon, children, actions)
+    }
+
+    public createItem (
+        label: string,
+        icon?: IconItem,
+        to: string = '',
+        type: MENU_LINK_TYPE = MENU_LINK_TYPE.INTERNAL,
+    ): MenuItem {
+        return super.createItem(label, icon, to, type)
+    }
+
+    public buildSubmenu(menuBuilder: typeof AbstractMenuBuilder) {
+        return super.buildSubmenu(menuBuilder)
+    }
+}
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: TestableAbstractMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    menuBuilder = new TestableAbstractMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+
+describe('getMenuName', () => {
+    test('get name', () => {
+        expect(menuBuilder.getMenuName()).toEqual('TestableMenu')
+    })
+})
+
+describe('createGroup', () => {
+    test('simple group', () => {
+        const label = 'my_menu'
+        const icon = {name: 'my_icon'}
+        const children = [{label: 'submenu', type: MENU_LINK_TYPE.INTERNAL, active: true}]
+        const actions = [{label: 'action', type: MENU_LINK_TYPE.INTERNAL, active: true}]
+
+        const result = menuBuilder.createGroup(label, icon, children, actions)
+
+        expect(result).toEqual({label, icon, children, actions})
+    })
+
+    test('default values', () => {
+        const result = menuBuilder.createGroup('my_menu')
+
+        expect(result).toEqual(
+            {label: 'my_menu', icon: undefined, children: [], actions: []}
+        )
+    })
+})
+
+describe('createItem', () => {
+    test('simple item', () => {
+        const label = 'my_menu'
+        const icon = {name: 'my_icon'}
+        const to = 'https://domain.com/foo/bar'
+        const type = MENU_LINK_TYPE.EXTERNAL
+
+        const result = menuBuilder.createItem(label, icon, to, type)
+
+        expect(result).toEqual({ icon, label, to, type, active: false })
+    })
+
+    test('default values', () => {
+        const result = menuBuilder.createItem('my_menu')
+
+        expect(result).toEqual(
+            {label: 'my_menu', icon: undefined, to: '', type: MENU_LINK_TYPE.INTERNAL, active: false}
+        )
+    })
+
+    test('prepend https on external url', () => {
+        const item = menuBuilder.createItem('my_menu', undefined, 'domain.com', MENU_LINK_TYPE.EXTERNAL)
+        expect(item.to).toEqual('https://domain.com')
+    })
+
+    test('complete V1 links (server side)', () => {
+
+        runtimeConfig.baseUrlAdminLegacy = 'https://admin.opentalent.fr'
+
+        const item = menuBuilder.createItem('my_menu', undefined, '/my_page', MENU_LINK_TYPE.V1)
+
+        expect(item.to).toEqual('https://admin.opentalent.fr/my_page')
+    })
+
+    test('complete V1 links (client side)', () => {
+
+        runtimeConfig.baseUrlAdminLegacy = ''
+        // @ts-ignore
+        runtimeConfig.public = {baseUrlAdminLegacy: 'https://admin.opentalent.fr'}
+
+        const item = menuBuilder.createItem('my_menu', undefined, '/my_page', MENU_LINK_TYPE.V1)
+
+        expect(item.to).toEqual('https://admin.opentalent.fr/my_page')
+    })
+})
+
+describe('buildSubmenu', () => {
+    test('should call given menu build method', () => {
+        expect(menuBuilder.buildSubmenu(TestableAbstractMenuBuilder)).toEqual({ label: 'my_menu' })
+    })
+})

+ 108 - 0
tests/units/services/layout/menuBuilder/accessMenuBuilder.test.ts

@@ -0,0 +1,108 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import AccessMenuBuilder from "~/services/layout/menuBuilder/accessMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: AccessMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new AccessMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Access")
+    })
+})
+
+describe('build', () => {
+    test('has all items', () => {
+        // @ts-ignore
+        organizationProfile.isSchool = true
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('address_book')
+        expect(result.icon).toEqual({name: 'fas fa-address-book'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(6)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu person', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'accesses_page')
+        // @ts-ignore
+        organizationProfile.isSchool = true
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'person', icon: {name: 'fas fa-user'}, to: 'https://mydomain.com/students/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+
+        // @ts-ignore
+        organizationProfile.isSchool = false
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'person', icon: {name: 'fas fa-user'}, to: 'https://mydomain.com/adherent/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu family_view', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'student_registration_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'family_view', icon: {name: 'fas fa-users'}, to: 'https://mydomain.com/student_registration/new', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu education_student_next_year', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'education_student_next_year_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'education_student_next_year', icon: {name: 'fas fa-list-alt'}, to: 'https://mydomain.com/education_student_next_year/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu commissions_page', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'commissions_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'commissions', icon: {name: 'fas fa-street-view'}, to: 'https://mydomain.com/commissions/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu network_children_page', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'network_children_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'network', icon: {name: 'fas fa-sitemap'}, to: 'https://mydomain.com/networks/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu network_parents_page', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'network_parents_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'my_network', icon: {name: 'fas fa-sitemap'}, to: 'https://mydomain.com/network_artist_schools/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 208 - 0
tests/units/services/layout/menuBuilder/accountMenuBuilder.test.ts

@@ -0,0 +1,208 @@
+import {describe, expect, test} from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import AccountMenuBuilder from "~/services/layout/menuBuilder/accountMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import {GENDER} from "~/types/enum/enums";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: AccountMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+    accessProfile.id = 123
+
+    menuBuilder = new AccountMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Account")
+    })
+})
+
+
+describe('build', () => {
+    test('has all items (mister)', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('my_account')
+
+        // @ts-ignore
+        expect(result.children.length).toEqual(15)
+        // @ts-ignore
+        expect(result.actions.length).toEqual(1)
+
+
+        // Has the logout action
+        // @ts-ignore
+        expect(result.actions).toEqual([
+            {label: 'logout', icon: undefined, to: 'https://mydomain.com/logout', type: MENU_LINK_TYPE.V1, active: false}
+        ])
+    })
+
+    test('has profile icon : mister)', () => {
+        ability.can = vi.fn(() => false)
+        accessProfile.avatarId = 100
+        accessProfile.gender = GENDER.MISTER
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.icon).toEqual({avatarId: 100, avatarByDefault: '/images/default/men-1.png'})
+    })
+
+    test('has profile icon : miss)', () => {
+        ability.can = vi.fn(() => false)
+        accessProfile.avatarId = 100
+        accessProfile.gender = GENDER.MISS
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.icon).toEqual({avatarId: 100, avatarByDefault: '/images/default/women-1.png'})
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        const group = menuBuilder.build()
+        // AccountMenuBuilder retourne toujours un groupe
+        // @ts-ignore
+        expect(group.children).toEqual([])
+
+        // Still has the logout action
+        // @ts-ignore
+        expect(group.actions).toEqual([
+            {label: 'logout', icon: undefined, to: 'https://mydomain.com/logout', type: MENU_LINK_TYPE.V1, active: false}
+        ])
+    })
+
+    test('has only rights for menu my_schedule_page', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_schedule_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_schedule_page', icon: undefined, to: 'https://mydomain.com/my_calendar', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu attendance_bookings_menu', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'attendance_bookings_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'attendance_bookings_menu', icon: undefined, to: 'https://mydomain.com/own_attendance', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu my_attendance', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_attendance_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_attendance', icon: undefined, to: 'https://mydomain.com/my_attendances/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu my_invitation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_invitation_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_invitation', icon: undefined, to: 'https://mydomain.com/my_invitations/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu my_students', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_students_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_students', icon: undefined, to: 'https://mydomain.com/my_students/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu my_students_education_students', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_students_education_students_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_students_education_students', icon: undefined, to: 'https://mydomain.com/my_students_education_students/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu my_education_students', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_education_students_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_education_students', icon: undefined, to: 'https://mydomain.com/main/my_profile/123/dashboard/my_education_students/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu send_an_email', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'send_an_email_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'send_an_email', icon: undefined, to: 'https://mydomain.com/list/create/emails', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu my_documents', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_documents_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_documents', icon: undefined, to: 'https://mydomain.com/main/my_profile/123/dashboard/show/my_access_file', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu my_profile', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_profile_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_profile', icon: undefined, to: 'https://mydomain.com/main/my_profile/123/dashboard', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu adherent_list', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'adherent_list_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'adherent_list', icon: undefined, to: 'https://mydomain.com/adherent_contacts/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu my_bills', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'my_bills_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'my_bills', icon: undefined, to: 'https://mydomain.com/main/my_profile/123/dashboard/show/my_bills', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu print_my_licence', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'cmf_licence_person_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'print_my_licence', icon: undefined, to: 'https://mydomain.com/licence_cmf/user', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 106 - 0
tests/units/services/layout/menuBuilder/admin2iosMenuBuilder.test.ts

@@ -0,0 +1,106 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import Admin2iosMenuBuilder from "~/services/layout/menuBuilder/admin2iosMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: Admin2iosMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new Admin2iosMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Admin2ios")
+    })
+})
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('admin2ios')
+        expect(result.icon).toEqual({name: 'fas fa-sitemap'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(7)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu all_accesses', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'all_accesses_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'all_accesses', icon: {name: 'fas fa-users'}, to: 'https://mydomain.com/all_accesses/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu all_organizations', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'all_organizations_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'all_organizations', icon: {name: 'fas fa-building'}, to: 'https://mydomain.com/organization_params/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu tips', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'tips_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'tips', icon: {name: 'fas fa-info-circle'}, to: 'https://mydomain.com/tips/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu dgv', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'dgv_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'dgv', icon: {name: 'fas fa-house-damage'}, to: 'https://mydomain.com/admin2ios/dgv', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu cmf_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'cmf_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'cmf_cotisation', icon: {name: 'fas fa-info-circle'}, to: 'https://mydomain.com/admin2ios/cotisationcmf', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu right_menu', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'right_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'right_menu', icon: {name: 'fas fa-balance-scale-right'}, to: 'https://mydomain.com/admin2ios/right', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu tree_menu', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'tree_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'tree_menu', icon: {name: 'fas fa-sitemap'}, to: 'https://mydomain.com/admin2ios/tree', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 67 - 0
tests/units/services/layout/menuBuilder/agendaMenuBuilder.test.ts

@@ -0,0 +1,67 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import AgendaMenuBuilder from "~/services/layout/menuBuilder/agendaMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: AgendaMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new AgendaMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Agenda")
+    })
+})
+
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('schedule')
+        expect(result.icon).toEqual({name: 'fas fa-calendar-alt'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(2)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu schedule', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'agenda_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'schedule', icon: {name: 'fas fa-calendar-alt'}, to: 'https://mydomain.com/calendar', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu attendances', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'attendance_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'attendances', icon: {name: 'fas fa-calendar-check'}, to: 'https://mydomain.com/attendances/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 115 - 0
tests/units/services/layout/menuBuilder/billingMenuBuilder.test.ts

@@ -0,0 +1,115 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import BillingMenuBuilder from "~/services/layout/menuBuilder/billingMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: BillingMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new BillingMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Billing")
+    })
+})
+
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('billing')
+        expect(result.icon).toEqual({name: 'fas fa-euro-sign'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(8)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu billing_product', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'billing_product_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'billing_product', icon: {name: 'fas fa-cube'}, to: 'https://mydomain.com/intangibles/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu billing_products_by_student', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'billing_products_by_student_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'billing_products_by_student', icon: {name: 'fas fa-cubes'}, to: 'https://mydomain.com/access_intangibles/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu billing_edition', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'billing_edition_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'billing_edition', icon: {name: 'fas fa-copy'}, to: 'https://mydomain.com/billing_edition', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu billing_accounting', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'billing_accounting_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'billing_accounting', icon: {name: 'fas fa-file-alt'}, to: 'https://mydomain.com/bill_accountings/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu billing_payment_list', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'billing_payment_list_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'billing_payment_list', icon: {name: 'fas fa-credit-card'}, to: 'https://mydomain.com/bill_payments_list/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu pes_export', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'pes_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'pes_export', icon: {name: 'fas fa-align-justify'}, to: 'https://mydomain.com/pes/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu berger_levrault_export', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'berger_levrault_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'berger_levrault_export', icon: {name: 'fas fa-align-justify'}, to: 'https://mydomain.com/berger_levraults/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu jvs_export', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'jvs_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'jvs_export', icon: {name: 'fas fa-align-justify'}, to: 'https://mydomain.com/jvs/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 76 - 0
tests/units/services/layout/menuBuilder/communicationMenuBuilder.test.ts

@@ -0,0 +1,76 @@
+
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import CommunicationMenuBuilder from "~/services/layout/menuBuilder/communicationMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: CommunicationMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new CommunicationMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Communication")
+    })
+})
+
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('communication')
+        expect(result.icon).toEqual({name: 'fas fa-comments'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(3)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu inbox', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'inbox_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'inbox', icon: {name: 'fas fa-inbox'}, to: 'https://mydomain.com/messages/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu message_send', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'message_send_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'message_send', icon: {name: 'fas fa-paper-plane'}, to: 'https://mydomain.com/messagessends/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu message_templates', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'message_templates_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'message_templates', icon: {name: 'fas fa-edit'}, to: 'https://mydomain.com/templates/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 124 - 0
tests/units/services/layout/menuBuilder/configurationMenuBuilder.test.ts

@@ -0,0 +1,124 @@
+
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import ConfigurationMenuBuilder from "~/services/layout/menuBuilder/configurationMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: ConfigurationMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new ConfigurationMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Configuration")
+    })
+})
+
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('configuration')
+        expect(result.icon).toEqual({name: 'fas fa-cogs'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(13)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu organization_page', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'organization_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'organization_page', icon: undefined, to: 'https://mydomain.com/organization', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu cmf_licence_generate', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'cmf_licence_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'cmf_licence_generate', icon: undefined, to: 'https://mydomain.com/cmf_licence/organization', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu place', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'place_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'place', icon: undefined, to: 'https://mydomain.com/places/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu education', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'education_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'education', icon: undefined, to: 'https://mydomain.com/educations/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu tag', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'tag_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'tag', icon: undefined, to: 'https://mydomain.com/taggs/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu activities', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'activities_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'activities', icon: undefined, to: 'https://mydomain.com/activities/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu transition_next_year', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'transition_next_year_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'transition_next_year', icon: undefined, to: 'https://mydomain.com/transition_next_year', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu course_duplication', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'course_duplication_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'course_duplication', icon: undefined, to: 'https://mydomain.com/duplicate_courses', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu import', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'import_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'import', icon: undefined, to: 'https://mydomain.com/import/all', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 188 - 0
tests/units/services/layout/menuBuilder/cotisationsMenuBuilder.test.ts

@@ -0,0 +1,188 @@
+
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import CotisationsMenuBuilder from "~/services/layout/menuBuilder/cotisationsMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: CotisationsMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new CotisationsMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Cotisation")
+    })
+})
+
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('cotisations')
+        expect(result.icon).toEqual({name: 'fas fa-money-bill'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(17)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu rate_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'rate_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'rate_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/rate', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu parameters_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'parameters_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'parameters_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/parameter', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu send_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'send_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'send_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/send', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu state_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'state_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'state_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/state', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu pay_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'pay_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'pay_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/pay', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu check_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'check_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'check_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/check', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu ledger_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'ledger_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'ledger_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/ledger', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu magazine_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'magazine_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'magazine_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/magazine', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu ventilated_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'ventilated_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'ventilated_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/ventilated', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu pay_erase_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'pay_erase_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'pay_erase_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/payerase', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu resume_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'resume_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'resume_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/resume', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu history_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'history_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'history_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/history', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu call_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'call_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'call_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/call', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu history_structure_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'history_structure_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'history_structure_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/historystructure', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu insurance_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'insurance_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'insurance_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/insurance', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu resume_all_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'resume_all_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'resume_all_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/resumeall', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu resume_pay_cotisation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'resume_pay_cotisation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'resume_pay_cotisation', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/cotisation/resumepay', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 48 - 0
tests/units/services/layout/menuBuilder/donorsMenuBuilder.test.ts

@@ -0,0 +1,48 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import DonorsMenuBuilder from "~/services/layout/menuBuilder/donorsMenuBuilder";
+import {MenuGroup, MenuItem} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: DonorsMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new DonorsMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Donors")
+    })
+})
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuItem
+        const result = menuBuilder.build() as MenuItem
+
+        expect(result).toEqual({
+            label: 'donors', icon: {name: 'fas fa-handshake'}, to: 'https://mydomain.com/donors/list/', type: MENU_LINK_TYPE.V1, active: false
+        })
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+})

+ 99 - 0
tests/units/services/layout/menuBuilder/educationalMenuBuilder.test.ts

@@ -0,0 +1,99 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import EducationalMenuBuilder from "~/services/layout/menuBuilder/educationalMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: EducationalMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new EducationalMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Educational")
+    })
+})
+
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('education_state')
+        expect(result.icon).toEqual({name: 'fas fa-graduation-cap'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(7)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu criteria_notations', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'criteria_notations_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'criteria_notations', icon: {name: 'fas fa-bars'}, to: 'https://mydomain.com/criteria_notations/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu seizure_period', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'seizure_period_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'seizure_period', icon: {name: 'fas fa-calendar-alt'}, to: 'https://mydomain.com/education_teachers/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu test_seizure', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'test_seizure_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'test_seizure', icon: {name: 'fas fa-pencil-alt'}, to: 'https://mydomain.com/education_input/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu test_validation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'test_validation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'test_validation', icon: {name: 'fas fa-check'}, to: 'https://mydomain.com/education_notations/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu examen_results', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'examen_results_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'examen_results', icon: {name: 'fas fa-graduation-cap'}, to: 'https://mydomain.com/examen_convocations/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu education_by_student_validation', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'education_by_student_validation_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'education_by_student_validation', icon: {name: 'fas fa-check-square'}, to: 'https://mydomain.com/education_by_student/list/', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 50 - 0
tests/units/services/layout/menuBuilder/equipmentMenuBuilder.test.ts

@@ -0,0 +1,50 @@
+
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import EquipmentMenuBuilder from "~/services/layout/menuBuilder/equipmentMenuBuilder";
+import {MenuGroup, MenuItem} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: EquipmentMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new EquipmentMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Equipment")
+    })
+})
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuItem
+        const result = menuBuilder.build() as MenuItem
+
+        expect(result).toEqual({
+            label: 'equipment', icon: {name: 'fas fa-cube'}, to: 'https://mydomain.com/equipment/list/', type: MENU_LINK_TYPE.V1, active: false
+        })
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+})

+ 105 - 0
tests/units/services/layout/menuBuilder/mainMenuBuilder.test.ts

@@ -0,0 +1,105 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import MainMenuBuilder from "~/services/layout/menuBuilder/mainMenuBuilder";
+import {MenuGroup, MenuItem} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import AbstractMenuBuilder from "~/services/layout/menuBuilder/abstractMenuBuilder";
+import AccessMenuBuilder from "~/services/layout/menuBuilder/accessMenuBuilder";
+import AgendaMenuBuilder from "~/services/layout/menuBuilder/agendaMenuBuilder";
+import EquipmentMenuBuilder from "~/services/layout/menuBuilder/equipmentMenuBuilder";
+import EducationalMenuBuilder from "~/services/layout/menuBuilder/educationalMenuBuilder";
+import BillingMenuBuilder from "~/services/layout/menuBuilder/billingMenuBuilder";
+import CommunicationMenuBuilder from "~/services/layout/menuBuilder/communicationMenuBuilder";
+import DonorsMenuBuilder from "~/services/layout/menuBuilder/donorsMenuBuilder";
+import MedalsMenuBuilder from "~/services/layout/menuBuilder/medalsMenuBuilder";
+import WebsiteAdminMenuBuilder from "~/services/layout/menuBuilder/websiteAdminMenuBuilder";
+import CotisationsMenuBuilder from "~/services/layout/menuBuilder/cotisationsMenuBuilder";
+import StatsMenuBuilder from "~/services/layout/menuBuilder/statsMenuBuilder";
+import Admin2iosMenuBuilder from "~/services/layout/menuBuilder/admin2iosMenuBuilder";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: TestableMainMenuBuilder
+
+class TestableMainMenuBuilder extends MainMenuBuilder {
+    public buildSubmenu(menuBuilder: typeof AbstractMenuBuilder) {
+        return super.buildSubmenu(menuBuilder)
+    }
+}
+
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new TestableMainMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Main")
+    })
+})
+
+describe('build', () => {
+    test('return all menus', () => {
+        menuBuilder.buildSubmenu = vi.fn((menuBuilder: typeof AbstractMenuBuilder): MenuGroup => {
+            return { label: 'foo' }
+        })
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(AccessMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(AgendaMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(EquipmentMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(EducationalMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(BillingMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(CommunicationMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(DonorsMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(MedalsMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(WebsiteAdminMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(CotisationsMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(StatsMenuBuilder)
+        expect(menuBuilder.buildSubmenu).toBeCalledWith(Admin2iosMenuBuilder)
+
+        expect(result.label).toEqual('main')
+        expect(result.icon).toEqual(undefined)
+        expect((result.children ?? []).length).toEqual(12)
+    })
+
+    test('return a single group', () => {
+        menuBuilder.buildSubmenu = vi.fn((menuBuilder: typeof AbstractMenuBuilder): MenuGroup | null => {
+            if (menuBuilder.menuName === 'Access') {
+                return { label: 'submenu', children: [
+                        {label: 'item1', type: MENU_LINK_TYPE.INTERNAL, active: true},
+                        {label: 'item2', type: MENU_LINK_TYPE.INTERNAL, active: true},
+                        {label: 'item3', type: MENU_LINK_TYPE.INTERNAL, active: true}
+                    ] }
+            }
+            return  null
+        })
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('submenu')
+        expect((result.children ?? []).length).toEqual(3)
+    })
+
+    test('return null', () => {
+        menuBuilder.buildSubmenu = vi.fn((menuBuilder: typeof AbstractMenuBuilder): MenuGroup | null => {
+            return  null
+        })
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result).toEqual(null)
+    })
+})

+ 50 - 0
tests/units/services/layout/menuBuilder/medalsMenuBuilder.test.ts

@@ -0,0 +1,50 @@
+
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import MedalsMenuBuilder from "~/services/layout/menuBuilder/medalsMenuBuilder";
+import {MenuGroup, MenuItem} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: MedalsMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new MedalsMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Medals")
+    })
+})
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuItem
+        const result = menuBuilder.build() as MenuItem
+
+        expect(result).toEqual({
+            label: 'medals', icon: {name: 'fas fa-trophy'}, to: 'https://mydomain.com/medals/list/', type: MENU_LINK_TYPE.V1, active: false
+        })
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+})

+ 59 - 0
tests/units/services/layout/menuBuilder/myAccessesMenuBuilder.test.ts

@@ -0,0 +1,59 @@
+
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import MyAccessesMenuBuilder from "~/services/layout/menuBuilder/myAccessesMenuBuilder";
+import {MenuGroup, MenuItem} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: MyAccessesMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new MyAccessesMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("MyAccesses")
+    })
+})
+
+describe('build', () => {
+    test('default : has no items, even with rights', () => {
+        ability.can = vi.fn(() => true)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('with enabled multi accesses', () => {
+        accessProfile.multiAccesses = [
+            { id: 1, name: 'Bob' },
+            { id: 2, name: 'Séraphin' },
+            { id: 3, name: 'Lilou' },
+        ]
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('multiAccesses')
+        expect(result.icon).toEqual({name: 'fas fa-building'})
+
+        expect(result.children).toEqual([
+            { label: 'Bob', icon: undefined, to: 'https://mydomain.com/switch/1', type: MENU_LINK_TYPE.V1, active: false },
+            { label: 'Séraphin', icon: undefined, to: 'https://mydomain.com/switch/2', type: MENU_LINK_TYPE.V1, active: false },
+            { label: 'Lilou', icon: undefined, to: 'https://mydomain.com/switch/3', type: MENU_LINK_TYPE.V1, active: false }
+        ])
+    })
+})
+

+ 72 - 0
tests/units/services/layout/menuBuilder/myFamilyMenuBuilder.test.ts

@@ -0,0 +1,72 @@
+import {describe, expect, test} from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import MyFamilyMenuBuilder from "~/services/layout/menuBuilder/myFamilyMenuBuilder";
+import {GENDER} from "~/types/enum/enums";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import {MenuGroup} from "~/types/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: MyFamilyMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new MyFamilyMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("MyFamily")
+    })
+})
+
+describe('build', () => {
+    test('no item by default', () => {
+        ability.can = vi.fn(() => true)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('with family accesses', () => {
+        ability.can = vi.fn(() => true)
+
+        organizationProfile.id = 100
+        accessProfile.id = 1
+
+        accessProfile.familyAccesses = [
+            { id: 1, name: 'Bob', givenName: 'Dupont', gender: GENDER.MISTER, avatarId: 1 },
+            { id: 2, name: 'Séraphin', givenName: 'Dupuis', gender: GENDER.MISTER, avatarId: 2 },
+            { id: 3, name: 'Lilou', givenName: 'Dubois', gender: GENDER.MISS, avatarId: 3 },
+        ]
+
+        accessProfile.originalAccess = {
+            id: 4,
+            name: 'Tony',
+            givenName: 'Soprano',
+            gender: GENDER.MISTER,
+            avatarId: 4,
+            isSuperAdminAccess: false,
+            organization: { id: 100, name: 'my_organization' }
+        }
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.children).toEqual([
+            { label: 'Dupont Bob', icon: { avatarId: 1, avatarByDefault: '/images/default/men-1.png'}, to: 'https://mydomain.com/switch_user/100/1/1', type: MENU_LINK_TYPE.V1, active: false },
+            { label: 'Dupuis Séraphin', icon: { avatarId: 2, avatarByDefault: '/images/default/men-1.png'}, to: 'https://mydomain.com/switch_user/100/1/2', type: MENU_LINK_TYPE.V1, active: false },
+            { label: 'Dubois Lilou', icon: { avatarId: 3, avatarByDefault: '/images/default/women-1.png'}, to: 'https://mydomain.com/switch_user/100/1/3', type: MENU_LINK_TYPE.V1, active: false },
+            { label: 'Soprano Tony', icon: undefined, to: 'https://mydomain.com/switch_user/100/4/exit', type: MENU_LINK_TYPE.V1, active: false }
+        ])
+    })
+})
+

+ 104 - 0
tests/units/services/layout/menuBuilder/parametersMenuBuilder.test.ts

@@ -0,0 +1,104 @@
+
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import ParametersMenuBuilder from "~/services/layout/menuBuilder/parametersMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: ParametersMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new ParametersMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Parameters")
+    })
+})
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('parameters')
+        expect(result.icon).toEqual(undefined)
+        // @ts-ignore
+        expect(result.children.length).toEqual(6)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu general_params', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'parameters_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'general_params', icon: {name: 'fas fa-cogs'}, to: 'https://mydomain.com/parameters', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu communication_params', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'parameters_communication_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'communication_params', icon: {name: 'fas fa-comments'}, to: 'https://mydomain.com/parameters/communication', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu students_params', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'parameters_student_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'students_params', icon: {name: 'fas fa-users'}, to: 'https://mydomain.com/parameters/student', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu education_params', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'parameters_education_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'education_params', icon: {name: 'fas fa-graduation-cap'}, to: 'https://mydomain.com/parameters/education', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu bills_params', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'parameters_bills_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'bills_params', icon: {name: 'fas fa-euro-sign'}, to: 'https://mydomain.com/parameters/billing', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu secure_params', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'parameters_secure_page')
+
+        // @ts-ignore
+        expect(menuBuilder.build().children[0]).toEqual(
+            {label: 'secure_params', icon: {name: 'fas fa-lock'}, to: 'https://mydomain.com/parameters/secure', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 76 - 0
tests/units/services/layout/menuBuilder/statsMenuBuilder.test.ts

@@ -0,0 +1,76 @@
+
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import StatsMenuBuilder from "~/services/layout/menuBuilder/statsMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: StatsMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new StatsMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("Stats")
+    })
+})
+
+
+describe('build', () => {
+    test('has all items', () => {
+        ability.can = vi.fn(() => true)
+
+        // Should return a MenuGroup
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.label).toEqual('stats')
+        expect(result.icon).toEqual({name: 'fas fa-chart-bar'})
+        // @ts-ignore
+        expect(result.children.length).toEqual(4)
+    })
+
+    test('has no items', () => {
+        ability.can = vi.fn(() => false)
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('has only rights for menu report_activity', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'report_activity_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'report_activity', icon: {name: 'fas fa-chart-bar'}, to: 'https://mydomain.com/report_activity', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu fede_stats', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'fede_stats_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'fede_stats', icon: {name: 'fas fa-chart-bar'}, to: 'https://mydomain.com/statistic/membersfedeonly', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+
+    test('has only rights for menu structure_stats', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'structure_stats_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'structure_stats', icon: {name: 'fas fa-chart-bar'}, to: 'https://mydomain.com/statistic/membersfedeassos', type: MENU_LINK_TYPE.V1, active: false}
+        )
+    })
+})

+ 55 - 0
tests/units/services/layout/menuBuilder/websiteAdminMenuBuilder.test.ts

@@ -0,0 +1,55 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import WebsiteAdminMenuBuilder from "~/services/layout/menuBuilder/websiteAdminMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: WebsiteAdminMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = vi.fn() as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new WebsiteAdminMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("WebsiteAdmin")
+    })
+})
+
+describe('build', () => {
+    test('without website', () => {
+        organizationProfile.website = null
+        expect(menuBuilder.build()).toEqual(null)
+    })
+    test('without admin access', () => {
+        organizationProfile.website = 'https://some-website.com'
+        accessProfile.isAdminAccess = false
+        expect(menuBuilder.build()).toEqual(null)
+    })
+    test('with website and admin access', () => {
+        organizationProfile.website = 'https://some-website.com'
+        accessProfile.isAdminAccess = true
+        expect(menuBuilder.build()).toEqual(
+            {
+                label: 'advanced_modification',
+                icon: {name: 'fas fa-globe-americas'},
+                to: 'https://some-website.com/typo3',
+                type: MENU_LINK_TYPE.EXTERNAL,
+                active: false
+        })
+    })
+})
+

+ 96 - 0
tests/units/services/layout/menuBuilder/websiteListMenuBuilder.test.ts

@@ -0,0 +1,96 @@
+import { describe, test, it, expect } from 'vitest'
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, organizationState} from "~/types/interfaces";
+import WebsiteListMenuBuilder from "~/services/layout/menuBuilder/websiteListMenuBuilder";
+import {MenuGroup} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+
+let runtimeConfig: RuntimeConfig
+let ability: AnyAbility
+let organizationProfile: organizationState
+let accessProfile: AccessProfile
+let menuBuilder: WebsiteListMenuBuilder
+
+beforeEach(()=> {
+    runtimeConfig = vi.fn() as any as RuntimeConfig
+    ability = vi.fn() as any as AnyAbility
+    organizationProfile = {} as any as organizationState
+    accessProfile = vi.fn() as any as AccessProfile
+
+    runtimeConfig.baseUrlAdminLegacy = 'https://mydomain.com/'
+
+    menuBuilder = new WebsiteListMenuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+})
+
+
+describe('getMenuName', () => {
+    test('validate name', () => {
+        expect(menuBuilder.getMenuName()).toEqual("WebsiteList")
+    })
+})
+
+describe('build', () => {
+    test('without website and parents', () => {
+        organizationProfile.website = null
+        organizationProfile.parents = []
+        expect(menuBuilder.build()).toEqual(null)
+    })
+
+    test('with website but no parents', () => {
+        organizationProfile.name = 'MyOrganization'
+        organizationProfile.website = 'https://some-website.com'
+        organizationProfile.parents = []
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.children).toEqual([
+            {
+                label: 'MyOrganization',
+                icon: undefined,
+                to: 'https://some-website.com/typo3',
+                type: MENU_LINK_TYPE.EXTERNAL,
+                active: false
+            }
+        ])
+    })
+
+    test('with parents but no website', () => {
+        organizationProfile.name = 'MyOrganization'
+        organizationProfile.website = ''
+        organizationProfile.parents = [
+            {id: 1, name: 'parent1', website: 'https://parent1.net'},
+            {id: 2, name: 'parent2', website: 'https://parent2.net'},
+            {id: 3, name: 'parent3', website: 'https://parent3.net'},
+        ]
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.children).toEqual([
+            { label: 'parent1', icon: undefined, to: 'https://parent1.net', type: MENU_LINK_TYPE.EXTERNAL, active: false },
+            { label: 'parent2', icon: undefined, to: 'https://parent2.net', type: MENU_LINK_TYPE.EXTERNAL, active: false },
+            { label: 'parent3', icon: undefined, to: 'https://parent3.net', type: MENU_LINK_TYPE.EXTERNAL, active: false },
+        ])
+    })
+
+    test('with parents and website, opentalent is excluded from parents', () => {
+        organizationProfile.name = 'MyOrganization'
+        organizationProfile.website = 'https://some-website.com'
+        organizationProfile.parents = [
+            {id: 1, name: 'parent1', website: 'https://parent1.net'},
+            {id: 2, name: 'parent2', website: 'https://parent2.net'},
+            {id: 3, name: 'parent3', website: 'https://parent3.net'},
+        ]
+
+        runtimeConfig.OPENTALENT_MANAGER_ID = 3
+
+        const result = menuBuilder.build() as MenuGroup
+
+        expect(result.children).toEqual([
+            { label: 'MyOrganization', icon: undefined, to: 'https://some-website.com/typo3', type: MENU_LINK_TYPE.EXTERNAL, active: false },
+            { label: 'parent1', icon: undefined, to: 'https://parent1.net', type: MENU_LINK_TYPE.EXTERNAL, active: false },
+            { label: 'parent2', icon: undefined, to: 'https://parent2.net', type: MENU_LINK_TYPE.EXTERNAL, active: false },
+        ])
+    })
+})
+

+ 87 - 0
tests/units/services/layout/menuComposer.test.ts

@@ -0,0 +1,87 @@
+import { describe, test, it, expect } from 'vitest'
+import MenuComposer from "~/services/layout/menuComposer";
+import {RuntimeConfig} from "@nuxt/schema";
+import {AnyAbility} from "@casl/ability/dist/types";
+import {AccessProfile, LayoutState, organizationState} from "~/types/interfaces";
+import {MenuBuilder, MenuGroup, MenuItem} from "~/types/layout";
+import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import AbstractMenuBuilder from "~/services/layout/menuBuilder/abstractMenuBuilder";
+
+
+
+describe('build', () => {
+    test('simple call', () => {
+        // @ts-ignore
+        const runtimeConfig = vi.fn() as RuntimeConfig
+        // @ts-ignore
+        const ability = vi.fn() as AnyAbility
+        // @ts-ignore
+        const organizationProfile = vi.fn() as organizationState
+        // @ts-ignore
+        const accessProfile = vi.fn() as AccessProfile
+        // @ts-ignore
+        const layoutState = vi.fn() as LayoutState
+
+        layoutState.menus = {}
+        layoutState.menusOpened = {}
+
+        const item1 = {label: 'item1', type: MENU_LINK_TYPE.INTERNAL} as MenuItem
+        const group1 = {label: 'group1', children: []} as MenuGroup
+        const group2 = {label: 'group1', children: [{label: 'item2', type: MENU_LINK_TYPE.INTERNAL}]} as MenuGroup
+
+        // @ts-ignore
+        const dummyBuilder1 = vi.fn() as MenuBuilder
+        const dummyBuilderClass1 = vi.fn(() => dummyBuilder1)
+        dummyBuilder1.getMenuName = vi.fn(() => 'dummy1')
+        dummyBuilder1.build = vi.fn(() => item1)
+
+        // @ts-ignore
+        const dummyBuilder2 = vi.fn() as MenuBuilder
+        const dummyBuilderClass2 = vi.fn(() => dummyBuilder2)
+        dummyBuilder2.getMenuName = vi.fn(() => 'dummy2')
+        dummyBuilder2.build = vi.fn(() => group1)
+
+        // @ts-ignore
+        const dummyBuilder3 = vi.fn() as MenuBuilder
+        const dummyBuilderClass3 = vi.fn(() => dummyBuilder3)
+        dummyBuilder3.getMenuName = vi.fn(() => 'dummy3')
+        dummyBuilder3.build = vi.fn(() => group2)
+
+        // @ts-ignore
+        const dummyBuilder4 = vi.fn() as MenuBuilder
+        const dummyBuilderClass4 = vi.fn(() => dummyBuilder4)
+        dummyBuilder4.getMenuName = vi.fn(() => 'dummy4')
+        dummyBuilder4.build = vi.fn(() => null)
+
+        // @ts-ignore
+        MenuComposer.builders = [ dummyBuilderClass1, dummyBuilderClass2, dummyBuilderClass3, dummyBuilderClass4 ]
+
+        MenuComposer.build(runtimeConfig, ability, organizationProfile, accessProfile, layoutState)
+
+        expect(dummyBuilderClass1).toHaveBeenCalledOnce()
+        expect(dummyBuilderClass1).toHaveBeenCalledWith(runtimeConfig, ability, organizationProfile, accessProfile)
+        expect(dummyBuilder1.build).toHaveBeenCalledOnce()
+
+        expect(dummyBuilderClass2).toHaveBeenCalledOnce()
+        expect(dummyBuilderClass2).toHaveBeenCalledWith(runtimeConfig, ability, organizationProfile, accessProfile)
+        expect(dummyBuilder2.build).toHaveBeenCalledOnce()
+
+        expect(dummyBuilderClass3).toHaveBeenCalledOnce()
+        expect(dummyBuilderClass3).toHaveBeenCalledWith(runtimeConfig, ability, organizationProfile, accessProfile)
+        expect(dummyBuilder3.build).toHaveBeenCalledOnce()
+
+        expect(dummyBuilderClass4).toHaveBeenCalledOnce()
+        expect(dummyBuilderClass4).toHaveBeenCalledWith(runtimeConfig, ability, organizationProfile, accessProfile)
+        expect(dummyBuilder4.build).toHaveBeenCalledOnce()
+
+        expect(layoutState.menus).toEqual({
+            'dummy1': item1,
+            'dummy2': group1,
+            'dummy3': group2
+        })
+
+        expect(layoutState.menusOpened).toEqual({
+            'dummy3': false
+        })
+    })
+})

+ 674 - 0
tests/units/services/rights/abilityBuilder.test.ts

@@ -0,0 +1,674 @@
+import { describe, test, it, expect } from 'vitest'
+import {MongoAbility} from "@casl/ability/dist/types/Ability";
+import {AbilitiesType, AccessProfile, organizationState} from "~/types/interfaces";
+import AbilityBuilder from "~/services/rights/abilityBuilder";
+import {ABILITIES} from "~/types/enum/enums";
+import yaml from "yaml-import";
+
+let ability: MongoAbility
+let accessProfile: AccessProfile
+let organizationProfile: organizationState
+let abilityBuilder: TestableAbilityBuilder
+
+class TestableAbilityBuilder extends AbilityBuilder {
+    public execAndValidateCondition(condition: any, subject: string = '') { return super.execAndValidateCondition(condition, subject) }
+}
+
+// Mock the content of the config yaml files
+// > This must be done in the global scope: https://vitest.dev/api/vi.html#vi-mock
+const doc = {
+    abilities: {
+        'subject1': {
+            action: ABILITIES.READ,
+            conditions: [
+                {
+                    'function': 'fct1',
+                    'parameters': ['param1'],
+                    'expectedResult': true
+                }
+            ]
+        },
+        'subject2': {
+            action: ABILITIES.READ,
+            conditions: {
+                'function': 'fct2',
+                'parameters': ['param2'],
+                'expectedResult': false
+            }
+        }
+    }
+}
+vi.mock('yaml-import', async () => {
+    return {
+        default: { read: vi.fn((data: string) => doc) }
+    }
+})
+
+beforeEach(() => {
+    ability = vi.fn() as any as MongoAbility
+    accessProfile = vi.fn() as any as AccessProfile
+    organizationProfile = vi.fn() as any as organizationState
+
+    abilityBuilder = new TestableAbilityBuilder(ability, accessProfile, organizationProfile)
+})
+
+describe('buildAbilities', () => {
+    test('base call', () => {
+
+        const roleAbilities: Array<AbilitiesType> = [
+            {action: ABILITIES.READ, subject: 'subject1'},
+            {action: ABILITIES.READ, subject: 'subject2'}
+        ]
+        const configAbilities: Array<AbilitiesType> = [
+            {action: ABILITIES.READ, subject: 'subject3'},
+            {action: ABILITIES.READ, subject: 'subject4'}
+        ]
+        const allAbilities: Array<AbilitiesType> = roleAbilities.concat(configAbilities)
+
+
+        abilityBuilder.buildAbilitiesFromRoles = vi.fn(() => roleAbilities)
+        abilityBuilder.buildAbilitiesFromConfig = vi.fn(() => configAbilities)
+
+        ability.update = vi.fn()
+
+        const result = abilityBuilder.buildAbilities()
+
+        expect(ability.update).toHaveBeenCalledTimes(2)
+        expect(ability.update).toHaveBeenCalledWith(roleAbilities)
+        expect(ability.update).toHaveBeenCalledWith(allAbilities)
+
+        expect(abilityBuilder.buildAbilitiesFromRoles).toHaveBeenCalledOnce()
+        expect(abilityBuilder.buildAbilitiesFromConfig).toHaveBeenCalledOnce()
+
+        expect(result).toEqual(allAbilities)
+    })
+})
+
+describe('buildAbilitiesFromRoles', () => {
+  test('calls roleUtils', () => {
+      accessProfile.roles = ['ROLE_EVENTS_VIEW', 'ROLE_COURSES', 'ROLE_TEACHER_CORE', 'ROLE_OTHER']
+
+      const expected = [
+          { subject: 'events', action: 'read' },
+          { subject: 'courses', action: 'manage' },
+          { subject: 'other', action: 'manage' },
+      ]
+
+      expect(abilityBuilder.buildAbilitiesFromRoles()).toEqual(expected)
+  })
+})
+
+describe('buildAbilitiesFromConfig', () => {
+  test('calls roleUtils', () => {
+      abilityBuilder.hasConfigAbility = vi.fn(() => true)
+
+      expect(abilityBuilder.buildAbilitiesFromConfig()).toEqual([
+          { action: 'read', subject: 'subject1' },
+          { action: 'read', subject: 'subject2' },
+      ])
+  })
+})
+
+describe('hasConfigAbility', () => {
+    beforeEach(() => {
+        accessProfile.isGuardian = true
+        // @ts-ignore
+        organizationProfile.isSchool = true
+        // @ts-ignore
+        organizationProfile.isCmf = false
+    })
+    test('fulfill all conditions', () => {
+        const conditions = [
+            {'function': 'accessHasAnyProfile', parameters: ['guardian', 'payer']},
+            {'function': 'organizationIsSchool'},
+        ]
+
+        expect(abilityBuilder.hasConfigAbility(conditions)).toBeTruthy()
+    })
+    test('fulfill at least one condition', () => {
+        const conditions = [
+            {'function': 'accessHasAnyProfile', parameters: ['guardian', 'payer']},
+            {'function': 'organizationIsCmf'},
+        ]
+
+        expect(abilityBuilder.hasConfigAbility(conditions)).toBeFalsy()
+    })
+    test('fulfill none of the conditions', () => {
+        const conditions = [
+            {'function': 'organizationIsCmf'},
+        ]
+
+        expect(abilityBuilder.hasConfigAbility(conditions)).toBeFalsy()
+    })
+})
+
+describe('execAndValidateCondition', () => {
+    test('accessHasAllRoleAbilities', () => {
+        ability.can = vi.fn((action: string, subject: string) => {
+            return action === 'read' && (subject === 'subject1' || subject === 'subject2')
+        })
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {
+                    'function': 'accessHasAllRoleAbilities',
+                    parameters: [
+                        {action: ABILITIES.READ, subject: 'subject1'},
+                        {action: ABILITIES.READ, subject: 'subject2'},
+                    ]
+                })
+        ).toBeTruthy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {
+                    'function': 'accessHasAllRoleAbilities',
+                    parameters: [
+                    {action: ABILITIES.READ, subject: 'subject1'},
+                    {action: ABILITIES.READ, subject: 'subject3'}
+                ]
+            })
+        ).toBeFalsy()
+    })
+
+    test('accessHasAnyRoleAbility', () => {
+        ability.can = vi.fn((action: string, subject: string) => {
+            return action === 'read' && subject === 'subject1'
+        })
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {
+                    'function': 'accessHasAnyRoleAbility',
+                    parameters: [
+                        {action: ABILITIES.READ, subject: 'subject1'},
+                        {action: ABILITIES.READ, subject: 'subject2'},
+                    ]
+                })
+        ).toBeTruthy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'accessHasAnyRoleAbility', parameters: [{action: ABILITIES.READ, subject: 'subject2'}]})
+        ).toBeFalsy()
+    })
+
+    test('accessHasAnyProfile', () => {
+        accessProfile.isMember = true
+        accessProfile.isGuardian = true
+        accessProfile.isPayer = true
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'accessHasAnyProfile', parameters: ['guardian', 'payer']}
+            )
+        ).toBeTruthy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'accessHasAnyProfile', parameters: ['guardian', 'caMember']}
+            )
+        ).toBeTruthy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'accessHasAnyProfile', parameters: ['caMember']}
+            )
+        ).toBeFalsy()
+    })
+
+    test('organizationHasAllModules', () => {
+        // @ts-ignore
+        organizationProfile.hasModule = vi.fn(
+            (module: string) => module === 'module1' || module === 'module2'
+        )
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'organizationHasAllModules', parameters: ['module1', 'module2']}
+            )
+        ).toBeTruthy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'organizationHasAllModules', parameters: ['module1', 'module3']}
+            )
+        ).toBeFalsy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'organizationHasAllModules', parameters: ['module3']}
+            )
+        ).toBeFalsy()
+    })
+
+    test('organizationHasAnyModule', () => {
+        // @ts-ignore
+        organizationProfile.hasModule = vi.fn(
+            (module: string) => module === 'module1' || module === 'module2'
+        )
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'organizationHasAnyModule', parameters: ['module1', 'module2']}
+            )
+        ).toBeTruthy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'organizationHasAnyModule', parameters: ['module1', 'module3']}
+            )
+        ).toBeTruthy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition(
+                {'function': 'organizationHasAnyModule', parameters: ['module3']}
+            )
+        ).toBeFalsy()
+    })
+
+    test('organizationHasAnyModule', () => {
+        // @ts-ignore
+        accessProfile.isAdminAccount = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'accessIsAdminAccount'})).toBeTruthy()
+
+        // @ts-ignore
+        accessProfile.isAdminAccount = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'accessIsAdminAccount'})).toBeFalsy()
+    })
+
+    test('organizationIsSchool', () => {
+        // @ts-ignore
+        organizationProfile.isSchool = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsSchool'})).toBeTruthy()
+
+        // @ts-ignore
+        organizationProfile.isSchool = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsSchool'})).toBeFalsy()
+    })
+
+    test('organizationIsArtist', () => {
+        // @ts-ignore
+        organizationProfile.isArtist = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsArtist'})).toBeTruthy()
+
+        // @ts-ignore
+        organizationProfile.isArtist = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsArtist'})).toBeFalsy()
+    })
+
+    test('organizationIsManagerProduct', () => {
+        // @ts-ignore
+        organizationProfile.isManagerProduct = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsManagerProduct'})).toBeTruthy()
+
+        // @ts-ignore
+        organizationProfile.isManagerProduct = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsManagerProduct'})).toBeFalsy()
+    })
+
+    test('organizationHasChildren', () => {
+        // @ts-ignore
+        organizationProfile.hasChildren = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationHasChildren'})).toBeTruthy()
+
+        // @ts-ignore
+        organizationProfile.hasChildren = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationHasChildren'})).toBeFalsy()
+    })
+
+    test('organizationIsAssociation', () => {
+        // @ts-ignore
+        organizationProfile.isAssociation = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsAssociation'})).toBeTruthy()
+
+        // @ts-ignore
+        organizationProfile.isAssociation = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsAssociation'})).toBeFalsy()
+    })
+
+    test('organizationIsShowAdherentList', () => {
+        // @ts-ignore
+        organizationProfile.isShowAdherentList = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsShowAdherentList'})).toBeTruthy()
+
+        // @ts-ignore
+        organizationProfile.isShowAdherentList = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsShowAdherentList'})).toBeFalsy()
+    })
+
+    test('organizationIsCmf', () => {
+        // @ts-ignore
+        organizationProfile.isCmf = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsCmf'})).toBeTruthy()
+
+        // @ts-ignore
+        organizationProfile.isCmf = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationIsCmf'})).toBeFalsy()
+    })
+
+    test('organizationHasWebsite', () => {
+        // @ts-ignore
+        organizationProfile.getWebsite = true
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationHasWebsite'})).toBeTruthy()
+
+        // @ts-ignore
+        organizationProfile.getWebsite = false
+        expect(abilityBuilder.execAndValidateCondition({'function': 'organizationHasWebsite'})).toBeFalsy()
+    })
+
+    test('with expected result', () => {
+        // @ts-ignore
+        organizationProfile.getWebsite = true
+
+        expect(
+            abilityBuilder.execAndValidateCondition({'function': 'organizationHasWebsite', expectedResult: true})
+        ).toBeTruthy()
+
+        expect(
+            abilityBuilder.execAndValidateCondition({'function': 'organizationHasWebsite', expectedResult: 'abc'})
+        ).toBeFalsy()
+    })
+
+    test('invalid function', () => {
+        expect(
+            () => abilityBuilder.execAndValidateCondition({'function': 'invalid'})
+        ).toThrowError('unknown condition function : invalid')
+    })
+})
+
+describe('hasRoleAbility', () => {
+    beforeEach(() => {
+        ability.can = vi.fn((action: string, subject: string) => {
+            return action === 'read' && subject === 'a_subject'
+        })
+    })
+
+    test('owned ability', () => {
+        expect(abilityBuilder.hasRoleAbility({action: ABILITIES.READ, subject: 'a_subject'})).toBeTruthy()
+    })
+
+    test('not owned ability', () => {
+        expect(abilityBuilder.hasRoleAbility({action: ABILITIES.READ, subject: 'other_subject'})).toBeFalsy()
+    })
+})
+
+describe('hasAllRoleAbilities', () => {
+    beforeEach(() => {
+        ability.can = vi.fn((action: string, subject: string) => {
+            return action === 'read' && (subject === 'subject1' || subject === 'subject2')
+        })
+    })
+
+    test('own all abilities', () => {
+        const result = abilityBuilder.hasAllRoleAbilities(
+            [
+                {action: ABILITIES.READ, subject: 'subject1'},
+                {action: ABILITIES.READ, subject: 'subject2'},
+            ]
+        )
+
+        expect(result).toBeTruthy()
+    })
+
+    test('own at least one ability', () => {
+        const result = abilityBuilder.hasAllRoleAbilities(
+            [
+                {action: ABILITIES.READ, subject: 'subject1'},
+                {action: ABILITIES.READ, subject: 'subject3'},
+            ]
+        )
+
+        expect(result).toBeFalsy()
+    })
+
+    test('own none of the abilities', () => {
+        const result = abilityBuilder.hasAllRoleAbilities(
+            [
+                {action: ABILITIES.READ, subject: 'subject3'},
+                {action: ABILITIES.READ, subject: 'subject4'},
+            ]
+        )
+
+        expect(result).toBeFalsy()
+    })
+})
+
+describe('hasAnyRoleAbility', () => {
+    beforeEach(() => {
+        ability.can = vi.fn((action: string, subject: string) => {
+            return action === 'read' && (subject === 'subject1' || subject === 'subject2')
+        })
+    })
+
+    test('has all abilities', () => {
+        const result = abilityBuilder.hasAnyRoleAbility(
+            [
+                {action: ABILITIES.READ, subject: 'subject1'},
+                {action: ABILITIES.READ, subject: 'subject2'},
+            ]
+        )
+
+        expect(result).toBeTruthy()
+    })
+
+    test('has at least one ability', () => {
+        const result = abilityBuilder.hasAnyRoleAbility(
+            [
+                {action: ABILITIES.READ, subject: 'subject1'},
+                {action: ABILITIES.READ, subject: 'subject3'},
+            ]
+        )
+
+        expect(result).toBeTruthy()
+    })
+
+    test('any none of the abilites', () => {
+        const result = abilityBuilder.hasAnyRoleAbility(
+            [
+                {action: ABILITIES.READ, subject: 'subject3'},
+                {action: ABILITIES.READ, subject: 'subject4'},
+            ]
+        )
+
+        expect(result).toBeFalsy()
+    })
+})
+
+describe('hasProfile', () => {
+    test('owned profiles', () => {
+        accessProfile.isAdmin = true
+        accessProfile.isAdministratifManager = true
+        accessProfile.isPedagogicManager = true
+        accessProfile.isFinancialManager = true
+        accessProfile.isCaMember = true
+        accessProfile.isStudent = true
+        accessProfile.isTeacher = true
+        accessProfile.isMember = true
+        accessProfile.isOther = true
+        accessProfile.isGuardian = true
+        accessProfile.isPayer = true
+
+        expect(abilityBuilder.hasProfile('admin')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('administratifManager')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('pedagogicManager')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('financialManager')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('caMember')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('student')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('teacher')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('member')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('other')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('guardian')).toBeTruthy()
+        expect(abilityBuilder.hasProfile('payor')).toBeTruthy()
+    })
+
+    test('not owned profiles', () => {
+        accessProfile.isAdmin = false
+        accessProfile.isAdministratifManager = false
+        accessProfile.isPedagogicManager = false
+        accessProfile.isFinancialManager = false
+        accessProfile.isCaMember = false
+        accessProfile.isStudent = false
+        accessProfile.isTeacher = false
+        accessProfile.isMember = false
+        accessProfile.isOther = false
+        accessProfile.isGuardian = false
+        accessProfile.isPayer = false
+
+        expect(abilityBuilder.hasProfile('admin')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('administratifManager')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('pedagogicManager')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('financialManager')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('caMember')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('student')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('teacher')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('member')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('other')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('guardian')).toBeFalsy()
+        expect(abilityBuilder.hasProfile('payor')).toBeFalsy()
+    })
+})
+
+describe('hasAnyProfile', () => {
+    beforeEach(() => {
+        accessProfile.isMember = true
+        accessProfile.isGuardian = true
+        accessProfile.isPayer = true
+    })
+
+    test('own all profiles', () => {
+        expect(abilityBuilder.hasAnyProfile(['member', 'guardian', 'payor'])).toBeTruthy()
+    })
+
+    test('own at least one profile', () => {
+        expect(abilityBuilder.hasAnyProfile(['member', 'caMember'])).toBeTruthy()
+    })
+
+    test('own none of the profiles', () => {
+        expect(abilityBuilder.hasAnyProfile(['caMember', 'isFinancialManager'])).toBeFalsy()
+    })
+})
+
+describe('hasAllProfiles', () => {
+    beforeEach(() => {
+        accessProfile.isMember = true
+        accessProfile.isGuardian = true
+        accessProfile.isPayer = true
+    })
+
+    test('own all profiles', () => {
+        expect(abilityBuilder.hasAllProfiles(['member', 'guardian', 'payor'])).toBeTruthy()
+    })
+
+    test('own only one of the profiles', () => {
+        expect(abilityBuilder.hasAllProfiles(['member', 'caMember'])).toBeFalsy()
+    })
+
+    test('own none of the profiles', () => {
+        expect(abilityBuilder.hasAllProfiles(['caMember', 'isFinancialManager'])).toBeFalsy()
+    })
+})
+
+describe('hasRole', () => {
+    beforeEach(() => {
+        // @ts-ignore
+        accessProfile.hasRole = vi.fn((role: string) => role === 'foo')
+    })
+
+    test('has role', () => {
+        expect(abilityBuilder.hasRole('foo')).toBeTruthy()
+    })
+    test('has not role', () => {
+        expect(abilityBuilder.hasRole('bar')).toBeFalsy()
+    })
+})
+
+describe('hasAnyRole', () => {
+    beforeEach(() => {
+        // @ts-ignore
+        accessProfile.hasRole = vi.fn((role: string) => role === 'role1' || role === 'role2')
+    })
+
+    test('own all roles', () => {
+        expect(abilityBuilder.hasAnyRole(['role1', 'role2'])).toBeTruthy()
+    })
+
+    test('own at least one role', () => {
+        expect(abilityBuilder.hasAnyRole(['role1', 'role3'])).toBeTruthy()
+    })
+
+    test('own none of the roles', () => {
+        expect(abilityBuilder.hasAnyRole(['role3'])).toBeFalsy()
+    })
+})
+
+describe('hasAllRoles', () => {
+    beforeEach(() => {
+        // @ts-ignore
+        accessProfile.hasRole = vi.fn((role: string) => role === 'role1' || role === 'role2')
+    })
+
+    test('own all roles', () => {
+        expect(abilityBuilder.hasAllRoles(['role1', 'role2'])).toBeTruthy()
+    })
+
+    test('own at least one role', () => {
+        expect(abilityBuilder.hasAllRoles(['role1', 'role3'])).toBeFalsy()
+    })
+
+    test('own none of the roles', () => {
+        expect(abilityBuilder.hasAllRoles(['role3'])).toBeFalsy()
+    })
+})
+
+describe('hasModule', () => {
+    beforeEach(() => {
+        // @ts-ignore
+        organizationProfile.hasModule = vi.fn((module: string) => module === 'foo')
+    })
+
+    test('has module', () => {
+        expect(abilityBuilder.hasModule('foo')).toBeTruthy()
+    })
+    test('has not module', () => {
+        expect(abilityBuilder.hasModule('bar')).toBeFalsy()
+    })
+})
+
+describe('hasAnyModule', () => {
+    beforeEach(() => {
+        // @ts-ignore
+        organizationProfile.hasModule = vi.fn((Module: string) => Module === 'Module1' || Module === 'Module2')
+    })
+
+    test('own all modules', () => {
+        expect(abilityBuilder.hasAnyModule(['Module1', 'Module2'])).toBeTruthy()
+    })
+
+    test('own at least one module', () => {
+        expect(abilityBuilder.hasAnyModule(['Module1', 'Module3'])).toBeTruthy()
+    })
+
+    test('own none of the modules', () => {
+        expect(abilityBuilder.hasAnyModule(['Module3'])).toBeFalsy()
+    })
+})
+
+describe('hasAllModules', () => {
+    beforeEach(() => {
+        // @ts-ignore
+        organizationProfile.hasModule = vi.fn((Module: string) => Module === 'Module1' || Module === 'Module2')
+    })
+
+    test('own all modules', () => {
+        expect(abilityBuilder.hasAllModules(['Module1', 'Module2'])).toBeTruthy()
+    })
+
+    test('own at least one module', () => {
+        expect(abilityBuilder.hasAllModules(['Module1', 'Module3'])).toBeFalsy()
+    })
+
+    test('own none of the modules', () => {
+        expect(abilityBuilder.hasAllModules(['Module3'])).toBeFalsy()
+    })
+})

+ 44 - 0
tests/units/services/rights/roleUtils.test.ts

@@ -0,0 +1,44 @@
+import { describe, test, it, expect } from 'vitest'
+import RoleUtils from "~/services/rights/roleUtils";
+
+describe('isA', () => {
+    test('has role', () => {
+        const roles = ['ROLE_PEDAGOGICS_MANAGER', 'ROLE_TEACHER_CORE', 'ROLE_OTHER']
+        expect(RoleUtils.isA('teacher', roles)).toBeTruthy()
+    })
+    test('has not role', () => {
+        const roles = ['ROLE_PEDAGOGICS_MANAGER', 'ROLE_TEACHER_CORE', 'ROLE_OTHER']
+        expect(RoleUtils.isA('financial_manager', roles)).toBeFalsy()
+    })
+    test('invalid profile name', () => {
+        expect(() => RoleUtils.isA('profile-123', [])).toThrowError('invalid role name')
+    })
+    test('unknown profile name', () => {
+        const roles = ['ROLE_PEDAGOGICS_MANAGER', 'ROLE_TEACHER_CORE', 'ROLE_OTHER']
+        expect(RoleUtils.isA('unknown', roles)).toBeFalsy()
+    })
+})
+
+describe('filterFunctionRoles', () => {
+    test('exclude roles', () => {
+        const roles = ['ROLE_EVENTS', 'ROLE_COURSES', 'ROLE_TEACHER_CORE', 'ROLE_OTHER']
+        expect(RoleUtils.filterFunctionRoles(roles)).toEqual(['ROLE_EVENTS', 'ROLE_COURSES'])
+    })
+});
+
+describe('transformUnderscoreToHyphen', () => {
+
+})
+
+describe('rolesToAbilities', () => {
+    test('exclude roles', () => {
+        const roles = ['ROLE_EVENTS_VIEW', 'ROLE_COURSES', 'ROLE_TEACHER_CORE', 'ROLE_OTHER']
+        const expected = [
+            { subject: 'events', action: 'read' },
+            { subject: 'courses', action: 'manage' },
+            { subject: 'other', action: 'manage' },
+        ]
+
+        expect(RoleUtils.rolesToAbilities(roles)).toEqual(expected)
+    })
+})

+ 173 - 0
tests/units/services/sse/sseSource.test.ts

@@ -0,0 +1,173 @@
+import { describe, test, it, expect } from 'vitest'
+import SseSource from "~/services/sse/sseSource";
+import {EventSourcePolyfill} from "event-source-polyfill";
+
+class TestableSseSource extends SseSource {
+    public getUrl() { return this.url }
+    public getOnOpen() { return this.onOpen }
+    public getOnMessage() { return this.onMessage }
+    public getOnClose() { return this.onClose }
+    public getWithCredentials() { return this.withCredentials }
+
+    public createEventSource(url: string, withCredentials: boolean): EventSourcePolyfill {
+        return super.createEventSource(url, withCredentials)
+    }
+
+    public setEventSource(eventSource: EventSourcePolyfill) {
+        this.eventSource = eventSource
+    }
+}
+
+let mercureUrl: string
+let topic: string
+let onOpen: () => any
+let onMessage: (data: Array<any>) => any
+let onClose: () => any
+let sseSource: TestableSseSource
+
+beforeEach(() => {
+    mercureUrl = 'https://my.mercure.com'
+    topic = 'mytopic'
+
+    onOpen = () => 'opened'
+    onMessage = (data: Array<any>) => 'message'
+    onClose = () => 'closed'
+
+    sseSource = new TestableSseSource(mercureUrl, topic, onOpen, onMessage, onClose, false)
+})
+
+afterEach(() => {
+    vi.restoreAllMocks()
+})
+
+describe('test constructor', () => {
+    test('with all params', () => {
+        expect(sseSource.getUrl().toString()).toEqual('https://my.mercure.com/?topic=mytopic')
+        expect(sseSource.getOnOpen()).toEqual(onOpen)
+        expect(sseSource.getOnMessage()).toEqual(onMessage)
+        expect(sseSource.getOnClose()).toEqual(onClose)
+        expect(sseSource.getWithCredentials()).toEqual(false)
+    })
+})
+
+describe('createEventSource', () => {
+    test('simple call', () => {
+        const eventSource = sseSource.createEventSource(mercureUrl, false)
+
+        expect(eventSource.readyState).toEqual(0)
+        expect(eventSource.url).toEqual(mercureUrl)
+        expect(eventSource.withCredentials).toEqual(false)
+    })
+})
+
+describe('isConnected', () => {
+    test('no event source', () => {
+        expect(sseSource.isConnected()).toEqual(false)
+    })
+    test('got an event source, but it is not open', () => {
+        const eventSource = sseSource.createEventSource(mercureUrl, true)
+        sseSource.setEventSource(eventSource)
+        expect(sseSource.isConnected()).toEqual(false)
+    })
+    test('got an open event source', () => {
+        // @ts-ignore
+        const eventSource = vi.fn() as EventSourcePolyfill
+        // @ts-ignore
+        // noinspection JSConstantReassignment
+        eventSource.readyState = EventSourcePolyfill.OPEN
+        sseSource.setEventSource(eventSource)
+        expect(sseSource.isConnected()).toEqual(true)
+    })
+})
+
+describe('subscribe', () => {
+    test('already connected', () => {
+        sseSource.isConnected = vi.fn(() => true)
+        expect(() => sseSource.subscribe()).toThrowError("SSE - Already subscribed to this event source")
+    })
+    test('is server side', () => {
+        process.server = true
+        expect(() => sseSource.subscribe()).toThrowError("SSE - Cannot subscribe on server side")
+        process.server = false
+    })
+    test('successful subscription', () => {
+
+        onOpen = vi.fn(() => null)
+        onMessage = vi.fn((data: Array<any>) => null)
+
+        sseSource = new TestableSseSource(mercureUrl, topic, onOpen, onMessage, onClose)
+
+        const dummyEventSource = new EventSourcePolyfill('https://my.mercure.com', { withCredentials: true })
+
+        sseSource.createEventSource = vi.fn((url: string, withCredentials: boolean) => {
+            return dummyEventSource
+        })
+
+        console.log = vi.fn()
+        console.error = vi.fn()
+
+        sseSource.subscribe()
+        expect(sseSource.createEventSource).toHaveBeenCalled()
+
+        // @ts-ignore
+        dummyEventSource.onopen()
+        expect(onOpen).toHaveBeenCalled()
+        expect(console.log).toHaveBeenCalledWith('SSE - Listening for events...')
+
+        // @ts-ignore
+        dummyEventSource.onmessage({ data: '1' })
+        expect(onMessage).toHaveBeenCalledWith(1)
+
+        // @ts-ignore
+        dummyEventSource.onerror()
+        expect(console.error).toHaveBeenCalledWith('SSE - An error happened')
+    })
+})
+
+describe('unsubscribe', () => {
+    test('if no event source, does nothing', () => {
+        onClose = vi.fn(() => null)
+        sseSource = new TestableSseSource(mercureUrl, topic, onOpen, onMessage, onClose)
+
+        sseSource.unsubscribe()
+
+        expect(onClose).toHaveBeenCalledTimes(0)
+    })
+
+    test('if event source is not opened, does nothing', () => {
+        onClose = vi.fn(() => null)
+        sseSource = new TestableSseSource(mercureUrl, topic, onOpen, onMessage, onClose)
+
+        // @ts-ignore
+        const eventSource = vi.fn() as EventSourcePolyfill
+        // @ts-ignore
+        // noinspection JSConstantReassignment
+        eventSource.readyState = EventSourcePolyfill.CLOSED
+        sseSource.setEventSource(eventSource)
+
+        sseSource.unsubscribe()
+
+        expect(onClose).toHaveBeenCalledTimes(0)
+    })
+
+    test('has an open subscription', () => {
+        onClose = vi.fn(() => null)
+        sseSource = new TestableSseSource(mercureUrl, topic, onOpen, onMessage, onClose)
+
+        // @ts-ignore
+        const eventSource = vi.fn() as EventSourcePolyfill
+        // @ts-ignore
+        // noinspection JSConstantReassignment
+        eventSource.readyState = EventSourcePolyfill.OPEN
+        eventSource.close = vi.fn(() => null)
+        sseSource.setEventSource(eventSource)
+
+        console.log = vi.fn()
+
+        sseSource.unsubscribe()
+
+        expect(eventSource.close).toHaveBeenCalled()
+        expect(onClose).toHaveBeenCalled()
+        expect(console.log).toHaveBeenCalledWith('SSE - Subscription closed')
+    })
+})

+ 70 - 0
tests/units/services/utils/arrayUtils.test.ts

@@ -0,0 +1,70 @@
+import { describe, test, it, expect } from 'vitest'
+import ArrayUtils from "~/services/utils/arrayUtils";
+import DateUtils from "~/services/utils/dateUtils";
+
+describe('sort', () => {
+    test('with integers', () => {
+        expect(ArrayUtils.sort([2, 1, 3])).toEqual([1, 2, 3])
+        expect(ArrayUtils.sort([2, 1, 3], true)).toEqual([3, 2, 1])
+    })
+    test('with string', () => {
+        expect(ArrayUtils.sort(['b', 'a', 'c'])).toEqual(['a', 'b', 'c'])
+        expect(ArrayUtils.sort(['b', 'a', 'c'], true)).toEqual(['c', 'b', 'a'])
+    })
+    test('with dates', () => {
+        const input = [
+            new Date(2023, 0, 12),
+            new Date(2023, 0, 10),
+            new Date(2023, 0, 11)
+        ]
+
+        const expected = [
+            new Date(2023, 0, 10),
+            new Date(2023, 0, 11),
+            new Date(2023, 0, 12)
+        ]
+
+        const expectedReverse = [
+            new Date(2023, 0, 12),
+            new Date(2023, 0, 11),
+            new Date(2023, 0, 10)
+        ]
+
+        expect(DateUtils.sort(input)).toEqual(expected)
+        expect(DateUtils.sort(input, true)).toEqual(expectedReverse)
+    })
+})
+
+describe('sortObjectsByProp', () => {
+    test('existing prop', () => {
+        const input = [
+            {'id': 2, 'name': 'b'},
+            {'id': 1, 'name': 'a'},
+            {'id': 3, 'name': 'c'},
+        ]
+
+        const expected = [
+            {'id': 1, 'name': 'a'},
+            {'id': 2, 'name': 'b'},
+            {'id': 3, 'name': 'c'},
+        ]
+
+        expect(ArrayUtils.sortObjectsByProp(input, 'id')).toEqual(expected)
+    })
+    test('existing prop (reverse)', () => {
+        const input = [
+            {'id': 2, 'name': 'b'},
+            {'id': 1, 'name': 'a'},
+            {'id': 3, 'name': 'c'},
+        ]
+
+        const expected = [
+            {'id': 3, 'name': 'c'},
+            {'id': 2, 'name': 'b'},
+            {'id': 1, 'name': 'a'},
+        ]
+
+        expect(ArrayUtils.sortObjectsByProp(input, 'id', true)).toEqual(expected)
+    })
+})
+

+ 54 - 0
tests/units/services/utils/dateUtils.test.ts

@@ -0,0 +1,54 @@
+import { describe, test, it, expect } from 'vitest'
+import DateUtils from "~/services/utils/dateUtils";
+
+describe('format', () => {
+    test('simple formatting', () => {
+      const input = new Date(2020, 4, 12)
+
+      expect(DateUtils.format(input, 'y-MM-dd')).toEqual('2020-05-12')
+    })
+})
+
+describe('formatDatesAndConcat', () => {
+    test('simple array and default sep', () => {
+      const input = [
+          new Date(2023, 0, 10),
+          new Date(2023, 0, 11),
+          new Date(2023, 0, 12)
+      ]
+
+      const result = DateUtils.formatAndConcat(input, 'dd/MM/y')
+
+      expect(result).toEqual('10/01/2023 - 11/01/2023 - 12/01/2023')
+    })
+
+    test('single date and default sep', () => {
+      const input = new Date(2023, 0, 10)
+
+      const result = DateUtils.formatAndConcat(input, 'dd/MM/y')
+
+      expect(result).toEqual('10/01/2023')
+    })
+
+    test('simple array with other format and custom sep', () => {
+      const input = [
+          new Date(2023, 0, 10),
+          new Date(2023, 0, 11),
+          new Date(2023, 0, 12)
+      ]
+
+      const result = DateUtils.formatAndConcat(input, 'yMMdd', '|')
+
+      expect(result).toEqual('20230110|20230111|20230112')
+    })
+
+    test ('empty array', () => {
+        expect(DateUtils.formatAndConcat([], 'dd-MM-y')).toEqual('')
+    })
+})
+
+describe('sortDate', () => {
+    test('sort', () => {
+        // TODO: should assert that ArrayUtils.sort is called
+    })
+})

+ 69 - 0
tests/units/services/utils/i18nUtils.test.ts

@@ -0,0 +1,69 @@
+import { describe, test, it, expect } from 'vitest'
+import {createI18n, VueI18n} from 'vue-i18n'
+import I18nUtils from "~/services/utils/i18nUtils";
+import {EnumChoices} from "~/types/interfaces";
+
+describe('translateEnum', () => {
+    let i18nUtils: I18nUtils
+
+    beforeEach(() => {
+        // @ts-ignore
+        const i18n = vi.fn() as VueI18n;
+        i18nUtils = new I18nUtils(i18n)
+        i18n.t = vi.fn((msg: string) => msg.replace('This is the letter', 'C\'est la lettre'))
+    })
+
+    test('with simple enum', () => {
+        const input: EnumChoices = [
+            { value: 'Alpha', label: 'This is the letter A' },
+            { value: 'Beta', label: 'This is the letter B' },
+            { value: 'Epsilon', label: 'This is the letter E' },
+        ]
+
+        const expected: EnumChoices = [
+            { value: 'Alpha', label: 'C\'est la lettre A' },
+            { value: 'Beta', label: 'C\'est la lettre B' },
+            { value: 'Epsilon', label: 'C\'est la lettre E' },
+        ]
+
+        expect(i18nUtils.translateEnum(input)).toEqual(expected)
+    })
+
+    test('with simple enum and sorting enabled', () => {
+        const input: EnumChoices = [
+            { value: 'Epsilon', label: 'This is the letter E' },
+            { value: 'Alpha', label: 'This is the letter A' },
+            { value: 'Beta', label: 'This is the letter B' },
+        ]
+
+        const expected: EnumChoices = [
+            { value: 'Alpha', label: 'C\'est la lettre A' },
+            { value: 'Beta', label: 'C\'est la lettre B' },
+            { value: 'Epsilon', label: 'C\'est la lettre E' },
+        ]
+
+        expect(i18nUtils.translateEnum(input, true)).toEqual(expected)
+    })
+})
+
+describe('formatPhoneNumber', () => {
+    let i18nUtils: I18nUtils
+
+    beforeEach(() => {
+        // @ts-ignore
+        const i18n = vi.fn() as VueI18n;
+        i18nUtils = new I18nUtils(i18n)
+    })
+    test('with valid international phone number', () => {
+        expect(i18nUtils.formatPhoneNumber('+33611223344')).toEqual('06 11 22 33 44')
+    })
+    test('with valid non-formatted phone number', () => {
+        expect(i18nUtils.formatPhoneNumber('0611223344', 'FR')).toEqual('06 11 22 33 44')
+    })
+    test('with empty string', () => {
+        expect(() => i18nUtils.formatPhoneNumber('', 'FR')).toThrowError('NOT_A_NUMBER')
+    })
+    test('with invalid string', () => {
+        expect(() => i18nUtils.formatPhoneNumber('abcd', 'FR')).toThrowError('NOT_A_NUMBER')
+    })
+})

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác