diff --git a/ami/user/api_urls.py b/ami/user/api_urls.py index 32e8c3049..91d29575f 100644 --- a/ami/user/api_urls.py +++ b/ami/user/api_urls.py @@ -1,9 +1,10 @@ from django.urls import path -from .api_views import consent, registrations, unregister +from .api_views import consent, consents, registrations, unregister urlpatterns = [ path("users/registrations", registrations), path("users/registrations/", unregister), path("consent/", consent), + path("users/consents", consents), ] diff --git a/ami/user/api_views.py b/ami/user/api_views.py index 2d608ffc3..7b992d04c 100644 --- a/ami/user/api_views.py +++ b/ami/user/api_views.py @@ -21,6 +21,8 @@ ConsentPostResponseSerializer, ConsentPostSerializer, ConsentResponseSerializer, + ConsentSerializer, + ConsentUpdateSerializer, MobileAppSubscriptionSerializer, RegistrationCreateSerializer, RegistrationSerializer, @@ -156,3 +158,36 @@ def consent(request: Request, fc_hash: str) -> Response: {"message": "Consent given" if data["consent"] else "Consent withdrawn"} ) return Response(response_serializer.data) + + +@extend_schema( + methods=["POST"], + request=ConsentUpdateSerializer, +) +@api_view(["GET", "POST"]) +@ami_login_required +def consents(request: Request) -> Response: + if request.method == "GET": + consents_qs: QuerySet[Consent] = request.ami_user.consent_set.all() + return Response(ConsentSerializer(consents_qs, many=True).data) + + serializer = ConsentUpdateSerializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError: + logger.exception("Internal post consent serialization error") + raise + data: dict = cast(dict, serializer.validated_data) + + consent_datetime = now() if data["consent"] else None + Consent.objects.update_or_create( + user=request.ami_user, + partner_id=data["partner_id"], + defaults={"consent_datetime": consent_datetime}, + create_defaults={"consent_datetime": consent_datetime}, + ) + + response_serializer = ConsentPostResponseSerializer( + {"message": "Consent given" if data["consent"] else "Consent withdrawn"} + ) + return Response(response_serializer.data) diff --git a/ami/user/serializers.py b/ami/user/serializers.py index 1f2b7598b..7f7bdfe5d 100644 --- a/ami/user/serializers.py +++ b/ami/user/serializers.py @@ -48,3 +48,14 @@ class ConsentPostSerializer(serializers.Serializer): class ConsentPostResponseSerializer(serializers.Serializer): message = serializers.ChoiceField(choices=["Consent given", "Consent withdrawn"]) + + +class ConsentSerializer(serializers.Serializer): + id = serializers.UUIDField() + partner_id = serializers.CharField() + consent_datetime = serializers.DateTimeField() + + +class ConsentUpdateSerializer(serializers.Serializer): + partner_id = serializers.CharField() + consent = serializers.BooleanField() diff --git a/ami/user/tests/test_consent.py b/ami/user/tests/test_consent.py index 08b2fbe50..cca82d866 100644 --- a/ami/user/tests/test_consent.py +++ b/ami/user/tests/test_consent.py @@ -4,6 +4,7 @@ import pytest from django.utils.timezone import now +from ami.tests.utils import assert_query_fails_without_auth, login from ami.user.models import Consent, User @@ -165,3 +166,92 @@ def test_post_consent_without_auth(app, settings) -> None: b64 = base64.b64encode("dinum-ami:foo".encode("utf8")).decode("utf8") app.post("/api/v1/consent/fake-fc-hash", headers={"authorization": f"Basic {b64}"}, status=401) + + +@pytest.mark.django_db +def test_get_consents(app, user: User) -> None: + login(app, user) + + consent_datetime = datetime.datetime(2020, 12, 25, 17, 5, 55, tzinfo=datetime.timezone.utc) + consent = Consent.objects.create(user=user, partner_id="psl", consent_datetime=consent_datetime) + + response = app.get("/api/v1/users/consents", status=200) + consents = response.json + assert len(consents) == 1 + assert set(response.json[0].keys()) == {"consent_datetime", "id", "partner_id"} + assert response.json[0]["id"] == str(consent.id) + assert response.json[0]["partner_id"] == consent.partner_id + assert response.json[0]["consent_datetime"] == "2020-12-25T17:05:55Z" + + +@pytest.mark.django_db +def test_get_consents_without_auth(app) -> None: + assert_query_fails_without_auth(app, "/api/v1/users/consents") + + +@pytest.mark.django_db +def test_post_consents( + app, + two_users: list[User], +) -> None: + login(app, two_users[0]) + + Consent.objects.create(user=two_users[0], partner_id="psl", consent_datetime=now()) + Consent.objects.create(user=two_users[1], partner_id="dinum-ami", consent_datetime=now()) + + data = {"partner_id": "dinum-ami", "consent": True} + response = app.post_json("/api/v1/users/consents", data) + assert response.json == {"message": "Consent given"} + assert Consent.objects.count() == 3 + consent = Consent.objects.latest("created_at") + assert consent.user == two_users[0] + assert consent.partner_id == "dinum-ami" + assert consent.consent_datetime is not None + + data = {"partner_id": "dinum-ami", "consent": False} + response = app.post_json("/api/v1/users/consents", data) + assert response.json == {"message": "Consent withdrawn"} + assert Consent.objects.count() == 3 + consent.refresh_from_db() + assert consent.user == two_users[0] + assert consent.partner_id == "dinum-ami" + assert consent.consent_datetime is None + + +@pytest.mark.django_db +def test_post_consents_user_consent_invalid( + app, + user: User, +) -> None: + login(app, user) + + data = {"partner_id": "dinum-ami"} + response = app.post_json("/api/v1/users/consents", data, status=400) + assert response.json == {"consent": ["Ce champ est obligatoire."]} + assert Consent.objects.count() == 0 + assert User.objects.count() == 1 + + data = {"partner_id": "dinum-ami", "consent": "invalid"} + response = app.post_json("/api/v1/users/consents", data, status=400) + assert response.json == {"consent": ["Must be a valid boolean."]} + assert Consent.objects.count() == 0 + assert User.objects.count() == 1 + + +@pytest.mark.django_db +def test_post_consents_without_auth(app, settings) -> None: + app.post("/api/v1/users/consents", status=401) + + app.post("/api/v1/users/consents", headers={"authorization": "foo"}, status=401) + + app.post("/api/v1/users/consents", headers={"authorization": "Foo bar"}, status=401) + + app.post("/api/v1/users/consents", headers={"authorization": "Basic bar"}, status=401) + + b64 = base64.b64encode(f"foo:{settings.PARTNERS_DINUM_AMI_SECRET}".encode("utf8")).decode( + "utf8" + ) + app.post("/api/v1/users/consents", headers={"authorization": f"Basic {b64}"}, status=401) + + b64 = base64.b64encode("dinum-ami:foo".encode("utf8")).decode("utf8") + app.post("/api/v1/users/consents", headers={"authorization": f"Basic {b64}"}, status=401) diff --git a/public/mobile-app/src/lib/ConnectedHomepage.svelte b/public/mobile-app/src/lib/ConnectedHomepage.svelte index 2587a563f..c9caa68c2 100644 --- a/public/mobile-app/src/lib/ConnectedHomepage.svelte +++ b/public/mobile-app/src/lib/ConnectedHomepage.svelte @@ -6,10 +6,11 @@ import AgendaItem from '$lib/components/AgendaItem.svelte'; import AutoPromoCarousel from '$lib/components/AutoPromo.svelte'; import AutoPromoItem from '$lib/components/AutoPromoItem.svelte'; - import FollowupItem from '$lib/components/FollowupItem.svelte'; + import FollowupItem from '$lib/components/followup/FollowupItem.svelte'; + import FollowupNoConsent from '$lib/components/followup/FollowupNoConsent.svelte'; import AgendaItemModal from '$lib/components/modal/AgendaItemModal.svelte'; - import CenteredModal from '$lib/components/modal/CenteredModal.svelte'; import FollowupItemModal from '$lib/components/modal/FollowupItemModal.svelte'; + import { buildConsents, hasAnyConsents as hasAnyConsentsFunc } from '$lib/consents'; import type { Followup, FollowupItem as FollowupItemType } from '$lib/followup'; import { buildFollowup } from '$lib/followup'; import { @@ -29,6 +30,7 @@ let selectedAgendaItem: AgendaItemType | null = $state(null); let selectedFollowupItem: FollowupItemType | null = $state(null); let autoPromo: AutoPromo | null = $state(null); + let hasAnyConsents: boolean = $state(false); onMount(async () => { console.log('User is connected:', userStore.connected); @@ -65,6 +67,8 @@ followup = await buildFollowup(); console.log($state.snapshot(followup)); isFollowupEmpty = !followup.items.length; + await buildConsents(); + hasAnyConsents = await hasAnyConsentsFunc(); } catch (error) { console.error(error); } @@ -156,20 +160,43 @@ {/if} -
- {#if isFollowupEmpty} -
-

Mes démarches

-
-
-
-
- + {#if hasAnyConsents} +
+ {#if isFollowupEmpty} +
+

Mes démarches

+
+
+
+
+ +
+
Suivez vos démarches ici.
-
Retrouvez et suivez vos démarches ici.
-
- {:else} + {:else} +
+

Mes démarches

+ +
+
+ {#if followup && followup.items.length} + {@const firstItem = followup.items[0]} + openFollowupItemModal(firstItem)} + /> + {/if} +
+ {/if} +
+ {:else} +

Mes démarches

- {#if followup && followup.items.length} - {@const firstItem = followup.items[0]} - openFollowupItemModal(firstItem)} - /> - {/if} +
- {/if} -
+
+ {/if}
{#if selectedAgendaItem} diff --git a/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts b/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts index 8b6900c0f..a953a6c9f 100644 --- a/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts +++ b/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts @@ -5,8 +5,11 @@ import type { WS as WSType } from 'vitest-websocket-mock'; import WS from 'vitest-websocket-mock'; import * as agendaMethods from '$lib/agenda'; import { Agenda, Item } from '$lib/agenda'; +import type { APIConsents, APIConsentsItem } from '$lib/api-consents'; import * as autoPromoMethods from '$lib/auto-promo'; import { AutoPromo, AutoPromoItem } from '$lib/auto-promo'; +import * as consentsMethods from '$lib/consents'; +import { Consents } from '$lib/consents'; import * as followupMethods from '$lib/followup'; import { Followup, FollowupItem } from '$lib/followup'; import * as notificationsMethods from '$lib/notifications'; @@ -46,6 +49,7 @@ describe('/ConnectedHomepage.svelte', () => { new AutoPromo(new Agenda()) ); vi.spyOn(followupMethods, 'buildFollowup').mockResolvedValue(new Followup()); + vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(new Consents()); window.localStorage.setItem('notifications_enabled', 'false'); window.localStorage.setItem('user_data', 'fake-user-data'); @@ -345,7 +349,20 @@ describe('/ConnectedHomepage.svelte', () => { }); }); - describe('Followup block', () => { + describe('Followup block - when user has consented', () => { + beforeEach(async () => { + const apiConsentsItem: APIConsentsItem = { + partner_id: 'fake-partner-id', + consent_datetime: new Date('2026-02-22T15:55:00.000Z'), + }; + const apiConsents: APIConsents = { + consents: [apiConsentsItem], + }; + const consents: Consents = new Consents(apiConsents); + vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(consents); + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('Should display first followup found from API', async () => { // Given const followup = new Followup(); @@ -464,9 +481,7 @@ describe('/ConnectedHomepage.svelte', () => { await waitFor(() => { const followupBlock = container.querySelector('.followup-container'); expect(spy).toHaveBeenCalledTimes(1); - expect(followupBlock).toHaveTextContent( - 'Retrouvez et suivez vos démarches ici.' - ); + expect(followupBlock).toHaveTextContent('Suivez vos démarches ici.'); }); }); @@ -724,4 +739,30 @@ describe('/ConnectedHomepage.svelte', () => { }); }); }); + + describe('Followup block - when user has not consented', () => { + beforeEach(async () => { + const apiConsents: APIConsents = { + consents: [], + }; + const consents: Consents = new Consents(apiConsents); + vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(consents); + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(false); + }); + + test('should display followup no consent block', async () => { + // When + const { container } = render(ConnectedHomepage); + + // Then + await waitFor(() => { + const followupNoConsentBlock = container.querySelector( + '.followup-no-consent-container' + ); + expect(followupNoConsentBlock).toHaveTextContent( + 'Suivez vos démarches administratives au même endroit !' + ); + }); + }); + }); }); diff --git a/public/mobile-app/src/lib/api-consents.test.ts b/public/mobile-app/src/lib/api-consents.test.ts new file mode 100644 index 000000000..7a2d8dc6f --- /dev/null +++ b/public/mobile-app/src/lib/api-consents.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { waitFor } from '@testing-library/svelte'; +import { retrieveConsents, updateApiConsent } from '$lib/api-consents'; + +const apiConsents = { + consents: [ + { + partner_id: 'dinum-ami', + consent_datetime: '2026-01-23T15:50:00Z', + }, + { + partner_id: 'dinum-dn', + consent_datetime: '2026-01-22T14:55:00Z', + }, + ], +}; + +describe('/api-consents', () => { + afterEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + }); + + describe('retrieveConsents', () => { + test('should get consents from API', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify(apiConsents.consents), { status: 200 }) + ); + + // When + const result = await retrieveConsents(); + + // Then + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents'); + expect(result.consents.length).toEqual(2); + expect(result.consents[0].partner_id).toEqual(apiConsents.consents[0].partner_id); + expect(result.consents[0].consent_datetime).toEqual( + apiConsents.consents[0].consent_datetime + ); + expect(result.consents[1].partner_id).toEqual(apiConsents.consents[1].partner_id); + expect(result.consents[1].consent_datetime).toEqual( + apiConsents.consents[1].consent_datetime + ); + }); + test('should store consents to localStorage', async () => { + // Given + localStorage.clear(); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(apiConsents.consents), { status: 200 }) + ); + + // When + await retrieveConsents(); + + // Then + const result = JSON.parse(localStorage.getItem('consents') || '[]'); + expect(result.consents.length).toEqual(2); + expect(result.consents[0].partner_id).toEqual(apiConsents.consents[0].partner_id); + expect(result.consents[0].consent_datetime).toEqual( + apiConsents.consents[0].consent_datetime + ); + expect(result.consents[1].partner_id).toEqual(apiConsents.consents[1].partner_id); + expect(result.consents[1].consent_datetime).toEqual( + apiConsents.consents[1].consent_datetime + ); + }); + + test('should get consents items from localStorage - when status code is not 200', async () => { + // Given + localStorage.clear(); + localStorage.setItem('consents', JSON.stringify(apiConsents)); + + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('error', { status: 400 })); + + // When + const result = await retrieveConsents(); + + // Then + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents'); + expect(result.consents.length).toEqual(2); + expect(result.consents[0].partner_id).toEqual(apiConsents.consents[0].partner_id); + expect(result.consents[0].consent_datetime).toEqual( + apiConsents.consents[0].consent_datetime + ); + expect(result.consents[1].partner_id).toEqual(apiConsents.consents[1].partner_id); + expect(result.consents[1].consent_datetime).toEqual( + apiConsents.consents[1].consent_datetime + ); + }); + test('should get consents with no item when localStorage has no key - when status code is not 200', async () => { + // Given + localStorage.clear(); + + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('error', { status: 400 })); + + // When + const result = await retrieveConsents(); + + // Then + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents'); + expect(result.consents.length).toEqual(0); + }); + test('should get consents items from localStorage - when fetch fails', async () => { + // Given + localStorage.clear(); + localStorage.setItem('consents', JSON.stringify(apiConsents)); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Fetch failed')); + + // When + const result = await retrieveConsents(); + + // Then + console.log(result); + expect(result.consents.length).toEqual(2); + expect(result.consents[0].partner_id).toEqual(apiConsents.consents[0].partner_id); + expect(result.consents[0].consent_datetime).toEqual( + apiConsents.consents[0].consent_datetime + ); + expect(result.consents[1].partner_id).toEqual(apiConsents.consents[1].partner_id); + expect(result.consents[1].consent_datetime).toEqual( + apiConsents.consents[1].consent_datetime + ); + }); + test('should get consents with no item when localStorage has no key - when fetch fails', async () => { + // Given + localStorage.clear(); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Fetch failed')); + + // When + const result = await retrieveConsents(); + + // Then + await waitFor(() => { + expect(result.consents.length).toEqual(0); + }); + }); + }); + + describe('updateApiConsent', () => { + test('should return true', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + // When + const result = await updateApiConsent('dinum-ami', true); + + // Then + expect(result).toEqual(true); + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents', { + body: '{"partner_id":"dinum-ami","consent":true}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + }); + test('should return false: 400 error', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 400 })); + + // When + const result = await updateApiConsent('dinum-ami', true); + + // Then + expect(result).toEqual(false); + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents', { + body: '{"partner_id":"dinum-ami","consent":true}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + }); + test('should return false: 500 error', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 500 })); + + // When + const result = await updateApiConsent('dinum-ami', true); + + // Then + expect(result).toEqual(false); + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents', { + body: '{"partner_id":"dinum-ami","consent":true}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + }); + }); +}); diff --git a/public/mobile-app/src/lib/api-consents.ts b/public/mobile-app/src/lib/api-consents.ts new file mode 100644 index 000000000..3c7e05569 --- /dev/null +++ b/public/mobile-app/src/lib/api-consents.ts @@ -0,0 +1,49 @@ +import { apiFetch } from '$lib/auth'; + +export type APIConsentsItem = { + partner_id: string; + consent_datetime: Date | null; +}; + +export type APIConsents = { + consents: APIConsentsItem[]; +}; + +export const retrieveConsents = async (): Promise => { + let apiConsents = { + consents: [] as APIConsentsItem[], + } as APIConsents; + + try { + const response = await apiFetch('/api/v1/users/consents'); + if (response.status === 200) { + apiConsents.consents = await response.json(); + localStorage.setItem('consents', JSON.stringify(apiConsents)); + } + } catch (error) { + console.error(error); + } + apiConsents = JSON.parse(localStorage.getItem('consents') || '{"consents":[]}'); + + return apiConsents; +}; + +export const updateApiConsent = async (partnerId: string, checked: boolean) => { + const payload = { + partner_id: partnerId, + consent: checked, + }; + try { + const response = await apiFetch(`/api/v1/users/consents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (response.status === 200) { + return true; + } + } catch (error) { + console.error(error); + } + return false; +}; diff --git a/public/mobile-app/src/lib/components/Followup.svelte b/public/mobile-app/src/lib/components/Followup.svelte deleted file mode 100644 index f5a7c1a83..000000000 --- a/public/mobile-app/src/lib/components/Followup.svelte +++ /dev/null @@ -1,179 +0,0 @@ - - -{#if archived} - -{/if} - -
- {#if !archived} -
-

Mes démarches

-
- -
- {#if menuOpened} -
    -
  • - - -
  • -
- {/if} -
- {/if} - -
- {#if archived && followup && followup.archived_items.length} - {#each followup.archived_items as item} - openFollowupItemModal(item)} /> - {/each} - {:else if !archived && followup && followup.items.length} - {#each followup.items as item} - openFollowupItemModal(item)} /> - {/each} - {:else} -
-
- -
-
- Après avoir effectué vos démarches, vous pouvez les suivre en temps réel - depuis l’application. -
-
- {/if} -
-
- -{#if selectedFollowupItem} - -{/if} - - diff --git a/public/mobile-app/src/lib/components/Toggle.svelte b/public/mobile-app/src/lib/components/Toggle.svelte index 9448d492e..4a0debdf2 100644 --- a/public/mobile-app/src/lib/components/Toggle.svelte +++ b/public/mobile-app/src/lib/components/Toggle.svelte @@ -1,4 +1,5 @@ + +{#if archived} + +{/if} + +
+ {#if !archived} +
+

Mes démarches

+ {#if hasAnyConsents} +
+ +
+ {/if} + {#if menuOpened} +
    +
  • + + +
  • +
+ {/if} +
+ {/if} + +
+ {#if hasAnyConsents} + {#if archived && followup && followup.archived_items.length} + {#each followup.archived_items as item} + openFollowupItemModal(item)} /> + {/each} + {:else if !archived && followup && followup.items.length} + {#each followup.items as item} + openFollowupItemModal(item)} /> + {/each} + {/if} +
+
+

+ +

+
+
+

Consultez votre compte

+
    + + + + +
+

Vérifiez que vous suivez bien toutes vos démarches

+ +
+
+
+
+ {:else} + + {/if} +
+
+ +{#if selectedFollowupItem} + +{/if} + + diff --git a/public/mobile-app/src/lib/components/Followup.svelte.test.ts b/public/mobile-app/src/lib/components/followup/Followup.svelte.test.ts similarity index 85% rename from public/mobile-app/src/lib/components/Followup.svelte.test.ts rename to public/mobile-app/src/lib/components/followup/Followup.svelte.test.ts index 1ebeb355e..8a79fb867 100644 --- a/public/mobile-app/src/lib/components/Followup.svelte.test.ts +++ b/public/mobile-app/src/lib/components/followup/Followup.svelte.test.ts @@ -1,13 +1,18 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import * as navigationMethods from '$app/navigation'; -import FollowupComponent from '$lib/components/Followup.svelte'; +import FollowupComponent from '$lib/components/followup/Followup.svelte'; +import * as consentsMethods from '$lib/consents'; import * as followupMethods from '$lib/followup'; import { Followup, FollowupItem } from '$lib/followup'; -import { toastStore } from '$lib/state/toast.svelte'; +import { toastStore } from '$lib/state/toast.svelte.js'; describe('/Followup.svelte', () => { describe('Current items', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('Should display followup from API', async () => { // Given const followup = new Followup(); @@ -112,6 +117,9 @@ describe('/Followup.svelte', () => { expect(screen.getByTestId('followup')).not.toHaveTextContent( 'Après avoir effectué vos démarches, vous pouvez les suivre en temps réel depuis l’application.' ); + const accordionButton: HTMLButtonElement = + screen.getByTestId('accordion-button'); + expect(accordionButton).toHaveAttribute('aria-expanded', 'false'); }); }); test('Should display empty followup', async () => { @@ -149,12 +157,19 @@ describe('/Followup.svelte', () => { await waitFor(() => { expect(spy).toHaveBeenCalledTimes(1); expect(screen.getByTestId('followup')).toHaveTextContent( - 'Après avoir effectué vos démarches, vous pouvez les suivre en temps réel depuis l’application.' + 'Votre démarche n’apparaît pas ? Consultez votre compte Service Public CNMSS Démarche numérique Dossier facile Vérifiez que vous suivez bien toutes vos démarches' ); + const accordionButton: HTMLButtonElement = + screen.getByTestId('accordion-button'); + expect(accordionButton).toHaveAttribute('aria-expanded', 'true'); }); }); }); describe('Archived items', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('Should display followup from API', async () => { // Given const followup = new Followup(); @@ -259,6 +274,9 @@ describe('/Followup.svelte', () => { expect(screen.getByTestId('followup')).not.toHaveTextContent( 'Après avoir effectué vos démarches, vous pouvez les suivre en temps réel depuis l’application.' ); + const accordionButton: HTMLButtonElement = + screen.getByTestId('accordion-button'); + expect(accordionButton).toHaveAttribute('aria-expanded', 'false'); }); }); test('Should display empty followup', async () => { @@ -296,12 +314,19 @@ describe('/Followup.svelte', () => { await waitFor(() => { expect(spy).toHaveBeenCalledTimes(1); expect(screen.getByTestId('followup')).toHaveTextContent( - 'Après avoir effectué vos démarches, vous pouvez les suivre en temps réel depuis l’application.' + 'Votre démarche n’apparaît pas ? Consultez votre compte Service Public CNMSS Démarche numérique Dossier facile Vérifiez que vous suivez bien toutes vos démarches' ); + const accordionButton: HTMLButtonElement = + screen.getByTestId('accordion-button'); + expect(accordionButton).toHaveAttribute('aria-expanded', 'false'); }); }); }); describe('More menu', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('No "more" button for archived followup items', async () => { // Given const followup = new Followup(); @@ -358,8 +383,30 @@ describe('/Followup.svelte', () => { expect(spy).toHaveBeenCalledWith('/#/followup/archived'); }); }); + + test('No "more" button when user has not consented', async () => { + // Given + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(false); + + const followup = new Followup(); + vi.spyOn(followup, 'items', 'get').mockReturnValue([]); + vi.spyOn(followupMethods, 'buildFollowup').mockResolvedValue(followup); + + // When + render(FollowupComponent, { archived: true }); + + // Then + await waitFor(async () => { + const button = screen.queryByTestId('more-button'); + expect(button).toBeNull(); + }); + }); }); describe('Followup item modal', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('No more icon for archived followup item', async () => { const followup = new Followup(); vi.spyOn(followup, 'archived_items', 'get').mockReturnValue([ @@ -663,4 +710,32 @@ describe('/Followup.svelte', () => { }); }); }); + + describe('No consent block', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + + test('No more icon for archived followup item', async () => { + // Given + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(false); + + const followup = new Followup(); + vi.spyOn(followup, 'items', 'get').mockReturnValue([]); + vi.spyOn(followupMethods, 'buildFollowup').mockResolvedValue(followup); + + // When + const { container } = render(FollowupComponent); + + // Then + await waitFor(() => { + const followupNoConsentBlock = container.querySelector( + '.followup-no-consent-container' + ); + expect(followupNoConsentBlock).toHaveTextContent( + 'Suivez vos démarches administratives au même endroit !' + ); + }); + }); + }); }); diff --git a/public/mobile-app/src/lib/components/followup/FollowupInformation.svelte b/public/mobile-app/src/lib/components/followup/FollowupInformation.svelte new file mode 100644 index 000000000..07c836be4 --- /dev/null +++ b/public/mobile-app/src/lib/components/followup/FollowupInformation.svelte @@ -0,0 +1,56 @@ +
+

+ +

+
+
    +
  • S’inscrire à l’opération tranquillité vacances
  • +
  • Déclaration de changement d’adresse
  • +
  • Demande de recensement citoyen obligatoire
  • +
  • + Demande d’acte de naissance, de mariage, de décès : copie intégrale ou + extrait (survenu à l'étranger) +
    + Service gratuit +
  • +
  • Déclaration de changement de nom d’usage (nom de l’époux ou de l’épouse)
  • +
  • Déposer une pré-demande de Pacs
  • +
  • Toutes les démarches Démarche numérique
  • +
  • + Et bientôt : Demande d’acte de naissance, de mariage, de décès : copie + intégrale ou extrait (survenu en France) Service gratuit, Demander en ligne un + certificat de non-Pacs pour le partenaire étranger né à l’étranger, + Renouvellement de l’inscription consulaire, Inscription consulaire, + Actualisation, Radiation, Demander ses certificats (inscription ou radiation) et + sa carte consulaire (Registre des français à l’étranger), Demande de correction + d’état civil auprès de l’Insee, Demande de publication au Journal officiel + d’annonce préalable de changement de nom pour motif légitime, Renouvellement à + distance de passeport en Australie, au Canada, en Espagne et au Portugal, + Rendez-vous commissariat +
  • +
+
+
+ + diff --git a/public/mobile-app/src/lib/components/FollowupItem.svelte b/public/mobile-app/src/lib/components/followup/FollowupItem.svelte similarity index 94% rename from public/mobile-app/src/lib/components/FollowupItem.svelte rename to public/mobile-app/src/lib/components/followup/FollowupItem.svelte index 93df53ee3..fd63e383f 100644 --- a/public/mobile-app/src/lib/components/FollowupItem.svelte +++ b/public/mobile-app/src/lib/components/followup/FollowupItem.svelte @@ -118,8 +118,8 @@ bottom: 1rem; right: 0.5rem; --icon-size: 1.25rem; - -webkit-mask-image: url("@gouvfr/dsfr/dist/icons/arrows/arrow-right-s-line.svg"); - mask-image: url("@gouvfr/dsfr/dist/icons/arrows/arrow-right-s-line.svg"); + -webkit-mask-image: url("../../../../node_modules/@gouvfr/dsfr/dist/icons/arrows/arrow-right-s-line.svg"); + mask-image: url("../../../../node_modules/@gouvfr/dsfr/dist/icons/arrows/arrow-right-s-line.svg"); } } } diff --git a/public/mobile-app/src/lib/components/FollowupItem.svelte.test.ts b/public/mobile-app/src/lib/components/followup/FollowupItem.svelte.test.ts similarity index 100% rename from public/mobile-app/src/lib/components/FollowupItem.svelte.test.ts rename to public/mobile-app/src/lib/components/followup/FollowupItem.svelte.test.ts diff --git a/public/mobile-app/src/lib/components/FollowupItemDetail.svelte b/public/mobile-app/src/lib/components/followup/FollowupItemDetail.svelte similarity index 92% rename from public/mobile-app/src/lib/components/FollowupItemDetail.svelte rename to public/mobile-app/src/lib/components/followup/FollowupItemDetail.svelte index ec92a9f48..92a1dd308 100644 --- a/public/mobile-app/src/lib/components/FollowupItemDetail.svelte +++ b/public/mobile-app/src/lib/components/followup/FollowupItemDetail.svelte @@ -1,6 +1,6 @@ + + + + diff --git a/public/mobile-app/src/lib/components/FollowupParentItemDetail.svelte b/public/mobile-app/src/lib/components/followup/FollowupParentItemDetail.svelte similarity index 90% rename from public/mobile-app/src/lib/components/FollowupParentItemDetail.svelte rename to public/mobile-app/src/lib/components/followup/FollowupParentItemDetail.svelte index c13184c3b..bde8f145f 100644 --- a/public/mobile-app/src/lib/components/FollowupParentItemDetail.svelte +++ b/public/mobile-app/src/lib/components/followup/FollowupParentItemDetail.svelte @@ -1,6 +1,6 @@ + + + +
+ + + + + +
diff --git a/public/mobile-app/src/routes/preferences/consents/page.svelte.test.ts b/public/mobile-app/src/routes/preferences/consents/page.svelte.test.ts new file mode 100644 index 000000000..8bfb0539c --- /dev/null +++ b/public/mobile-app/src/routes/preferences/consents/page.svelte.test.ts @@ -0,0 +1,84 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, test, vi } from 'vitest'; +import * as navigationMethods from '$app/navigation'; +import type { APIConsents } from '$lib/api-consents'; +import * as consentsMethods from '$lib/consents'; +import { Consents } from '$lib/consents'; +import { userStore } from '$lib/state/User.svelte'; +import { expectBackButtonPresent, mockUserInfo } from '$tests/utils'; +import Page from './+page.svelte'; + +describe('/+page.svelte', () => { + test('user has to be connected', async () => { + // Given + const spy = vi.spyOn(navigationMethods, 'goto').mockResolvedValue(); + + // When + render(Page); + + // Then + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('/#/login'); + }); + }); + + test('should enable consent when user toggles on', async () => { + // Given + await userStore.login(mockUserInfo); + + const apiConsents: APIConsents = { consents: [] }; + const consents: Consents = new Consents(apiConsents); + vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(consents); + + const spy = vi.spyOn(consentsMethods, 'updateConsent'); + render(Page); + + // When + const toggleInput: HTMLInputElement = screen.getByTestId('psl'); + expect(toggleInput.checked).toBeFalsy(); + await fireEvent.click(toggleInput); + + // Then + await waitFor(async () => { + expect(spy).toHaveBeenCalledWith('psl', true); + }); + }); + + test('should disable consent when user toggles off', async () => { + // Given + await userStore.login(mockUserInfo); + + const spy = vi.spyOn(consentsMethods, 'updateConsent'); + render(Page); + const toggleInput: HTMLInputElement = screen.getByTestId('psl'); + await fireEvent.click(toggleInput); // set toggle to checked + + // When + expect(toggleInput.checked).toBeTruthy(); + await fireEvent.click(toggleInput); + + // Then + await waitFor(async () => { + expect(spy).toHaveBeenCalledWith('psl', false); + }); + }); + + test('should import NavWithBackButton component', async () => { + // When + render(Page); + const backButton = screen.getByTestId('back-button'); + + // Then + expect(backButton).toBeInTheDocument(); + expect(screen.getByText('Suivi des démarches')).toBeInTheDocument(); + }); + + test('should render a Back button', async () => { + // When + render(Page); + + // Then + expectBackButtonPresent(screen); + }); +});