From 4a040706a8bea52d37185a89ed99548a70293922 Mon Sep 17 00:00:00 2001 From: fabriziofff Date: Tue, 1 Jun 2021 09:18:06 +0200 Subject: [PATCH] chore(EU Covid Certificate): [IAGP-5] Types, actions & store for EU Covid Certificate (#3076) * add placeholders for foldering * wip datamodel * add actions & store * add networking handlers * renaming * fix wrong import * add test * revert * add test * add selector * update certificate types * change selector * add generator * Update ts/features/euCovidCert/saga/index.ts Co-authored-by: Matteo Boschi --- codecov.yml | 4 +- ts/features/common/store/reducers/index.ts | 14 +++ ts/features/euCovidCert/analytics/placeholder | 0 ts/features/euCovidCert/api/placeholder | 0 .../euCovidCert/navigation/placeholder | 0 ts/features/euCovidCert/saga/index.ts | 14 +++ .../networking/handleGetEuCovidCertificate.ts | 20 ++++ .../saga/orchestration/placeholder | 0 ts/features/euCovidCert/screens/placeholder | 0 .../euCovidCert/store/actions/index.ts | 22 ++++ .../reducers/__test__/byAuthCode.test.ts | 107 ++++++++++++++++++ .../euCovidCert/store/reducers/byAuthCode.ts | 51 +++++++++ .../euCovidCert/store/reducers/index.ts | 15 +++ .../euCovidCert/types/EUCovidCertificate.ts | 44 +++++++ .../types/EUCovidCertificateResponse.ts | 65 +++++++++++ ts/sagas/startup.ts | 11 +- ts/store/actions/types.ts | 2 + ts/store/reducers/index.ts | 2 + ts/store/reducers/types.ts | 2 + 19 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 ts/features/common/store/reducers/index.ts create mode 100644 ts/features/euCovidCert/analytics/placeholder create mode 100644 ts/features/euCovidCert/api/placeholder create mode 100644 ts/features/euCovidCert/navigation/placeholder create mode 100644 ts/features/euCovidCert/saga/index.ts create mode 100644 ts/features/euCovidCert/saga/networking/handleGetEuCovidCertificate.ts create mode 100644 ts/features/euCovidCert/saga/orchestration/placeholder create mode 100644 ts/features/euCovidCert/screens/placeholder create mode 100644 ts/features/euCovidCert/store/actions/index.ts create mode 100644 ts/features/euCovidCert/store/reducers/__test__/byAuthCode.test.ts create mode 100644 ts/features/euCovidCert/store/reducers/byAuthCode.ts create mode 100644 ts/features/euCovidCert/store/reducers/index.ts create mode 100644 ts/features/euCovidCert/types/EUCovidCertificate.ts create mode 100644 ts/features/euCovidCert/types/EUCovidCertificateResponse.ts diff --git a/codecov.yml b/codecov.yml index 7083fc34952..ab0640edeaf 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,5 +1,5 @@ codecov: - require_ci_to_pass: yes + require_ci_to_pass: true coverage: precision: 2 @@ -24,7 +24,7 @@ parsers: comment: layout: "reach,diff,flags,files,footer" behavior: default - require_changes: no + require_changes: false github_checks: annotations: false diff --git a/ts/features/common/store/reducers/index.ts b/ts/features/common/store/reducers/index.ts new file mode 100644 index 00000000000..1b5417c3330 --- /dev/null +++ b/ts/features/common/store/reducers/index.ts @@ -0,0 +1,14 @@ +import { combineReducers } from "redux"; +import { Action } from "../../../../store/actions/types"; +import { + euCovidCertReducer, + EuCovidCertState +} from "../../../euCovidCert/store/reducers"; + +export type FeaturesState = { + euCovidCert: EuCovidCertState; +}; + +export const featuresReducer = combineReducers({ + euCovidCert: euCovidCertReducer +}); diff --git a/ts/features/euCovidCert/analytics/placeholder b/ts/features/euCovidCert/analytics/placeholder new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ts/features/euCovidCert/api/placeholder b/ts/features/euCovidCert/api/placeholder new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ts/features/euCovidCert/navigation/placeholder b/ts/features/euCovidCert/navigation/placeholder new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ts/features/euCovidCert/saga/index.ts b/ts/features/euCovidCert/saga/index.ts new file mode 100644 index 00000000000..81be9f37009 --- /dev/null +++ b/ts/features/euCovidCert/saga/index.ts @@ -0,0 +1,14 @@ +import { SagaIterator } from "redux-saga"; +import { takeLatest } from "redux-saga/effects"; +import { euCovidCertificateGet } from "../store/actions"; +import { handleGetEuCovidCertificate } from "./networking/handleGetEuCovidCertificate"; + +/** + * Handle the EU Covid Certificate requests + * @param _ + */ +export function* watchEUCovidCertificateSaga(_: string): SagaIterator { + // const euCovidCertClient = BackendBpdClient(bpdApiUrlPrefix, bpdBearerToken); + + yield takeLatest(euCovidCertificateGet.request, handleGetEuCovidCertificate); +} diff --git a/ts/features/euCovidCert/saga/networking/handleGetEuCovidCertificate.ts b/ts/features/euCovidCert/saga/networking/handleGetEuCovidCertificate.ts new file mode 100644 index 00000000000..02515bb026e --- /dev/null +++ b/ts/features/euCovidCert/saga/networking/handleGetEuCovidCertificate.ts @@ -0,0 +1,20 @@ +import { delay, Effect, put } from "redux-saga/effects"; +import { ActionType } from "typesafe-actions"; +import { euCovidCertificateGet } from "../../store/actions"; + +/** + * Handle the remote call to retrieve the certificate data + * @param action + */ +export function* handleGetEuCovidCertificate( + action: ActionType +): Generator { + // TODO: add networking logic + yield delay(500); + yield put( + euCovidCertificateGet.success({ + authCode: action.payload, + kind: "notFound" + }) + ); +} diff --git a/ts/features/euCovidCert/saga/orchestration/placeholder b/ts/features/euCovidCert/saga/orchestration/placeholder new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ts/features/euCovidCert/screens/placeholder b/ts/features/euCovidCert/screens/placeholder new file mode 100644 index 00000000000..e69de29bb2d diff --git a/ts/features/euCovidCert/store/actions/index.ts b/ts/features/euCovidCert/store/actions/index.ts new file mode 100644 index 00000000000..b1616781fc7 --- /dev/null +++ b/ts/features/euCovidCert/store/actions/index.ts @@ -0,0 +1,22 @@ +import { ActionType, createAsyncAction } from "typesafe-actions"; +import { GenericError } from "../../../../utils/errors"; +import { EUCovidCertificateAuthCode } from "../../types/EUCovidCertificate"; +import { + EUCovidCertificateResponse, + WithEUCovidCertAuthCode +} from "../../types/EUCovidCertificateResponse"; + +/** + * The user requests the EU Covid certificate, starting from the auth_code + */ +export const euCovidCertificateGet = createAsyncAction( + "EUCOVIDCERT_REQUEST", + "EUCOVIDCERT_SUCCESS", + "EUCOVIDCERT_FAILURE" +)< + EUCovidCertificateAuthCode, + EUCovidCertificateResponse, + WithEUCovidCertAuthCode +>(); + +export type EuCovidCertActions = ActionType; diff --git a/ts/features/euCovidCert/store/reducers/__test__/byAuthCode.test.ts b/ts/features/euCovidCert/store/reducers/__test__/byAuthCode.test.ts new file mode 100644 index 00000000000..0418683dfc2 --- /dev/null +++ b/ts/features/euCovidCert/store/reducers/__test__/byAuthCode.test.ts @@ -0,0 +1,107 @@ +import { pot } from "italia-ts-commons"; +import { createStore } from "redux"; +import { applicationChangeState } from "../../../../../store/actions/application"; +import { appReducer } from "../../../../../store/reducers"; +import { GenericError } from "../../../../../utils/errors"; +import { EUCovidCertificateAuthCode } from "../../../types/EUCovidCertificate"; +import { + EUCovidCertificateResponse, + WithEUCovidCertAuthCode +} from "../../../types/EUCovidCertificateResponse"; +import { euCovidCertificateGet } from "../../actions"; +import { euCovidCertificateFromAuthCodeSelector } from "../byAuthCode"; + +const authCode = "authCode1" as EUCovidCertificateAuthCode; +const mockResponseSuccess: EUCovidCertificateResponse = { + authCode, + kind: "notFound" +}; + +const mockFailure: WithEUCovidCertAuthCode = { + authCode, + kind: "generic", + value: new Error("A generic error") +}; + +describe("Test byAuthCode reducer & selector behaviour", () => { + it("Initial state should be pot.none", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + expect(globalState.features.euCovidCert.byAuthCode).toStrictEqual({}); + expect( + euCovidCertificateFromAuthCodeSelector(globalState, authCode) + ).toStrictEqual(pot.none); + }); + it("Should be pot.noneLoading after the first loading action dispatched", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + store.dispatch(euCovidCertificateGet.request(authCode)); + + const byAuthCode = store.getState().features.euCovidCert.byAuthCode; + + expect(byAuthCode[authCode]).toStrictEqual(pot.noneLoading); + expect( + euCovidCertificateFromAuthCodeSelector(store.getState(), authCode) + ).toStrictEqual(pot.noneLoading); + }); + it("Should be pot.some with the response, after the success action", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + store.dispatch(euCovidCertificateGet.request(authCode)); + store.dispatch(euCovidCertificateGet.success(mockResponseSuccess)); + + expect( + store.getState().features.euCovidCert.byAuthCode[authCode] + ).toStrictEqual(pot.some(mockResponseSuccess)); + expect( + euCovidCertificateFromAuthCodeSelector(store.getState(), authCode) + ).toStrictEqual(pot.some(mockResponseSuccess)); + + store.dispatch(euCovidCertificateGet.request(authCode)); + + expect( + store.getState().features.euCovidCert.byAuthCode[authCode] + ).toStrictEqual(pot.someLoading(mockResponseSuccess)); + expect( + euCovidCertificateFromAuthCodeSelector(store.getState(), authCode) + ).toStrictEqual(pot.someLoading(mockResponseSuccess)); + + store.dispatch(euCovidCertificateGet.failure(mockFailure)); + + expect( + store.getState().features.euCovidCert.byAuthCode[authCode] + ).toStrictEqual(pot.someError(mockResponseSuccess, mockFailure.value)); + expect( + euCovidCertificateFromAuthCodeSelector(store.getState(), authCode) + ).toStrictEqual(pot.someError(mockResponseSuccess, mockFailure.value)); + }); + it("Should be pot.noneError after the failure action", () => { + const globalState = appReducer(undefined, applicationChangeState("active")); + const store = createStore(appReducer, globalState as any); + store.dispatch(euCovidCertificateGet.request(authCode)); + store.dispatch(euCovidCertificateGet.failure(mockFailure)); + + expect( + store.getState().features.euCovidCert.byAuthCode[authCode] + ).toStrictEqual(pot.noneError(mockFailure.value)); + expect( + euCovidCertificateFromAuthCodeSelector(store.getState(), authCode) + ).toStrictEqual(pot.noneError(mockFailure.value)); + + store.dispatch(euCovidCertificateGet.request(authCode)); + expect( + store.getState().features.euCovidCert.byAuthCode[authCode] + ).toStrictEqual(pot.noneLoading); + expect( + euCovidCertificateFromAuthCodeSelector(store.getState(), authCode) + ).toStrictEqual(pot.noneLoading); + + store.dispatch(euCovidCertificateGet.success(mockResponseSuccess)); + + expect( + store.getState().features.euCovidCert.byAuthCode[authCode] + ).toStrictEqual(pot.some(mockResponseSuccess)); + expect( + euCovidCertificateFromAuthCodeSelector(store.getState(), authCode) + ).toStrictEqual(pot.some(mockResponseSuccess)); + }); +}); diff --git a/ts/features/euCovidCert/store/reducers/byAuthCode.ts b/ts/features/euCovidCert/store/reducers/byAuthCode.ts new file mode 100644 index 00000000000..abc8f453a6a --- /dev/null +++ b/ts/features/euCovidCert/store/reducers/byAuthCode.ts @@ -0,0 +1,51 @@ +import { pot } from "italia-ts-commons"; +import { createSelector } from "reselect"; +import { getType } from "typesafe-actions"; +import { Action } from "../../../../store/actions/types"; +import { IndexedById } from "../../../../store/helpers/indexer"; +import { + toError, + toLoading, + toSome +} from "../../../../store/reducers/IndexedByIdPot"; +import { GlobalState } from "../../../../store/reducers/types"; +import { EUCovidCertificateAuthCode } from "../../types/EUCovidCertificate"; +import { EUCovidCertificateResponse } from "../../types/EUCovidCertificateResponse"; +import { euCovidCertificateGet } from "../actions"; + +type EuCovidCertByIdState = IndexedById< + pot.Pot +>; + +/** + * Store the EU Certificate response status based on the AuthCode used to issue the request + * @param state + * @param action + */ +export const euCovidCertByAuthCodeReducer = ( + state: EuCovidCertByIdState = {}, + action: Action +): EuCovidCertByIdState => { + switch (action.type) { + case getType(euCovidCertificateGet.request): + return toLoading(action.payload, state); + case getType(euCovidCertificateGet.success): + return toSome(action.payload.authCode, state, action.payload); + case getType(euCovidCertificateGet.failure): + return toError(action.payload.authCode, state, action.payload.value); + } + + return state; +}; + +/** + * From authCode to EUCovidCertificateResponse + */ +export const euCovidCertificateFromAuthCodeSelector = createSelector( + [ + (state: GlobalState) => state.features.euCovidCert.byAuthCode, + (_: GlobalState, authCode: EUCovidCertificateAuthCode) => authCode + ], + (byAuthCode, authCode): pot.Pot => + byAuthCode[authCode] ?? pot.none +); diff --git a/ts/features/euCovidCert/store/reducers/index.ts b/ts/features/euCovidCert/store/reducers/index.ts new file mode 100644 index 00000000000..c2148c19f27 --- /dev/null +++ b/ts/features/euCovidCert/store/reducers/index.ts @@ -0,0 +1,15 @@ +import { pot } from "italia-ts-commons"; +import { combineReducers } from "redux"; +import { Action } from "../../../../store/actions/types"; +import { IndexedById } from "../../../../store/helpers/indexer"; +import { EUCovidCertificateResponse } from "../../types/EUCovidCertificateResponse"; +import { euCovidCertByAuthCodeReducer } from "./byAuthCode"; + +export type EuCovidCertState = { + byAuthCode: IndexedById>; +}; + +export const euCovidCertReducer = combineReducers({ + // save, using the AuthCode as key the the pot.Pot response + byAuthCode: euCovidCertByAuthCodeReducer +}); diff --git a/ts/features/euCovidCert/types/EUCovidCertificate.ts b/ts/features/euCovidCert/types/EUCovidCertificate.ts new file mode 100644 index 00000000000..bc17e54e98b --- /dev/null +++ b/ts/features/euCovidCert/types/EUCovidCertificate.ts @@ -0,0 +1,44 @@ +import { IUnitTag } from "italia-ts-commons/lib/units"; + +/** + * The unique ID of a EU Covid Certificate + */ +type EUCovidCertificateId = string & IUnitTag<"EUCovidCertificateId">; + +/** + * The auth code used to request the EU Covid Certificate, received via message + */ +export type EUCovidCertificateAuthCode = string & + IUnitTag<"EUCovidCertificateAuthCode">; + +type WithEUCovidCertificateId = T & { + id: EUCovidCertificateId; +}; + +type QRCode = { + mimeType: "image/png" | "image/svg"; + content: string; +}; + +type ValidCertificate = { + kind: "valid"; + qrCode: QRCode; + markdownPreview: string; + markdownDetails: string; +}; + +type ExpiredCertificate = { + kind: "expired"; +}; + +type RevokedCertificate = { + kind: "revoked"; + revokedOn: Date; +}; + +/** + * This type represents the EU Covid Certificate with the different states & data + */ +export type EUCovidCertificate = WithEUCovidCertificateId< + ValidCertificate | ExpiredCertificate | RevokedCertificate +>; diff --git a/ts/features/euCovidCert/types/EUCovidCertificateResponse.ts b/ts/features/euCovidCert/types/EUCovidCertificateResponse.ts new file mode 100644 index 00000000000..67666933dd8 --- /dev/null +++ b/ts/features/euCovidCert/types/EUCovidCertificateResponse.ts @@ -0,0 +1,65 @@ +import { + EUCovidCertificate, + EUCovidCertificateAuthCode +} from "./EUCovidCertificate"; + +type EUCovidCertificateResponseSuccess = { + kind: "success"; + value: EUCovidCertificate; +}; + +/** + * The required certificated is not found (403) + */ +type EUCovidCertificateResponseNotFound = { + kind: "notFound"; +}; + +/** + * The required certificate have a wrong format (400) + */ +type EUCovidCertificateResponseWrongFormat = { + kind: "wrongFormat"; +}; + +/** + * A generic error response was received (500, others, generic error) + */ +type EUCovidCertificateResponseGenericError = { + kind: "genericError"; +}; + +/** + * The EU Covid certificate service is not operational (410) + */ +type EUCovidCertificateResponseNotOperational = { + kind: "notOperational"; +}; + +/** + * The EU Covid certificate service is not operational (504) + */ +type EUCovidCertificateResponseTemporarilyNotAvailable = { + kind: "temporarilyNotAvailable"; +}; + +type EUCovidCertificateResponseFailure = + | EUCovidCertificateResponseNotFound + | EUCovidCertificateResponseWrongFormat + | EUCovidCertificateResponseGenericError + | EUCovidCertificateResponseNotOperational + | EUCovidCertificateResponseTemporarilyNotAvailable; + +/** + * Bind the response with the initial authCode + */ +export type WithEUCovidCertAuthCode = T & { + authCode: EUCovidCertificateAuthCode; +}; + +/** + * This type represents all the possible remote responses + */ +export type EUCovidCertificateResponse = WithEUCovidCertAuthCode< + EUCovidCertificateResponseSuccess | EUCovidCertificateResponseFailure +>; diff --git a/ts/sagas/startup.ts b/ts/sagas/startup.ts index f824b90a7f5..9b712ad0b8b 100644 --- a/ts/sagas/startup.ts +++ b/ts/sagas/startup.ts @@ -25,10 +25,12 @@ import { bonusVacanzeEnabled, bpdEnabled, cgnEnabled, + euCovidCertificateEnabled, pagoPaApiUrlPrefix, pagoPaApiUrlPrefixTest } from "../config"; import { watchBonusSaga } from "../features/bonus/bonusVacanze/store/sagas/bonusSaga"; +import { watchEUCovidCertificateSaga } from "../features/euCovidCert/saga"; import AppNavigator from "../navigation/AppNavigator"; import { startApplicationInitialization } from "../store/actions/application"; import { sessionExpired } from "../store/actions/authentication"; @@ -311,15 +313,20 @@ export function* initializeApplicationSaga(): Generator { } if (bpdEnabled) { - // Start watching for actions about bonus bpd + // Start watching for bpd actions yield fork(watchBonusBpdSaga, maybeSessionInformation.value.bpdToken); } if (cgnEnabled) { - // Start watching for actions about bonus bpd + // Start watching for cgn actions yield fork(watchBonusCgnSaga, sessionToken); } + if (euCovidCertificateEnabled) { + // Start watching for EU Covid Certificate actions + yield fork(watchEUCovidCertificateSaga, sessionToken); + } + // Load the user metadata yield call(loadUserMetadata, backendClient.getUserMetadata, true); diff --git a/ts/store/actions/types.ts b/ts/store/actions/types.ts index 3c71ac0aae1..47c4b8c004f 100644 --- a/ts/store/actions/types.ts +++ b/ts/store/actions/types.ts @@ -10,6 +10,7 @@ import { import { BonusActions } from "../../features/bonus/bonusVacanze/store/actions/bonusVacanze"; import { BpdActions } from "../../features/bonus/bpd/store/actions"; +import { EuCovidCertActions } from "../../features/euCovidCert/store/actions"; import { AbiActions } from "../../features/wallet/onboarding/bancomat/store/actions"; import { BPayActions } from "../../features/wallet/onboarding/bancomatPay/store/actions"; import { CoBadgeActions } from "../../features/wallet/onboarding/cobadge/store/actions"; @@ -92,6 +93,7 @@ export type Action = | SatispayActions | CrossSessionsActions | CgnActions + | EuCovidCertActions | OutcomeCodeActions; export type Dispatch = DispatchAPI; diff --git a/ts/store/reducers/index.ts b/ts/store/reducers/index.ts index f9a70e8dafc..4acab98451b 100644 --- a/ts/store/reducers/index.ts +++ b/ts/store/reducers/index.ts @@ -7,6 +7,7 @@ import { combineReducers, Reducer } from "redux"; import { PersistConfig, persistReducer, purgeStoredState } from "redux-persist"; import { isActionOf } from "typesafe-actions"; import bonusReducer from "../../features/bonus/bonusVacanze/store/reducers"; +import { featuresReducer } from "../../features/common/store/reducers"; import { logoutFailure, logoutSuccess, @@ -100,6 +101,7 @@ export const appReducer: Reducer = combineReducers< search: searchReducer, cie: cieReducer, bonus: bonusReducer, + features: featuresReducer, internalRouteNavigation: internalRouteNavigationReducer, // // persisted state diff --git a/ts/store/reducers/types.ts b/ts/store/reducers/types.ts index 407ba9543bc..de66266cdbd 100644 --- a/ts/store/reducers/types.ts +++ b/ts/store/reducers/types.ts @@ -2,6 +2,7 @@ import { NavigationState } from "react-navigation"; import { PersistPartial } from "redux-persist"; import { BonusState } from "../../features/bonus/bonusVacanze/store/reducers"; +import { FeaturesState } from "../../features/common/store/reducers"; import { Action } from "../actions/types"; import { AppState } from "./appState"; import { PersistedAuthenticationState } from "./authentication"; @@ -65,6 +66,7 @@ export type GlobalState = Readonly<{ emailValidation: EmailValidationState; cie: CieState; bonus: BonusState; + features: FeaturesState; internalRouteNavigation: InternalRouteNavigationState; crossSessions: CrossSessionsState; }>;