diff --git a/.changeset/blue-kids-flash.md b/.changeset/blue-kids-flash.md new file mode 100644 index 000000000..3c957ae01 --- /dev/null +++ b/.changeset/blue-kids-flash.md @@ -0,0 +1,5 @@ +--- +"io-services-cms-webapp": minor +--- + +implementation of activations sync to legacy function diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml index 9c96e2eb2..be7241fa7 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -45,3 +45,4 @@ services: AZURE_COSMOS_EMULATOR_ENABLE_DATA_PERSISTENCE: 'true' # Use the "https" protocol for the emulator PROTOCOL: https + GATEWAY_PUBLIC_ENDPOINT: https://cosmosdb:8081 diff --git a/apps/io-services-cms-webapp/ActivationSyncToLegacy/function.json b/apps/io-services-cms-webapp/ActivationSyncToLegacy/function.json new file mode 100644 index 000000000..841ecb74c --- /dev/null +++ b/apps/io-services-cms-webapp/ActivationSyncToLegacy/function.json @@ -0,0 +1,13 @@ +{ + "bindings": [ + { + "name": "activation", + "type": "blobTrigger", + "direction": "in", + "path": "%ACTIVATIONS_CONTAINER_NAME%/{name}", + "connection": "INTERNAL_STORAGE_CONNECTION_STRING" + } + ], + "scriptFile": "../dist/main.js", + "entryPoint": "activationsSyncToLegacyEntryPoint" +} diff --git a/apps/io-services-cms-webapp/src/main.ts b/apps/io-services-cms-webapp/src/main.ts index 21f9443d5..5c671080b 100644 --- a/apps/io-services-cms-webapp/src/main.ts +++ b/apps/io-services-cms-webapp/src/main.ts @@ -12,6 +12,10 @@ import { ServicePublication, stores, } from "@io-services-cms/models"; +import { + ACTIVATION_COLLECTION_NAME, + ActivationModel, +} from "@pagopa/io-functions-commons/dist/src/models/activation"; import { SERVICE_COLLECTION_NAME, ServiceModel, @@ -77,6 +81,7 @@ import { handler as onIngestionActivationChangeHandler, parseBlob, } from "./watchers/on-activation-ingestion-change"; +import { makeHandler as makeOnActivationChangeHandler } from "./watchers/on-activations-change"; import { makeHandler as makeOnLegacyActivationChangeHandler } from "./watchers/on-legacy-activations-change"; import { handler as onLegacyServiceChangeHandler } from "./watchers/on-legacy-service-change"; import { makeHandler as makeOnSelfcareGroupChangeHandler } from "./watchers/on-selfcare-group-change"; @@ -179,8 +184,14 @@ const legacyServicesContainer = cosmosdbClient .database(config.LEGACY_COSMOSDB_NAME) .container(SERVICE_COLLECTION_NAME); +const legacyActivationContainer = cosmosdbClient + .database(config.LEGACY_COSMOSDB_NAME) + .container(ACTIVATION_COLLECTION_NAME); + const legacyServiceModel = new ServiceModel(legacyServicesContainer); +const legacyActivationModel = new ActivationModel(legacyActivationContainer); + const blobService = createBlobService(config.ASSET_STORAGE_CONNECTIONSTRING); // eventhub producer for ServicePublication @@ -486,6 +497,13 @@ export const activationsSyncFromLegacyEntryPoint: AzureFunction = ( toAzureFunctionHandler, )(context, args); +// activation sync to legacy +export const activationsSyncToLegacyEntryPoint = pipe( + { legacyActivationModel }, + makeOnActivationChangeHandler, + toAzureFunctionHandler, +); + //Ingestion Service Publication export const onIngestionServicePublicationChangeEntryPoint = pipe( onIngestionServicePublicationChangeHandler( diff --git a/apps/io-services-cms-webapp/src/watchers/__tests__/on-activation-change.test.ts b/apps/io-services-cms-webapp/src/watchers/__tests__/on-activation-change.test.ts new file mode 100644 index 000000000..c74339ffe --- /dev/null +++ b/apps/io-services-cms-webapp/src/watchers/__tests__/on-activation-change.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as E from "fp-ts/lib/Either"; +import * as TE from "fp-ts/lib/TaskEither"; +import { makeHandler } from "../on-activations-change"; +import { CosmosErrors } from "@pagopa/io-functions-commons/dist/src/utils/cosmosdb_model"; + +const mock = vi.hoisted(() => ({ + legacyActivationModel: { upsert: vi.fn() }, +})); + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("makeHandler", () => { + const deps = { + legacyActivationModel: mock.legacyActivationModel, + } as unknown as Parameters[0]; + + const validActivation = { + fiscalCode: "RSSMRA80A01H501U", + serviceId: "serviceId", + status: "ACTIVE" as const, + modifiedAt: 1617187200000, + }; + + it("should fail when the buffer is not valid JSON", async () => { + // given + const invalidBuffer = Buffer.from("invalid json"); + + // when + const result = await makeHandler(deps)({ inputs: [invalidBuffer] })(); + + // then + expect(E.isLeft(result)).toBeTruthy(); + if (E.isLeft(result)) { + expect(result.left).toBeInstanceOf(Error); + expect(result.left.message).toContain("Unexpected token"); + } + expect(mock.legacyActivationModel.upsert).not.toHaveBeenCalled(); + }); + + it("should fail when the buffer is not a valid Activation", async () => { + // given + const invalidActivation = { ...validActivation, status: "WRONG_STATUS" }; + const invalidBuffer = Buffer.from(JSON.stringify(invalidActivation)); + + // when + const result = await makeHandler(deps)({ inputs: [invalidBuffer] })(); + // then + expect(E.isLeft(result)).toBeTruthy(); + if (E.isLeft(result)) { + expect(result.left).toBeInstanceOf(Error); + expect(result.left.message).toContain( + "at [root.status.0] is not a valid", + ); + } + expect(mock.legacyActivationModel.upsert).not.toHaveBeenCalled(); + }); + + it("should fail when upsert fails", async () => { + // given + const validBuffer = Buffer.from(JSON.stringify(validActivation)); + const error = new Error("upsert error"); + mock.legacyActivationModel.upsert.mockReturnValueOnce(TE.left(error)); + + // when + const result = await makeHandler(deps)({ inputs: [validBuffer] })(); + // then + expect(E.isLeft(result)).toBeTruthy(); + if (E.isLeft(result)) { + expect(result.left).toBeInstanceOf(Error); + expect(result.left).toStrictEqual(error); + } + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledOnce(); + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledWith({ + fiscalCode: validActivation.fiscalCode, + kind: "INewActivation", + serviceId: validActivation.serviceId, + status: "ACTIVE", + }); + }); + + it("should fail when upsert fails for a cosmos empty response", async () => { + // given + const validBuffer = Buffer.from(JSON.stringify(validActivation)); + const error: CosmosErrors = { kind: "COSMOS_CONFLICT_RESPONSE" }; + mock.legacyActivationModel.upsert.mockReturnValueOnce(TE.left(error)); + + // when + const result = await makeHandler(deps)({ inputs: [validBuffer] })(); + // then + expect(E.isLeft(result)).toBeTruthy(); + if (E.isLeft(result)) { + expect(result.left).toBeInstanceOf(Error); + expect(result.left.message).toStrictEqual(error.kind); + } + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledOnce(); + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledWith({ + fiscalCode: validActivation.fiscalCode, + kind: "INewActivation", + serviceId: validActivation.serviceId, + status: "ACTIVE", + }); + }); + + it("should fail when upsert fails for a cosmos decoding error", async () => { + // given + const validBuffer = Buffer.from(JSON.stringify(validActivation)); + const error: CosmosErrors = { + kind: "COSMOS_DECODING_ERROR", + error: [{ context: [], value: "a", message: "b" }], + }; + mock.legacyActivationModel.upsert.mockReturnValueOnce(TE.left(error)); + + // when + const result = await makeHandler(deps)({ inputs: [validBuffer] })(); + // then + expect(E.isLeft(result)).toBeTruthy(); + if (E.isLeft(result)) { + expect(result.left).toBeInstanceOf(Error); + expect(result.left.message).toStrictEqual(JSON.stringify(error.error)); + } + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledOnce(); + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledWith({ + fiscalCode: validActivation.fiscalCode, + kind: "INewActivation", + serviceId: validActivation.serviceId, + status: "ACTIVE", + }); + }); + + it("should fail when upsert fails for a cosmos error", async () => { + // given + const validBuffer = Buffer.from(JSON.stringify(validActivation)); + const error: CosmosErrors = { + kind: "COSMOS_ERROR_RESPONSE", + error: { + code: 500, + message: "internal server error", + name: "Error Response", + }, + }; + mock.legacyActivationModel.upsert.mockReturnValueOnce(TE.left(error)); + + // when + const result = await makeHandler(deps)({ inputs: [validBuffer] })(); + // then + expect(E.isLeft(result)).toBeTruthy(); + if (E.isLeft(result)) { + expect(result.left).toBeInstanceOf(Error); + expect(result.left.message).toStrictEqual(error.error.message); + } + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledOnce(); + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledWith({ + fiscalCode: validActivation.fiscalCode, + kind: "INewActivation", + serviceId: validActivation.serviceId, + status: "ACTIVE", + }); + }); + + it("should complete successfully when upsert do not fail", async () => { + // given + const validBuffer = Buffer.from(JSON.stringify(validActivation)); + mock.legacyActivationModel.upsert.mockReturnValueOnce(TE.right({})); + + // when + const result = await makeHandler(deps)({ inputs: [validBuffer] })(); + // then + expect(E.isRight(result)).toBeTruthy(); + if (E.isRight(result)) { + expect(result.right).toBeUndefined(); + } + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledOnce(); + expect(mock.legacyActivationModel.upsert).toHaveBeenCalledWith({ + fiscalCode: validActivation.fiscalCode, + kind: "INewActivation", + serviceId: validActivation.serviceId, + status: "ACTIVE", + }); + }); +}); diff --git a/apps/io-services-cms-webapp/src/watchers/on-activations-change.ts b/apps/io-services-cms-webapp/src/watchers/on-activations-change.ts new file mode 100644 index 000000000..7d18f09c4 --- /dev/null +++ b/apps/io-services-cms-webapp/src/watchers/on-activations-change.ts @@ -0,0 +1,86 @@ +import { Activations } from "@io-services-cms/models"; +import { ActivationStatusEnum } from "@pagopa/io-functions-commons/dist/generated/definitions/ActivationStatus"; +import { + ActivationModel, + NewActivation, +} from "@pagopa/io-functions-commons/dist/src/models/activation"; +import { readableReport } from "@pagopa/ts-commons/lib/reporters"; +import * as E from "fp-ts/lib/Either"; +import * as RTE from "fp-ts/lib/ReaderTaskEither"; +import * as TE from "fp-ts/lib/TaskEither"; +import { flow, pipe } from "fp-ts/lib/function"; + +interface HandlerDependencies { + readonly legacyActivationModel: ActivationModel; +} + +const cmsToLegacy = (activation: Activations.Activation): NewActivation => ({ + fiscalCode: activation.fiscalCode, + kind: "INewActivation", + serviceId: activation.serviceId, + status: toLegacyStatus(activation.status), +}); + +const toLegacyStatus = ( + status: Activations.Activation["status"], +): Activations.LegacyCosmosResource["status"] => { + switch (status) { + case "ACTIVE": + return ActivationStatusEnum.ACTIVE; + case "INACTIVE": + return ActivationStatusEnum.INACTIVE; + case "PENDING": + return ActivationStatusEnum.PENDING; + default: + // Should never happen if validation is correct + return ActivationStatusEnum.INACTIVE; + } +}; + +export const makeHandler = + ({ + legacyActivationModel, + }: HandlerDependencies): RTE.ReaderTaskEither< + { inputs: unknown[] }, + Error, + void + > => + ({ inputs }) => + pipe( + inputs[0] as Buffer, + (blob) => blob.toString("utf-8"), + E.tryCatchK(JSON.parse, E.toError), + E.chain( + flow( + Activations.Activation.decode, + E.mapLeft(flow(readableReport, (e) => new Error(e))), + ), + ), + TE.fromEither, + TE.chainW((activation) => + pipe( + cmsToLegacy(activation), + (newActivation) => legacyActivationModel.upsert(newActivation), + TE.mapLeft((err) => { + if (err instanceof Error) { + return err; + } else { + switch (err.kind) { + case "COSMOS_EMPTY_RESPONSE": + case "COSMOS_CONFLICT_RESPONSE": + return new Error(err.kind); + case "COSMOS_DECODING_ERROR": + return E.toError(JSON.stringify(err.error)); + case "COSMOS_ERROR_RESPONSE": + return E.toError(err.error.message); + default: + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-case-declarations + const _: never = err; + return new Error(`should not have executed this with ${err}`); + } + } + }), + ), + ), + TE.map(() => void 0), + ); diff --git a/infra/resources/_modules/cms_function_app/main.tf b/infra/resources/_modules/cms_function_app/main.tf index b301588c0..91f85b757 100644 --- a/infra/resources/_modules/cms_function_app/main.tf +++ b/infra/resources/_modules/cms_function_app/main.tf @@ -60,6 +60,7 @@ module "cms_fn" { "AzureWebJobs.SelfcareGroupWatcher.Disabled" = "0" "AzureWebJobs.IngestionActivationWatcher.Disabled" = "0" "AzureWebJobs.ActivationsSyncFromLegacy.Disabled" = "0" + "AzureWebJobs.ActivationsSyncToLegacy.Disabled" = "1" } ) @@ -94,6 +95,7 @@ module "cms_fn" { "AzureWebJobs.SelfcareGroupWatcher.Disabled" = "1" "AzureWebJobs.IngestionActivationWatcher.Disabled" = "1" "AzureWebJobs.ActivationsSyncFromLegacy.Disabled" = "1" + "AzureWebJobs.ActivationsSyncToLegacy.Disabled" = "1" } ) @@ -125,6 +127,7 @@ module "cms_fn" { "AzureWebJobs.SelfcareGroupWatcher.Disabled", "AzureWebJobs.IngestionActivationWatcher.Disabled", "AzureWebJobs.ActivationsSyncFromLegacy.Disabled", + "AzureWebJobs.ActivationsSyncToLegacy.Disabled", ] tier = local.cms.tier