From d1cff20d05d63ec4568251219d7746a5729e1cd5 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Wed, 27 Nov 2024 15:35:07 +0100 Subject: [PATCH] Endpoint for process state and events in one transaction (#562) --- src/DbTools/Program.cs | 2 +- src/Storage/Altinn.Platform.Storage.csproj | 2 +- src/Storage/Controllers/ProcessController.cs | 143 +++- .../insertinstanceevents.sql | 10 + .../v0.15/01-functions-and-procedures.sql | 761 ++++++++++++++++++ .../IInstanceAndEventsRepository.cs | 20 + .../PgInstanceAndEventsRepository.cs | 86 ++ .../Repository/PgInstanceRepository.cs | 55 +- src/Storage/Services/IInstanceEventService.cs | 8 + src/Storage/Services/InstanceEventService.cs | 10 +- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../InstanceAndEventsRepositoryMock.cs | 22 + .../ProcessControllerTest.cs | 239 +++--- .../TestingRepositories/InstanceTests.cs | 53 +- 14 files changed, 1243 insertions(+), 169 deletions(-) create mode 100644 src/Storage/Migration/FunctionsAndProcedures/insertinstanceevents.sql create mode 100644 src/Storage/Migration/v0.15/01-functions-and-procedures.sql create mode 100644 src/Storage/Repository/IInstanceAndEventsRepository.cs create mode 100644 src/Storage/Repository/PgInstanceAndEventsRepository.cs create mode 100644 test/UnitTest/Mocks/Repository/InstanceAndEventsRepositoryMock.cs diff --git a/src/DbTools/Program.cs b/src/DbTools/Program.cs index 7879184d..9a5c7f74 100644 --- a/src/DbTools/Program.cs +++ b/src/DbTools/Program.cs @@ -24,7 +24,7 @@ static void Main(string[] args) string scriptFile = GetScriptFile(versionDirectory); string toolName = Assembly.GetExecutingAssembly().FullName ?? "DbTools"; File.WriteAllText(scriptFile, $"-- This script is autogenerated from the tool {toolName.Split(',')[0]}. Do not edit manually.{cr}{cr}"); - foreach (string filename in (new DirectoryInfo(funcAndProcDirectory).GetFiles(("*.sql")).Select(f => f.FullName))) + foreach (string filename in (new DirectoryInfo(funcAndProcDirectory).GetFiles(("*.sql")).Select(f => f.FullName).OrderBy(f => f))) { File.AppendAllText(scriptFile, $"-- {filename.Split(Path.DirectorySeparatorChar)[^1]}:{cr}{File.ReadAllText(filename)}{cr}{cr}"); } diff --git a/src/Storage/Altinn.Platform.Storage.csproj b/src/Storage/Altinn.Platform.Storage.csproj index be98a35a..b5feb09f 100644 --- a/src/Storage/Altinn.Platform.Storage.csproj +++ b/src/Storage/Altinn.Platform.Storage.csproj @@ -13,7 +13,7 @@ - + diff --git a/src/Storage/Controllers/ProcessController.cs b/src/Storage/Controllers/ProcessController.cs index bf9a07cf..7a15fafe 100644 --- a/src/Storage/Controllers/ProcessController.cs +++ b/src/Storage/Controllers/ProcessController.cs @@ -29,6 +29,7 @@ public class ProcessController : ControllerBase { private readonly IInstanceRepository _instanceRepository; private readonly IInstanceEventRepository _instanceEventRepository; + private readonly IInstanceAndEventsRepository _instanceAndEventsRepository; private readonly string _storageBaseAndHost; private readonly IAuthorization _authorizationService; private readonly IInstanceEventService _instanceEventService; @@ -38,18 +39,21 @@ public class ProcessController : ControllerBase /// /// the instance repository handler /// the instance event repository service + /// the instance and events repository /// the general settings /// the authorization service /// the instance event service public ProcessController( IInstanceRepository instanceRepository, IInstanceEventRepository instanceEventRepository, + IInstanceAndEventsRepository instanceAndEventsRepository, IOptions generalsettings, IAuthorization authorizationService, IInstanceEventService instanceEventService) { _instanceRepository = instanceRepository; _instanceEventRepository = instanceEventRepository; + _instanceAndEventsRepository = instanceAndEventsRepository; _storageBaseAndHost = $"{generalsettings.Value.Hostname}/storage/api/v1/"; _authorizationService = authorizationService; _instanceEventService = instanceEventService; @@ -80,28 +84,57 @@ public async Task> PutProcess( return NotFound(); } - string taskId = null; - string altinnTaskType = existingInstance.Process?.CurrentTask?.AltinnTaskType; + var (action, taskId) = ActionMapping(processState, existingInstance); - if (processState?.CurrentTask?.FlowType == "AbandonCurrentMoveToNext") + bool authorized = await _authorizationService.AuthorizeInstanceAction(existingInstance, action, taskId); + + if (!authorized) { - altinnTaskType = "reject"; + return Forbid(); } - else if (processState?.CurrentTask?.FlowType is not null - && processState.CurrentTask.FlowType != "CompleteCurrentMoveToNext") + + UpdateInstance(existingInstance, processState, out var updateProperties); + + Instance updatedInstance = await _instanceRepository.Update(existingInstance, updateProperties); + + if (processState?.CurrentTask?.AltinnTaskType == "signing") { - altinnTaskType = processState.CurrentTask.AltinnTaskType; - taskId = processState.CurrentTask.ElementId; + await _instanceEventService.DispatchEvent(InstanceEventType.SentToSign, updatedInstance); } - string action = altinnTaskType switch + updatedInstance.SetPlatformSelfLinks(_storageBaseAndHost); + return Ok(updatedInstance); + } + + /// + /// Updates the process state of an instance. + /// + /// The party id of the instance owner. + /// The id of the instance that should have its process updated. + /// The new process state of the instance (including instance events). + /// + [Authorize] + [HttpPut("instanceandevents")] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Produces("application/json")] + public async Task> PutInstanceAndEvents( + int instanceOwnerPartyId, + Guid instanceGuid, + [FromBody] ProcessStateUpdate processStateUpdate) + { + (Instance existingInstance, _) = await _instanceRepository.GetOne(instanceGuid, true); + + if (existingInstance is null) { - "data" or "feedback" => "write", - "payment" => "pay", - "confirmation" => "confirm", - "signing" => "sign", - _ => altinnTaskType, - }; + return NotFound(); + } + + ProcessState processState = processStateUpdate.State; + + var (action, taskId) = ActionMapping(processState, existingInstance); bool authorized = await _authorizationService.AuthorizeInstanceAction(existingInstance, action, taskId); @@ -110,33 +143,16 @@ public async Task> PutProcess( return Forbid(); } - // Archiving instance if process was ended - List updateProperties = [ - nameof(existingInstance.Process), - nameof(existingInstance.LastChanged), - nameof(existingInstance.LastChangedBy) - ]; - if (existingInstance.Process?.Ended is null && processState?.Ended is not null) - { - existingInstance.Status ??= new InstanceStatus(); - existingInstance.Status.IsArchived = true; - existingInstance.Status.Archived = processState.Ended; - updateProperties.Add(nameof(existingInstance.Status)); - updateProperties.Add(nameof(existingInstance.Status.IsArchived)); - updateProperties.Add(nameof(existingInstance.Status.Archived)); - } - - existingInstance.Process = processState; - existingInstance.LastChangedBy = User.GetUserOrOrgId(); - existingInstance.LastChanged = DateTime.UtcNow; - - Instance updatedInstance = await _instanceRepository.Update(existingInstance, updateProperties); - + processStateUpdate.Events ??= []; + UpdateInstance(existingInstance, processState, out var updateProperties); if (processState?.CurrentTask?.AltinnTaskType == "signing") { - await _instanceEventService.DispatchEvent(InstanceEventType.SentToSign, updatedInstance); + InstanceEvent instanceEvent = _instanceEventService.BuildInstanceEvent(InstanceEventType.SentToSign, existingInstance); + processStateUpdate.Events.Add(instanceEvent); } + Instance updatedInstance = await _instanceAndEventsRepository.Update(existingInstance, updateProperties, processStateUpdate.Events); + updatedInstance.SetPlatformSelfLinks(_storageBaseAndHost); return Ok(updatedInstance); } @@ -194,5 +210,56 @@ public async Task> GetForAuth(int instanceOwnerPartyId, G return NotFound($"Unable to find instance {instanceOwnerPartyId}/{instanceGuid}: {message}"); } + + private void UpdateInstance(Instance existingInstance, ProcessState processState, out List updateProperties) + { + // Archiving instance if process was ended + updateProperties = [ + nameof(existingInstance.Process), + nameof(existingInstance.LastChanged), + nameof(existingInstance.LastChangedBy) + ]; + if (existingInstance.Process?.Ended is null && processState?.Ended is not null) + { + existingInstance.Status ??= new InstanceStatus(); + existingInstance.Status.IsArchived = true; + existingInstance.Status.Archived = processState.Ended; + updateProperties.Add(nameof(existingInstance.Status)); + updateProperties.Add(nameof(existingInstance.Status.IsArchived)); + updateProperties.Add(nameof(existingInstance.Status.Archived)); + } + + existingInstance.Process = processState; + existingInstance.LastChangedBy = User.GetUserOrOrgId(); + existingInstance.LastChanged = DateTime.UtcNow; + } + + private (string Action, string TaskId) ActionMapping(ProcessState processState, Instance existingInstance) + { + string taskId = null; + string altinnTaskType = existingInstance.Process?.CurrentTask?.AltinnTaskType; + + if (processState?.CurrentTask?.FlowType == "AbandonCurrentMoveToNext") + { + altinnTaskType = "reject"; + } + else if (processState?.CurrentTask?.FlowType is not null + && processState.CurrentTask.FlowType != "CompleteCurrentMoveToNext") + { + altinnTaskType = processState.CurrentTask.AltinnTaskType; + taskId = processState.CurrentTask.ElementId; + } + + string action = altinnTaskType switch + { + "data" or "feedback" => "write", + "payment" => "pay", + "confirmation" => "confirm", + "signing" => "sign", + _ => altinnTaskType, + }; + + return (action, taskId); + } } } diff --git a/src/Storage/Migration/FunctionsAndProcedures/insertinstanceevents.sql b/src/Storage/Migration/FunctionsAndProcedures/insertinstanceevents.sql new file mode 100644 index 00000000..bcdc03b6 --- /dev/null +++ b/src/Storage/Migration/FunctionsAndProcedures/insertinstanceevents.sql @@ -0,0 +1,10 @@ + +CREATE OR REPLACE PROCEDURE storage.insertinstanceevents(_instance UUID, _events JSONB) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + INSERT INTO storage.instanceevents (instance, alternateid, event) + SELECT _instance, (evs->>'Id')::UUID, jsonb_strip_nulls(evs) + FROM jsonb_array_elements(_events) evs; +END; +$BODY$; \ No newline at end of file diff --git a/src/Storage/Migration/v0.15/01-functions-and-procedures.sql b/src/Storage/Migration/v0.15/01-functions-and-procedures.sql new file mode 100644 index 00000000..1705b62a --- /dev/null +++ b/src/Storage/Migration/v0.15/01-functions-and-procedures.sql @@ -0,0 +1,761 @@ +-- This script is autogenerated from the tool DbTools. Do not edit manually. + +-- deletedataelement.sql: +CREATE OR REPLACE FUNCTION storage.deletedataelement_v2(_alternateid UUID, _instanceGuid UUID, _lastChangedBy TEXT) + RETURNS INT + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + _deleteCount INTEGER; +BEGIN + IF (SELECT COUNT(*) FROM storage.dataelements WHERE element -> 'IsRead' = 'true' AND instanceguid = _instanceGuid) = 0 THEN + UPDATE storage.instances + SET instance = jsonb_set(instance, '{Status, ReadStatus}', '0') + WHERE alternateid = _instanceGuid AND instance -> 'Status' ->> 'ReadStatus' = '1'; + END IF; + + UPDATE storage.instances + SET lastchanged = NOW(), + instance = instance + || jsonb_set('{"LastChanged":""}', '{LastChanged}', to_jsonb(REPLACE((NOW() AT TIME ZONE 'UTC')::TEXT, ' ', 'T') || 'Z')) + || jsonb_set('{"LastChangedBy":""}', '{LastChangedBy}', to_jsonb(_lastChangedBy)) + WHERE alternateid = (SELECT instanceguid FROM storage.dataelements WHERE alternateid = _alternateid); + + DELETE FROM storage.dataelements WHERE alternateid = _alternateid; + GET DIAGNOSTICS _deleteCount = ROW_COUNT; + + RETURN _deleteCount; +END; +$BODY$; + +-- deletedataelements.sql: +CREATE OR REPLACE FUNCTION storage.deletedataelements(_instanceguid UUID) + RETURNS INT + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + _deleteCount INTEGER; +BEGIN + UPDATE storage.instances + SET lastchanged = NOW(), + instance = instance + || jsonb_set('{"LastChanged":""}', '{LastChanged}', to_jsonb(REPLACE((NOW() AT TIME ZONE 'UTC')::TEXT, ' ', 'T') || 'Z')) + || jsonb_set('{"LastChangedBy":""}', '{LastChangedBy}', to_jsonb('altinn'::TEXT)) + WHERE alternateid = _instanceguid; + + DELETE FROM storage.dataelements d + USING storage.instances i + WHERE i.alternateid = d.instanceguid AND i.alternateid = _instanceguid; + GET DIAGNOSTICS _deleteCount = ROW_COUNT; + RETURN _deleteCount; +END; +$BODY$; + +-- deleteinstance.sql: +CREATE OR REPLACE FUNCTION storage.deleteinstance(_alternateid UUID) + RETURNS INT + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + _deleteCount INTEGER; +BEGIN + DELETE FROM storage.instances WHERE alternateid = _alternateid; + GET DIAGNOSTICS _deleteCount = ROW_COUNT; + RETURN _deleteCount; +END; +$BODY$; + +-- deleteinstanceevent.sql: +CREATE OR REPLACE FUNCTION storage.deleteinstanceevent(_instance UUID) + RETURNS INT + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + _deleteCount INTEGER; +BEGIN + DELETE FROM storage.instanceevents WHERE instance = _instance; + GET DIAGNOSTICS _deleteCount = ROW_COUNT; + RETURN _deleteCount; +END; +$BODY$; + +-- deletemigrationstate.sql: +CREATE OR REPLACE PROCEDURE storage.deletemigrationstate (_instanceguid UUID) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + DELETE FROM storage.a1migrationstate WHERE instanceguid = _instanceguid; + DELETE FROM storage.a2migrationstate WHERE instanceguid = _instanceguid; +END; +$BODY$; + +-- filterinstanceevent.sql: +CREATE OR REPLACE FUNCTION storage.filterinstanceevent(_instance UUID, _from TIMESTAMP, _to TIMESTAMP, _eventtype TEXT[]) + RETURNS TABLE (event JSONB) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT ie.event + FROM storage.instanceevents ie + WHERE instance = _instance + AND (ie.event->>'Created')::TIMESTAMP >= _from + AND (ie.event->>'Created')::TIMESTAMP <= _to + AND (_eventtype IS NULL OR ie.event->>'EventType' = ANY (_eventtype)) + ORDER BY ie.event->'Created'; +END; +$BODY$; + +-- inserta1migrationstate.sql: +CREATE OR REPLACE PROCEDURE storage.inserta1migrationstate (_a1archiveReference BIGINT) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + INSERT INTO storage.a1migrationstate (a1archivereference) VALUES + (_a1archiveReference) + ON CONFLICT (a1archivereference) DO NOTHING; +END; +$BODY$; + +-- inserta2codelist.sql: +CREATE OR REPLACE PROCEDURE storage.inserta2codelist (_name TEXT, _language TEXT, _version INT, _codelist TEXT) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + INSERT INTO storage.a2codelists (name, language, version, codelist) VALUES + (_name, _language, _version, _codelist) + ON CONFLICT (name, language, version) DO UPDATE SET codelist = _codelist; +END; +$BODY$; + +-- inserta2image.sql: +CREATE OR REPLACE PROCEDURE storage.inserta2image (_name TEXT, _image BYTEA) + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + _parentId INTEGER; +BEGIN + SELECT id into _parentId FROM storage.a2images WHERE md5(_image) = md5(image); + IF _parentId IS NOT NULL THEN + INSERT INTO storage.a2images (name, parentid, image) VALUES + (_name, _parentId, null) + ON CONFLICT (name) DO NOTHING; + ELSE + INSERT INTO storage.a2images (name, parentid, image) VALUES + (_name, null, _image) + ON CONFLICT (name) DO UPDATE SET image = _image; + END IF; +END; +$BODY$; + +-- inserta2migrationstate.sql: +CREATE OR REPLACE PROCEDURE storage.inserta2migrationstate (_a2archiveReference BIGINT) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + INSERT INTO storage.a2migrationstate (a2archivereference) VALUES + (_a2archiveReference) + ON CONFLICT (a2archivereference) DO NOTHING; +END; +$BODY$; + +-- inserta2xsl.sql: +CREATE OR REPLACE PROCEDURE storage.inserta2xsl (_org TEXT, _app TEXT, _lformid INT, _language TEXT, _pagenumber INT, _xsl TEXT, _xsltype INT, _isportrait BOOL) + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + _parentId INTEGER; + _appId INTEGER; + _applicationinternalid INTEGER; +BEGIN + SELECT id into _applicationinternalid FROM storage.applications WHERE org = _org AND app = _app; + SELECT id into _parentId FROM storage.a2xsls WHERE org = _org AND app = _app AND xsl = _xsl AND xsltype = _xsltype; + SELECT id into _appId FROM storage.applications WHERE org = _org AND app = _app; + IF _parentId IS NOT NULL THEN + INSERT INTO storage.a2xsls (org, app, applicationinternalid, parentid, lformid, language, pagenumber, xsl, xsltype, isportrait) VALUES + (_org, _app, _applicationinternalid, _parentId, _lformid, _language, _pagenumber, NULL, _xsltype, _isportrait) + ON CONFLICT (app, org, lformid, pagenumber, language, xsltype) DO NOTHING; + ELSE + INSERT INTO storage.a2xsls (org, app, applicationinternalid, parentid, lformid, language, pagenumber, xsl, xsltype, isportrait) VALUES + (_org, _app, _applicationinternalid, NULL, _lformid, _language, _pagenumber, _xsl, _xsltype, _isportrait) + ON CONFLICT (app, org, lformid, pagenumber, language, xsltype) DO UPDATE SET xsl = _xsl; + END IF; +END; +$BODY$; + +-- insertdataelement.sql: +CREATE OR REPLACE FUNCTION storage.insertdataelement_v2( + IN _instanceinternalid bigint, + IN _instanceguid uuid, + IN _alternateid uuid, + IN _element jsonb) + RETURNS TABLE (updatedElement JSONB) + LANGUAGE plpgsql +AS $BODY$ +BEGIN + -- Make sure that lastChanged has the Postgres precision (6 digits). The timestamp from C# DateTime and then json serialize has 7 digits + _element := _element || jsonb_set('{"LastChanged":""}', '{LastChanged}', to_jsonb(REPLACE(((_element ->> 'LastChanged')::TIMESTAMPTZ AT TIME ZONE 'UTC')::TEXT, ' ', 'T') || 'Z')); + + IF _element ->> 'IsRead' = 'false' THEN + UPDATE storage.instances + SET instance = jsonb_set(instance, '{Status, ReadStatus}', '2') + WHERE id = _instanceinternalid AND instance -> 'Status' ->> 'ReadStatus' = '1'; + END IF; + + UPDATE storage.instances + SET lastchanged = (_element ->> 'LastChanged')::TIMESTAMPTZ, + instance = instance + || jsonb_set('{"LastChanged":""}', '{LastChanged}', to_jsonb(_element ->> 'LastChanged')) + || jsonb_set('{"LastChangedBy":""}', '{LastChangedBy}', to_jsonb(_element ->> 'LastChangedBy')) + WHERE id = _instanceinternalid; + + RETURN QUERY + INSERT INTO storage.dataelements(instanceinternalid, instanceGuid, alternateid, element) VALUES (_instanceinternalid, _instanceGuid, _alternateid, jsonb_strip_nulls(_element)) + RETURNING element; +END; +$BODY$; + +-- insertinstance.sql: +CREATE OR REPLACE PROCEDURE storage.insertinstance_v2(_partyid BIGINT, _alternateid UUID, _instance JSONB, _created TIMESTAMPTZ, _lastchanged TIMESTAMPTZ, _org TEXT, _appid TEXT, _taskid TEXT, _altinnmainversion INT) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + INSERT INTO storage.instances(partyid, alternateid, instance, created, lastchanged, org, appid, taskid, altinnmainversion) + VALUES (_partyid, _alternateid, jsonb_strip_nulls(_instance), _created, _lastchanged, _org, _appid, _taskid, _altinnmainversion); +END; +$BODY$; + +-- insertinstanceevent.sql: +CREATE OR REPLACE PROCEDURE storage.insertinstanceevent(_instance UUID, _alternateid UUID, _event JSONB) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + -- Dummy comment to verify that new migration solution works end to end + INSERT INTO storage.instanceevents(instance, alternateid, event) VALUES (_instance, _alternateid, jsonb_strip_nulls(_event)); +END; +$BODY$; + +-- insertinstanceevents.sql: + +CREATE OR REPLACE PROCEDURE storage.insertinstanceevents(_instance UUID, _events JSONB) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + INSERT INTO storage.instanceevents (instance, alternateid, event) + SELECT _instance, (evs->>'Id')::UUID, jsonb_strip_nulls(evs) + FROM jsonb_array_elements(_events) evs; +END; +$BODY$; + +-- reada1migrationstate.sql: +CREATE OR REPLACE FUNCTION storage.reada1migrationstate(_a1archivereference BIGINT) + RETURNS TABLE (instanceguid UUID) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT ms.instanceguid FROM storage.a1migrationstate ms WHERE ms.a1archivereference = _a1archivereference; +END; +$BODY$; + +-- reada2codelist.sql: +CREATE OR REPLACE FUNCTION storage.reada2codelist(_name TEXT, _language TEXT) + RETURNS TABLE (codelist TEXT) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT c.codelist FROM storage.a2codelists c + WHERE name = _name AND language = _language + ORDER BY version DESC LIMIT 1; +END; +$BODY$; + +-- reada2image.sql: +CREATE OR REPLACE FUNCTION storage.reada2image(_name TEXT) + RETURNS TABLE (image BYTEA) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT + CASE + WHEN c.image IS NOT NULL THEN c.image + ELSE (SELECT p.image FROM storage.a2images p WHERE p.id = c.parentid) + END + FROM storage.a2images c WHERE c.name = _name; +END; +$BODY$; + +-- reada2migrationstate.sql: +CREATE OR REPLACE FUNCTION storage.reada2migrationstate(_a2archivereference BIGINT) + RETURNS TABLE (instanceguid UUID) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT ms.instanceguid FROM storage.a2migrationstate ms WHERE ms.a2archivereference = _a2archivereference; +END; +$BODY$; + +-- reada2xsls.sql: +CREATE OR REPLACE FUNCTION storage.reada2xsls(_org TEXT, _app TEXT, _lformid INT, _language TEXT, _xsltype INT) + RETURNS TABLE (xsl TEXT, isportrait BOOL) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT + CASE + WHEN x.xsl IS NOT NULL THEN x.xsl + ELSE (SELECT p.xsl FROM storage.a2xsls p WHERE p.id = x.parentid) + END, + x.isportrait + FROM storage.a2xsls x + WHERE x.org = _org and x.app = _app and x.lformid = _lformid and x.language = _language and x.xsltype = _xsltype + ORDER BY pagenumber; + +END; +$BODY$; + +-- readdataelement.sql: +CREATE OR REPLACE FUNCTION storage.readdataelement(_alternateid UUID) + RETURNS TABLE (element JSONB) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT d.element FROM storage.dataelements d WHERE alternateid = _alternateid; + +END; +$BODY$; + +-- readdeletedelements.sql: +CREATE OR REPLACE FUNCTION storage.readdeletedelements() + RETURNS TABLE (id BIGINT, instance JSONB, element JSONB) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN +RETURN QUERY + -- Use materialized cte to force join order + -- Target index dataelements_deletestatus_harddeleted. This index has a where clause that must match + -- the where clause in the data_elements query + WITH data_elements AS MATERIALIZED + (SELECT d.instanceinternalid, d.element FROM storage.dataelements d + WHERE (d.element -> 'DeleteStatus' -> 'IsHardDeleted')::BOOLEAN + AND (d.element -> 'DeleteStatus' ->> 'HardDeleted')::TIMESTAMPTZ <= NOW() - (7 ||' days')::interval + ) + SELECT i.id, i.instance, data_elements.element FROM data_elements JOIN storage.instances i ON i.id = data_elements.instanceinternalid; + END; +$BODY$; + +-- readdeletedinstances.sql: +CREATE OR REPLACE FUNCTION storage.readdeletedinstances() + RETURNS TABLE (instance JSONB) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + -- Make sure that part of the where clause is exactly as in filtered index instances_isharddeleted_and_more + SELECT i.instance FROM storage.instances i + WHERE (i.instance -> 'Status' -> 'IsHardDeleted')::BOOLEAN AND + ( + NOT (i.instance -> 'Status' -> 'IsArchived')::BOOLEAN + OR (i.instance -> 'CompleteConfirmations') IS NOT NULL AND (i.instance -> 'Status' ->> 'HardDeleted')::TIMESTAMPTZ <= (NOW() - (7 ||' days')::INTERVAL) + ); +END; +$BODY$; + + +-- readinstance.sql: +CREATE OR REPLACE FUNCTION storage.readinstance(_alternateid UUID) + RETURNS TABLE (id BIGINT, instance JSONB, element JSONB) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT i.id, i.instance, d.element FROM storage.instances i + LEFT JOIN storage.dataelements d ON i.id = d.instanceinternalid + WHERE i.alternateid = _alternateid + ORDER BY d.id; + +END; +$BODY$; + + +-- readinstanceevent.sql: +CREATE OR REPLACE FUNCTION storage.readinstanceevent(_alternateid UUID) + RETURNS TABLE (event JSONB) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT ie.event FROM storage.instanceevents ie WHERE alternateid = _alternateid; + +END; +$BODY$; + +-- readinstancefromquery.sql: +CREATE OR REPLACE FUNCTION storage.readinstancefromquery_v5( + _appId TEXT DEFAULT NULL, + _appIds TEXT[] DEFAULT NULL, + _archiveReference TEXT DEFAULT NULL, + _continue_idx BIGINT DEFAULT 0, + _created_eq TIMESTAMPTZ DEFAULT NULL, + _created_gt TIMESTAMPTZ DEFAULT NULL, + _created_gte TIMESTAMPTZ DEFAULT NULL, + _created_lt TIMESTAMPTZ DEFAULT NULL, + _created_lte TIMESTAMPTZ DEFAULT NULL, + _dueBefore_eq TEXT DEFAULT NULL, + _dueBefore_gt TEXT DEFAULT NULL, + _dueBefore_gte TEXT DEFAULT NULL, + _dueBefore_lt TEXT DEFAULT NULL, + _dueBefore_lte TEXT DEFAULT NULL, + _excludeConfirmedBy JSONB[] DEFAULT NULL, + _includeElements BOOL DEFAULT TRUE, + _instanceOwner_partyId INTEGER DEFAULT NULL, + _instanceOwner_partyIds INTEGER[] DEFAULT NULL, + _lastChanged_eq TIMESTAMPTZ DEFAULT NULL, + _lastChanged_gt TIMESTAMPTZ DEFAULT NULL, + _lastChanged_gte TIMESTAMPTZ DEFAULT NULL, + _lastChanged_idx TIMESTAMPTZ DEFAULT NULL, + _lastChanged_lt TIMESTAMPTZ DEFAULT NULL, + _lastChanged_lte TIMESTAMPTZ DEFAULT NULL, + _mainVersionInclude SMALLINT DEFAULT NULL, + _mainVersionExclude SMALLINT DEFAULT NULL, + _msgBoxInterval_eq TIMESTAMPTZ DEFAULT NULL, + _msgBoxInterval_gt TIMESTAMPTZ DEFAULT NULL, + _msgBoxInterval_gte TIMESTAMPTZ DEFAULT NULL, + _msgBoxInterval_lt TIMESTAMPTZ DEFAULT NULL, + _msgBoxInterval_lte TIMESTAMPTZ DEFAULT NULL, + _org TEXT DEFAULT NULL, + _process_currentTask TEXT DEFAULT NULL, + _process_ended_eq TEXT DEFAULT NULL, + _process_ended_gt TEXT DEFAULT NULL, + _process_ended_gte TEXT DEFAULT NULL, + _process_ended_lt TEXT DEFAULT NULL, + _process_ended_lte TEXT DEFAULT NULL, + _process_isComplete BOOLEAN DEFAULT NULL, + _search_string TEXT DEFAULT NULL, + _size INTEGER DEFAULT 100, + _sort_ascending BOOLEAN DEFAULT FALSE, + _status_isActiveOrSoftDeleted BOOLEAN DEFAULT NULL, + _status_isArchived BOOLEAN DEFAULT NULL, + _status_isArchivedOrSoftDeleted BOOLEAN DEFAULT NULL, + _status_isHardDeleted BOOLEAN DEFAULT NULL, + _status_isSoftDeleted BOOLEAN DEFAULT NULL, + _visibleAfter_eq TEXT DEFAULT NULL, + _visibleAfter_gt TEXT DEFAULT NULL, + _visibleAfter_gte TEXT DEFAULT NULL, + _visibleAfter_lt TEXT DEFAULT NULL, + _visibleAfter_lte TEXT DEFAULT NULL + ) + RETURNS TABLE (id BIGINT, instance JSONB, element JSONB) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN + IF _sort_ascending IS NULL THEN + _sort_ascending := false; + END IF; + + RETURN QUERY + WITH filteredInstances AS + ( + SELECT i.id, i.instance, i.lastchanged FROM storage.instances i + WHERE 1 = 1 + AND (_continue_idx <= 0 OR + (_continue_idx > 0 AND _sort_ascending = true AND (i.lastchanged > _lastChanged_idx OR (i.lastchanged = _lastChanged_idx AND i.id > _continue_idx))) OR + (_continue_idx > 0 AND _sort_ascending = false AND (i.lastchanged < _lastChanged_idx OR (i.lastchanged = _lastChanged_idx AND i.id < _continue_idx)))) + AND (_appId IS NULL OR i.appid = _appId) + AND (_archiveReference IS NULL OR i.instance ->> 'Id' like '%' || _archiveReference) + AND (_created_gte IS NULL OR i.created >= _created_gte) + AND (_created_gt IS NULL OR i.created > _created_gt) + AND (_created_lte IS NULL OR i.created <= _created_lte) + AND (_created_lt IS NULL OR i.created < _created_lt) + AND (_created_eq IS NULL OR i.created = _created_eq) + AND (_dueBefore_gte IS NULL OR i.instance ->> 'DueBefore' >= _dueBefore_gte) + AND (_dueBefore_gt IS NULL OR i.instance ->> 'DueBefore' > _dueBefore_gt) + AND (_dueBefore_lte IS NULL OR i.instance ->> 'DueBefore' <= _dueBefore_lte) + AND (_dueBefore_lt IS NULL OR i.instance ->> 'DueBefore' < _dueBefore_lt) + AND (_dueBefore_eq IS NULL OR i.instance ->> 'DueBefore' = _dueBefore_eq) + AND (_excludeConfirmedBy IS NULL OR i.instance -> 'CompleteConfirmations' IS NULL OR NOT i.instance -> 'CompleteConfirmations' @> ANY (_excludeConfirmedBy)) + AND (_instanceOwner_partyId IS NULL OR partyId = _instanceOwner_partyId) + AND (_instanceOwner_partyIds IS NULL OR partyId = ANY(_instanceOwner_partyIds)) + AND (_lastChanged_gte IS NULL OR i.lastchanged >= _lastChanged_gte) + AND (_lastChanged_gt IS NULL OR i.lastchanged > _lastChanged_gt) + AND (_lastChanged_lte IS NULL OR i.lastchanged <= _lastChanged_lte) + AND (_lastChanged_lt IS NULL OR i.lastchanged < _lastChanged_lt) + AND (_lastChanged_eq IS NULL OR i.lastchanged = _lastChanged_eq) + AND (_mainVersionInclude IS NULL OR i.altinnmainversion = _mainVersionInclude) + AND (_mainVersionExclude IS NULL OR i.altinnmainversion <> _mainVersionExclude) + AND (_msgBoxInterval_gte IS NULL OR ((i.instance -> 'Status' -> 'IsArchived')::boolean = false AND i.created >= _msgBoxInterval_gte OR (i.instance -> 'Status' -> 'IsArchived')::boolean = true AND i.lastchanged >= _msgBoxInterval_gte)) + AND (_msgBoxInterval_gt IS NULL OR ((i.instance -> 'Status' -> 'IsArchived')::boolean = false AND i.created > _msgBoxInterval_gt OR (i.instance -> 'Status' -> 'IsArchived')::boolean = true AND i.lastchanged > _msgBoxInterval_gt)) + AND (_msgBoxInterval_lte IS NULL OR ((i.instance -> 'Status' -> 'IsArchived')::boolean = false AND i.created <= _msgBoxInterval_lte OR (i.instance -> 'Status' -> 'IsArchived')::boolean = true AND i.lastchanged <= _msgBoxInterval_lte)) + AND (_msgBoxInterval_lt IS NULL OR ((i.instance -> 'Status' -> 'IsArchived')::boolean = false AND i.created < _msgBoxInterval_lt OR (i.instance -> 'Status' -> 'IsArchived')::boolean = true AND i.lastchanged < _msgBoxInterval_lt)) + AND (_msgBoxInterval_eq IS NULL OR ((i.instance -> 'Status' -> 'IsArchived')::boolean = false AND i.created = _msgBoxInterval_eq OR (i.instance -> 'Status' -> 'IsArchived')::boolean = true AND i.lastchanged = _msgBoxInterval_eq)) + AND (_org IS NULL OR i.org = _org) + AND (_process_currentTask IS NULL OR i.instance -> 'Process' -> 'CurrentTask' ->> 'ElementId' = _process_currentTask) + AND (_process_ended_gte IS NULL OR i.instance -> 'Process' ->> 'Ended' >= _process_ended_gte) + AND (_process_ended_gt IS NULL OR i.instance -> 'Process' ->> 'Ended' > _process_ended_gt) + AND (_process_ended_lte IS NULL OR i.instance -> 'Process' ->> 'Ended' <= _process_ended_lte) + AND (_process_ended_lt IS NULL OR i.instance -> 'Process' ->> 'Ended' < _process_ended_lt) + AND (_process_ended_eq IS NULL OR i.instance -> 'Process' ->> 'Ended' = _process_ended_eq) + AND (_process_isComplete IS NULL OR (_process_isComplete = TRUE AND i.instance -> 'Process' -> 'Ended' IS NOT NULL) OR (_process_isComplete = FALSE AND i.instance -> 'Process' -> 'CurrentTask' IS NOT NULL)) + AND (_search_string IS NULL OR (i.appid = ANY(_appIds) OR (EXISTS (SELECT value FROM jsonb_each_text(i.instance -> 'PresentationTexts') WHERE value ilike _search_string)))) + AND ((_status_isActiveOrSoftDeleted IS NULL OR _status_isActiveOrSoftDeleted = false) OR ((i.instance -> 'Status' -> 'IsArchived')::boolean = false OR (i.instance -> 'Status' -> 'IsSoftDeleted')::boolean = true)) + AND (_status_isArchived IS NULL OR (i.instance -> 'Status' -> 'IsArchived')::boolean = _status_isArchived) + AND ((_status_isArchivedOrSoftDeleted IS NULL OR _status_isArchivedOrSoftDeleted = false) OR ((i.instance -> 'Status' -> 'IsArchived')::boolean = true OR (i.instance -> 'Status' -> 'IsSoftDeleted')::boolean = true)) + AND (_status_isHardDeleted IS NULL OR (i.instance -> 'Status' -> 'IsHardDeleted')::boolean = _status_isHardDeleted) + AND (_status_isSoftDeleted IS NULL OR (i.instance -> 'Status' -> 'IsSoftDeleted')::boolean = _status_isSoftDeleted) + AND (_visibleAfter_gte IS NULL OR i.instance ->> 'VisibleAfter' >= _visibleAfter_gte) + AND (_visibleAfter_gt IS NULL OR i.instance ->> 'VisibleAfter' > _visibleAfter_gt) + AND (_visibleAfter_lte IS NULL OR i.instance ->> 'VisibleAfter' <= _visibleAfter_lte) + AND (_visibleAfter_lt IS NULL OR i.instance ->> 'VisibleAfter' < _visibleAfter_lt) + AND (_visibleAfter_eq IS NULL OR i.instance ->> 'VisibleAfter' = _visibleAfter_eq) + ORDER BY + (CASE WHEN _sort_ascending = true THEN i.lastChanged END) ASC, + (CASE WHEN _sort_ascending = false THEN i.lastChanged END) DESC, + i.id + FETCH FIRST _size ROWS ONLY + ) + SELECT filteredInstances.id, filteredInstances.instance, d.element FROM filteredInstances + LEFT JOIN storage.dataelements d ON filteredInstances.id = d.instanceInternalId AND _includeElements = TRUE + ORDER BY + (CASE WHEN _sort_ascending = true THEN filteredInstances.lastChanged END) ASC, + (CASE WHEN _sort_ascending = false THEN filteredInstances.lastChanged END) DESC, + filteredInstances.id; +END; +$BODY$; + +-- readinstancenoelements.sql: +CREATE OR REPLACE FUNCTION storage.readinstancenoelements(_alternateid UUID) + RETURNS TABLE (id BIGINT, instance JSONB) + LANGUAGE 'plpgsql' + +AS $BODY$ +BEGIN +RETURN QUERY + SELECT i.id, i.instance FROM storage.instances i + WHERE i.alternateid = _alternateid; +END; +$BODY$; + +-- updatea1migrationstatestarted.sql: +CREATE OR REPLACE PROCEDURE storage.updatea1migrationstatestarted (_a1archivereference BIGINT, _instanceguid UUID) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + UPDATE storage.a1migrationstate SET instanceguid = _instanceguid, started = now() + WHERE a1archivereference = _a1archivereference; +END; +$BODY$; + +-- updatea2migrationstatestarted.sql: +CREATE OR REPLACE PROCEDURE storage.updatea2migrationstatestarted (_a2archivereference BIGINT, _instanceguid UUID) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + UPDATE storage.a2migrationstate SET instanceguid = _instanceguid, started = now() + WHERE a2archivereference = _a2archivereference; +END; +$BODY$; + +-- updatedataelement.sql: +CREATE OR REPLACE FUNCTION storage.updatedataelement_v2(_dataelementGuid UUID, _instanceGuid UUID, _elementChanges JSONB, _instanceChanges JSONB, _isReadChangedToFalse BOOL, _lastChanged TIMESTAMPTZ) + RETURNS TABLE (updatedElement JSONB) + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + _lastChanged6digits TEXT; +BEGIN + IF _lastChanged IS NOT NULL + THEN + -- Make sure that lastChanged has the Postgres precision (6 digits). The timestamp from C# DateTime and then json serialize has 7 digits + _lastChanged6digits = REPLACE((_lastChanged AT TIME ZONE 'UTC')::TEXT, ' ', 'T') || 'Z'; + _elementChanges := _elementChanges || jsonb_set('{"LastChanged":""}', '{LastChanged}', to_jsonb(_lastChanged6digits)); + END IF; + + IF _isReadChangedToFalse = true AND + (SELECT COUNT(*) FROM storage.dataelements + WHERE element -> 'IsRead' = 'true' AND instanceguid = _instanceGuid AND alternateid <> _dataelementGuid) = 0 + THEN + UPDATE storage.instances + SET instance = jsonb_set(instance, '{Status, ReadStatus}', '0') + WHERE alternateid = _instanceGuid AND instance -> 'Status' ->> 'ReadStatus' = '1'; + END IF; + + IF _lastChanged IS NOT NULL + THEN + UPDATE storage.instances + SET lastchanged = _lastChanged, + instance = instance || _instanceChanges || jsonb_set('{"LastChanged":""}', '{LastChanged}', to_jsonb(_lastChanged6digits)) + WHERE alternateid = _instanceGuid; + END IF; + + RETURN QUERY + UPDATE storage.dataelements SET element = element || _elementChanges WHERE alternateid = _dataelementGuid + RETURNING element; +END; +$BODY$; + +-- updateinstance.sql: +CREATE OR REPLACE FUNCTION storage.updateinstance_v2( + _alternateid UUID, + _toplevelsimpleprops JSONB, + _datavalues JSONB, + _completeconfirmations JSONB, + _presentationtexts JSONB, + _status JSONB, + _substatus JSONB, + _process JSONB, + _lastchanged TIMESTAMPTZ, + _taskid TEXT) + RETURNS TABLE (updatedInstance JSONB) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + IF _datavalues IS NOT NULL THEN + RETURN QUERY + UPDATE storage.instances SET + instance = instance || _toplevelsimpleprops || + jsonb_strip_nulls( + jsonb_set( + '{"DataValues":""}', + '{DataValues}', + CASE WHEN instance -> 'DataValues' IS NOT NULL THEN + instance -> 'DataValues' || _datavalues + ELSE + _datavalues + END + ) + ), + lastchanged = _lastchanged + WHERE _alternateid = alternateid + RETURNING instance; + ELSIF _presentationtexts IS NOT NULL THEN + RETURN QUERY + UPDATE storage.instances SET + instance = instance || _toplevelsimpleprops || + jsonb_strip_nulls( + jsonb_set( + '{"PresentationTexts":""}', + '{PresentationTexts}', + CASE WHEN instance -> 'PresentationTexts' IS NOT NULL THEN + instance -> 'PresentationTexts' || _presentationtexts + ELSE + _presentationtexts + END + ) + ), + lastchanged = _lastchanged + WHERE _alternateid = alternateid + RETURNING instance; + ELSIF _completeconfirmations IS NOT NULL THEN + RETURN QUERY + UPDATE storage.instances SET + instance = instance || _toplevelsimpleprops || + jsonb_set( + '{"CompleteConfirmations":""}', + '{CompleteConfirmations}', + CASE WHEN instance -> 'CompleteConfirmations' IS NOT NULL THEN + instance -> 'CompleteConfirmations' || _completeconfirmations + ELSE + _completeconfirmations + END + ), + lastchanged = _lastchanged + WHERE _alternateid = alternateid + RETURNING instance; + ELSIF _status IS NOT NULL AND _process IS NULL THEN + RETURN QUERY + UPDATE storage.instances SET + instance = instance || + jsonb_set( + instance || _toplevelsimpleprops, + '{Status}', + CASE WHEN instance -> 'Status' IS NOT NULL THEN + instance -> 'Status' || _status + ELSE + _status + END + ), + lastchanged = _lastchanged + WHERE _alternateid = alternateid + RETURNING instance; + ELSIF _substatus IS NOT NULL THEN + RETURN QUERY + UPDATE storage.instances SET + instance = instance || + jsonb_set( + instance || _toplevelsimpleprops, + '{Status, Substatus}', + jsonb_strip_nulls(_substatus) + ), + lastchanged = _lastchanged + WHERE _alternateid = alternateid + RETURNING instance; + ELSIF _process IS NOT NULL AND _status IS NOT NULL THEN + RETURN QUERY + UPDATE storage.instances SET + instance = instance || + jsonb_set( + instance || _toplevelsimpleprops, + '{Process}', + jsonb_strip_nulls(_process) + ) || + jsonb_set( + '{"Status":""}', + '{Status}', + CASE WHEN instance -> 'Status' IS NOT NULL THEN + instance -> 'Status' || _status + ELSE + _status + END + ), + lastchanged = _lastchanged, + taskid = _taskid + WHERE _alternateid = alternateid + RETURNING instance; + ELSIF _process IS NOT NULL THEN + RETURN QUERY + UPDATE storage.instances SET + instance = instance || + jsonb_set( + instance || _toplevelsimpleprops, + '{Process}', + jsonb_strip_nulls(_process) + ), + lastchanged = _lastchanged, + taskid = _taskid + WHERE _alternateid = alternateid + RETURNING instance; + ELSE + RAISE EXCEPTION 'Unexpected parameters to update instance'; + END IF; +END; +$BODY$; + + +-- updatemigrationstatecompleted.sql: +CREATE OR REPLACE PROCEDURE storage.updatemigrationstatecompleted (_instanceguid UUID) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + UPDATE storage.a1migrationstate SET completed = now() + WHERE instanceguid = _instanceguid; + UPDATE storage.a2migrationstate SET completed = now() + WHERE instanceguid = _instanceguid; +END; +$BODY$; + diff --git a/src/Storage/Repository/IInstanceAndEventsRepository.cs b/src/Storage/Repository/IInstanceAndEventsRepository.cs new file mode 100644 index 00000000..a1c115fa --- /dev/null +++ b/src/Storage/Repository/IInstanceAndEventsRepository.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Altinn.Platform.Storage.Interface.Models; + +namespace Altinn.Platform.Storage.Repository; + +/// +/// Represents an implementation of . +/// +public interface IInstanceAndEventsRepository +{ + /// + /// update existing instance including instance events + /// + /// the instance to update + /// a list of which properties should be updated + /// the events to add + /// The updated instance + Task Update(Instance instance, List updateProperties, List events); +} diff --git a/src/Storage/Repository/PgInstanceAndEventsRepository.cs b/src/Storage/Repository/PgInstanceAndEventsRepository.cs new file mode 100644 index 00000000..e0fc0bf8 --- /dev/null +++ b/src/Storage/Repository/PgInstanceAndEventsRepository.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using Altinn.Platform.Storage.Interface.Models; +using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; + +namespace Altinn.Platform.Storage.Repository; + +/// +/// Represents an implementation of . +/// +public class PgInstanceAndEventsRepository : IInstanceAndEventsRepository +{ + private readonly ILogger _logger; + private readonly NpgsqlDataSource _dataSource; + private readonly TelemetryClient _telemetryClient; + private readonly IInstanceRepository _instanceRepository; + + private readonly string _insertInstanceEventsSql = "call storage.insertinstanceevents($1, $2)"; + + /// + /// Initializes a new instance of the class. + /// + /// The logger to use when writing to logs. + /// The npgsql data source. + /// Instance repo + /// Telemetry client + public PgInstanceAndEventsRepository( + ILogger logger, + NpgsqlDataSource dataSource, + IInstanceRepository instanceRepository, + TelemetryClient telemetryClient = null) + { + _logger = logger; + _dataSource = dataSource; + _instanceRepository = instanceRepository; + _telemetryClient = telemetryClient; + } + + /// + public async Task Update(Instance instance, List updateProperties, List events) + { + if (events.Count == 0) + { + return await _instanceRepository.Update(instance, updateProperties); + } + + foreach (var instanceEvent in events) + { + instanceEvent.Id ??= Guid.NewGuid(); + } + + // Remove last decimal digit to make postgres TIMESTAMPTZ equal to json serialized DateTime + instance.LastChanged = instance.LastChanged != null ? new DateTime((((DateTime)instance.LastChanged).Ticks / 10) * 10, DateTimeKind.Utc) : null; + List dataElements = instance.Data; + + PgInstanceRepository.ToInternal(instance); + instance.Data = null; + await using NpgsqlBatch batch = _dataSource.CreateBatch(); + + NpgsqlBatchCommand updateCommand = new(PgInstanceRepository.UpdateSql); + PgInstanceRepository.BuildUpdateCommand(instance, updateProperties, updateCommand.Parameters); + batch.BatchCommands.Add(updateCommand); + + NpgsqlBatchCommand insertEventsComand = new(_insertInstanceEventsSql); + insertEventsComand.Parameters.AddWithValue(NpgsqlDbType.Uuid, new Guid(instance.Id.Split('/').Last())); + insertEventsComand.Parameters.AddWithValue(NpgsqlDbType.Jsonb, events); + batch.BatchCommands.Add(insertEventsComand); + + await using NpgsqlDataReader reader = await batch.ExecuteReaderAsync(); + + if (await reader.ReadAsync()) + { + instance = await reader.GetFieldValueAsync("updatedInstance"); + } + + instance.Data = dataElements; // TODO: requery instead? + + return PgInstanceRepository.ToExternal(instance); + } +} diff --git a/src/Storage/Repository/PgInstanceRepository.cs b/src/Storage/Repository/PgInstanceRepository.cs index 6d8456fd..cbdf59e5 100644 --- a/src/Storage/Repository/PgInstanceRepository.cs +++ b/src/Storage/Repository/PgInstanceRepository.cs @@ -28,7 +28,12 @@ public class PgInstanceRepository : IInstanceRepository private const string _readSqlFilteredInitial = "select * from storage.readinstancefromquery_v5 ("; private readonly string _deleteSql = "select * from storage.deleteinstance ($1)"; private readonly string _insertSql = "call storage.insertinstance_v2 (@_partyid, @_alternateid, @_instance, @_created, @_lastchanged, @_org, @_appid, @_taskid, @_altinnmainversion)"; - private readonly string _updateSql = "select * from storage.updateinstance_v2 (@_alternateid, @_toplevelsimpleprops, @_datavalues, @_completeconfirmations, @_presentationtexts, @_status, @_substatus, @_process, @_lastchanged, @_taskid)"; + + /// + /// SQL for updating an instance. + /// + internal static readonly string UpdateSql = "select * from storage.updateinstance_v2 (@_alternateid, @_toplevelsimpleprops, @_datavalues, @_completeconfirmations, @_presentationtexts, @_status, @_substatus, @_process, @_lastchanged, @_taskid)"; + private readonly string _readSql = "select * from storage.readinstance ($1)"; private readonly string _readSqlFiltered = _readSqlFilteredInitial; private readonly string _readDeletedSql = "select * from storage.readdeletedinstances ()"; @@ -372,18 +377,9 @@ public async Task Update(Instance instance, List updatePropert ToInternal(instance); instance.Data = null; - await using NpgsqlCommand pgcom = _dataSource.CreateCommand(_updateSql); + await using NpgsqlCommand pgcom = _dataSource.CreateCommand(UpdateSql); using TelemetryTracker tracker = new(_telemetryClient, pgcom); - pgcom.Parameters.AddWithValue("_alternateid", NpgsqlDbType.Uuid, new Guid(instance.Id)); - pgcom.Parameters.AddWithValue("_toplevelsimpleprops", NpgsqlDbType.Jsonb, CustomSerializer.Serialize(instance, updateProperties)); - pgcom.Parameters.AddWithValue("_datavalues", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.DataValues)) ? instance.DataValues : DBNull.Value); - pgcom.Parameters.AddWithValue("_completeconfirmations", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.CompleteConfirmations)) ? instance.CompleteConfirmations : DBNull.Value); - pgcom.Parameters.AddWithValue("_presentationtexts", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.PresentationTexts)) ? instance.PresentationTexts : DBNull.Value); - pgcom.Parameters.AddWithValue("_status", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.Status)) ? CustomSerializer.Serialize(instance.Status, updateProperties) : DBNull.Value); - pgcom.Parameters.AddWithValue("_substatus", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.Status.Substatus)) ? instance.Status.Substatus : DBNull.Value); - pgcom.Parameters.AddWithValue("_process", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.Process)) ? instance.Process : DBNull.Value); - pgcom.Parameters.AddWithValue("_lastchanged", NpgsqlDbType.TimestampTz, instance.LastChanged ?? DateTime.UtcNow); - pgcom.Parameters.AddWithValue("_taskid", NpgsqlDbType.Text, instance.Process?.CurrentTask?.ElementId ?? (object)DBNull.Value); + BuildUpdateCommand(instance, updateProperties, pgcom.Parameters); await using NpgsqlDataReader reader = await pgcom.ExecuteReaderAsync(); if (await reader.ReadAsync()) @@ -396,15 +392,44 @@ public async Task Update(Instance instance, List updatePropert return ToExternal(instance); } - private static void ToInternal(Instance instance) + /// + /// Builds the update command for the instance. + /// + /// Instance + /// Updated props + /// Parameters + internal static void BuildUpdateCommand(Instance instance, List updateProperties, NpgsqlParameterCollection parameters) + { + parameters.AddWithValue("_alternateid", NpgsqlDbType.Uuid, new Guid(instance.Id)); + parameters.AddWithValue("_toplevelsimpleprops", NpgsqlDbType.Jsonb, CustomSerializer.Serialize(instance, updateProperties)); + parameters.AddWithValue("_datavalues", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.DataValues)) ? instance.DataValues : DBNull.Value); + parameters.AddWithValue("_completeconfirmations", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.CompleteConfirmations)) ? instance.CompleteConfirmations : DBNull.Value); + parameters.AddWithValue("_presentationtexts", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.PresentationTexts)) ? instance.PresentationTexts : DBNull.Value); + parameters.AddWithValue("_status", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.Status)) ? CustomSerializer.Serialize(instance.Status, updateProperties) : DBNull.Value); + parameters.AddWithValue("_substatus", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.Status.Substatus)) ? instance.Status.Substatus : DBNull.Value); + parameters.AddWithValue("_process", NpgsqlDbType.Jsonb, updateProperties.Contains(nameof(instance.Process)) ? instance.Process : DBNull.Value); + parameters.AddWithValue("_lastchanged", NpgsqlDbType.TimestampTz, instance.LastChanged ?? DateTime.UtcNow); + parameters.AddWithValue("_taskid", NpgsqlDbType.Text, instance.Process?.CurrentTask?.ElementId ?? (object)DBNull.Value); + } + + /// + /// Converts the instance to internal format. + /// + /// Instance + internal static void ToInternal(Instance instance) { if (instance.Id.Contains('/', StringComparison.Ordinal)) { instance.Id = instance.Id.Split('/')[1]; } } - - private static Instance ToExternal(Instance instance) + + /// + /// Converts the instance to external format. + /// + /// Instance + /// + internal static Instance ToExternal(Instance instance) { if (!instance.Id.Contains('/', StringComparison.Ordinal)) { diff --git a/src/Storage/Services/IInstanceEventService.cs b/src/Storage/Services/IInstanceEventService.cs index e1878e88..70db2db8 100644 --- a/src/Storage/Services/IInstanceEventService.cs +++ b/src/Storage/Services/IInstanceEventService.cs @@ -10,6 +10,14 @@ namespace Altinn.Platform.Storage.Services /// public interface IInstanceEventService { + /// + /// Construct an instance event given a type + /// + /// Event type + /// Instance + /// + public InstanceEvent BuildInstanceEvent(InstanceEventType eventType, Instance instance); + /// /// Dispatch an instance event to the repository /// diff --git a/src/Storage/Services/InstanceEventService.cs b/src/Storage/Services/InstanceEventService.cs index 438dab17..0f1abfb0 100644 --- a/src/Storage/Services/InstanceEventService.cs +++ b/src/Storage/Services/InstanceEventService.cs @@ -28,7 +28,7 @@ public InstanceEventService(IInstanceEventRepository repository, IHttpContextAcc } /// - public async Task DispatchEvent(InstanceEventType eventType, Instance instance) + public InstanceEvent BuildInstanceEvent(InstanceEventType eventType, Instance instance) { var user = _contextAccessor.HttpContext.User; @@ -48,6 +48,14 @@ public async Task DispatchEvent(InstanceEventType eventType, Instance instance) Created = DateTime.UtcNow, }; + return instanceEvent; + } + + /// + public async Task DispatchEvent(InstanceEventType eventType, Instance instance) + { + var instanceEvent = BuildInstanceEvent(eventType, instance); + await _repository.InsertInstanceEvent(instanceEvent); } diff --git a/test/UnitTest/Extensions/ServiceCollectionExtensions.cs b/test/UnitTest/Extensions/ServiceCollectionExtensions.cs index 79b054a1..9ffc373c 100644 --- a/test/UnitTest/Extensions/ServiceCollectionExtensions.cs +++ b/test/UnitTest/Extensions/ServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ public static IServiceCollection AddPostgresRepositories(this IServiceCollection .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddNpgsqlDataSource(connectionString, builder => builder.EnableDynamicJson()); } diff --git a/test/UnitTest/Mocks/Repository/InstanceAndEventsRepositoryMock.cs b/test/UnitTest/Mocks/Repository/InstanceAndEventsRepositoryMock.cs new file mode 100644 index 00000000..984252ed --- /dev/null +++ b/test/UnitTest/Mocks/Repository/InstanceAndEventsRepositoryMock.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Platform.Storage.Repository; + +namespace Altinn.Platform.Storage.UnitTest.Mocks.Repository +{ + public class InstanceAndEventsRepositoryMock : IInstanceAndEventsRepository + { + public Task Update(Instance instance, List updateProperties, List events) + { + if (instance.Id.Equals("1337/d3b326de-2dd8-49a1-834a-b1d23b11e540")) + { + return Task.FromResult(null); + } + + instance.Data = new List(); + + return Task.FromResult(instance); + } + } +} diff --git a/test/UnitTest/TestingControllers/ProcessControllerTest.cs b/test/UnitTest/TestingControllers/ProcessControllerTest.cs index 6dc4f092..5c213e27 100644 --- a/test/UnitTest/TestingControllers/ProcessControllerTest.cs +++ b/test/UnitTest/TestingControllers/ProcessControllerTest.cs @@ -5,15 +5,13 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Threading.Tasks; - +using Altinn.Common.AccessToken.Configuration; using Altinn.Common.AccessToken.Services; using Altinn.Common.PEP.Interfaces; using Altinn.Platform.Storage.Clients; using Altinn.Platform.Storage.Controllers; -using Altinn.Platform.Storage.Interface.Enums; using Altinn.Platform.Storage.Interface.Models; using Altinn.Platform.Storage.Repository; -using Altinn.Platform.Storage.Services; using Altinn.Platform.Storage.Tests.Mocks; using Altinn.Platform.Storage.UnitTest.Fixture; using Altinn.Platform.Storage.UnitTest.Mocks; @@ -49,6 +47,46 @@ public ProcessControllerTest(TestApplicationFactory factory) _factory = factory; } + private async Task SendUpdateRequest( + bool useInstanceAndEventsEndpoint, + string token, + string instanceId = null, + IInstanceRepository instanceRepository = null, + IInstanceAndEventsRepository instanceAndEventsRepository = null, + Action configure = null) + { + instanceId ??= "1337/20b1353e-91cf-44d6-8ff7-f68993638ffe"; + string requestUri = $"storage/api/v1/instances/{instanceId}/process/"; + JsonContent jsonString; + if (useInstanceAndEventsEndpoint) + { + requestUri += "instanceandevents/"; + ProcessStateUpdate update = new(); + ProcessState state = update.State = new(); + configure?.Invoke(state); + jsonString = JsonContent.Create(update, new MediaTypeHeaderValue("application/json")); + } + else + { + ProcessState state = new(); + configure?.Invoke(state); + jsonString = JsonContent.Create(state, new MediaTypeHeaderValue("application/json")); + } + + HttpClient client = GetTestClient(instanceRepository, instanceAndEventsRepository); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + // Act + return await client.PutAsync(requestUri, jsonString); + } + + public static TheoryData UpdateTestParameters => + new() + { + { true }, + { false }, + }; + /// /// Test case: User has to low authentication level. /// Expected: Returns status forbidden. @@ -64,7 +102,7 @@ public async Task GetProcessHistory_UserHasToLowAuthLv_ReturnStatusForbidden() client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.GetAsync(requestUri); + using HttpResponseMessage response = await client.GetAsync(requestUri); // Assert Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); @@ -84,7 +122,7 @@ public async Task GetProcessHistory_ReponseIsDeny_ReturnStatusForbidden() client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.GetAsync(requestUri); + using HttpResponseMessage response = await client.GetAsync(requestUri); // Assert Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); @@ -105,7 +143,7 @@ public async Task GetProcessHistory_UserIsAuthorized_ReturnsEmptyProcessHistoryR client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.GetAsync(requestUri); + using HttpResponseMessage response = await client.GetAsync(requestUri); string responseString = await response.Content.ReadAsStringAsync(); ProcessHistoryList processHistory = JsonConvert.DeserializeObject(responseString); @@ -118,21 +156,15 @@ public async Task GetProcessHistory_UserIsAuthorized_ReturnsEmptyProcessHistoryR /// Test case: User has to low authentication level. /// Expected: Returns status forbidden. /// - [Fact] - public async Task PutProcess_UserHasToLowAuthLv_ReturnStatusForbidden() + [Theory] + [MemberData(nameof(UpdateTestParameters))] + public async Task PutProcess_UserHasToLowAuthLv_ReturnStatusForbidden(bool useInstanceAndEventsEndpoint) { // Arrange - string requestUri = $"storage/api/v1/instances/1337/ae3fe2fa-1fcb-42b4-8e63-69a42d4e3502/process/"; - - ProcessState state = new ProcessState(); - JsonContent jsonString = JsonContent.Create(state, new MediaTypeHeaderValue("application/json")); - - HttpClient client = GetTestClient(); string token = PrincipalUtil.GetToken(3, 1337, 1); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.PutAsync(requestUri, jsonString); + using HttpResponseMessage response = await SendUpdateRequest(useInstanceAndEventsEndpoint, token: token, instanceId: "1337/ae3fe2fa-1fcb-42b4-8e63-69a42d4e3502"); // Assert Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); @@ -142,21 +174,15 @@ public async Task PutProcess_UserHasToLowAuthLv_ReturnStatusForbidden() /// Test case: Response is deny. /// Expected: Returns status forbidden. /// - [Fact] - public async Task PutProcess_PDPResponseIsDeny_ReturnStatusForbidden() + [Theory] + [MemberData(nameof(UpdateTestParameters))] + public async Task PutProcess_PDPResponseIsDeny_ReturnStatusForbidden(bool useInstanceAndEventsEndpoint) { // Arrange - string requestUri = $"storage/api/v1/instances/1337/ae3fe2fa-1fcb-42b4-8e63-69a42d4e3502/process/"; - - ProcessState state = new ProcessState(); - JsonContent jsonString = JsonContent.Create(state, new MediaTypeHeaderValue("application/json")); - - HttpClient client = GetTestClient(); string token = PrincipalUtil.GetToken(-1, 1); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.PutAsync(requestUri, jsonString); + using HttpResponseMessage response = await SendUpdateRequest(useInstanceAndEventsEndpoint, token: token, instanceId: "1337/ae3fe2fa-1fcb-42b4-8e63-69a42d4e3502"); // Assert Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); @@ -166,20 +192,15 @@ public async Task PutProcess_PDPResponseIsDeny_ReturnStatusForbidden() /// Test case: User is Authorized /// Expected: Returns status ok. /// - [Fact] - public async Task PutProcess_UserIsAuthorized_ReturnStatusOK() + [Theory] + [MemberData(nameof(UpdateTestParameters))] + public async Task PutProcess_UserIsAuthorized_ReturnStatusOK(bool useInstanceAndEventsEndpoint) { - // Arrange - string requestUri = $"storage/api/v1/instances/1337/20a1353e-91cf-44d6-8ff7-f68993638ffe/process/"; - ProcessState state = new ProcessState(); - JsonContent jsonString = JsonContent.Create(state, new MediaTypeHeaderValue("application/json")); - - HttpClient client = GetTestClient(); + // Arrange string token = PrincipalUtil.GetToken(3, 1337, 3); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.PutAsync(requestUri, jsonString); + using HttpResponseMessage response = await SendUpdateRequest(useInstanceAndEventsEndpoint, token: token, instanceId: "1337/20a1353e-91cf-44d6-8ff7-f68993638ffe"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -189,24 +210,23 @@ public async Task PutProcess_UserIsAuthorized_ReturnStatusOK() /// Test case: Uses want to go back to a earlier state /// Expected: Returns status ok. /// - [Fact] - public async Task PutProcessGatewayReturn_UserIsAuthorized_ReturnStatusOK() + [Theory] + [MemberData(nameof(UpdateTestParameters))] + public async Task PutProcessGatewayReturn_UserIsAuthorized_ReturnStatusOK(bool useInstanceAndEventsEndpoint) { - // Arrange - string requestUri = $"storage/api/v1/instances/1337/20b1353e-91cf-44d6-8ff7-f68993638ffe/process/"; - ProcessState state = new ProcessState(); - state.CurrentTask = new ProcessElementInfo(); - state.CurrentTask.ElementId = "Task_1"; - state.CurrentTask.FlowType = "AbandonCurrentReturnToNext"; - state.CurrentTask.AltinnTaskType = "data"; - JsonContent jsonString = JsonContent.Create(state, new MediaTypeHeaderValue("application/json")); - - HttpClient client = GetTestClient(); + // Arrange string token = PrincipalUtil.GetToken(3, 1337, 3); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.PutAsync(requestUri, jsonString); + using HttpResponseMessage response = await SendUpdateRequest(useInstanceAndEventsEndpoint, token: token, instanceId: "1337/20b1353e-91cf-44d6-8ff7-f68993638ffe", configure: state => + { + state.CurrentTask = new ProcessElementInfo + { + ElementId = "Task_1", + FlowType = "AbandonCurrentReturnToNext", + AltinnTaskType = "data", + }; + }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -216,20 +236,15 @@ public async Task PutProcessGatewayReturn_UserIsAuthorized_ReturnStatusOK() /// Test case: User wants to updates process on confirimation task. User does not have role required /// Expected: Returns forbidden. /// - [Fact] - public async Task PutProcessConfirm_UserIsNotAuthorized_ReturnDenied() + [Theory] + [MemberData(nameof(UpdateTestParameters))] + public async Task PutProcessConfirm_UserIsNotAuthorized_ReturnDenied(bool useInstanceAndEventsEndpoint) { - // Arrange - string requestUri = $"storage/api/v1/instances/1337/20b1353e-91cf-44d6-8ff7-f68993638ffe/process/"; - ProcessState state = new ProcessState(); - JsonContent jsonString = JsonContent.Create(state, new MediaTypeHeaderValue("application/json")); - - HttpClient client = GetTestClient(); + // Arrange string token = PrincipalUtil.GetToken(3, 1337, 3); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.PutAsync(requestUri, jsonString); + using HttpResponseMessage response = await SendUpdateRequest(useInstanceAndEventsEndpoint, token: token, instanceId: "1337/20b1353e-91cf-44d6-8ff7-f68993638ffe"); // Assert Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); @@ -239,37 +254,40 @@ public async Task PutProcessConfirm_UserIsNotAuthorized_ReturnDenied() /// Test case: User is Authorized /// Expected: Returns status ok. /// - [Fact] - public async Task PutProcess_EndProcess_EnsureArchivedStateIsSet() + [Theory] + [MemberData(nameof(UpdateTestParameters))] + public async Task PutProcess_EndProcess_EnsureArchivedStateIsSet(bool useInstanceAndEventsEndpoint) { // Arrange - string requestUri = $"storage/api/v1/instances/1337/377efa97-80ee-4cc6-8d48-09de12cc273d/process/"; + string token = PrincipalUtil.GetToken(3, 1337, 3); Instance testInstance = TestDataUtil.GetInstance(new Guid("377efa97-80ee-4cc6-8d48-09de12cc273d")); testInstance.Id = $"{testInstance.InstanceOwner.PartyId}/{testInstance.Id}"; - ProcessState state = new ProcessState - { - Started = DateTime.Parse("2020-04-29T13:53:01.7020218Z"), - StartEvent = "StartEvent_1", - Ended = DateTime.UtcNow, - EndEvent = "EndEvent_1" - }; - - JsonContent jsonString = JsonContent.Create(state, new MediaTypeHeaderValue("application/json")); - + Mock repositoryMock = new Mock(); + Mock batchRepositoryMock = new Mock(); repositoryMock.Setup(ir => ir.GetOne(It.IsAny(), true)).ReturnsAsync((testInstance, 0)); - repositoryMock.Setup(ir => ir.Update(It.IsAny(), It.IsAny>())).ReturnsAsync((Instance i, List props) => i); - - HttpClient client = GetTestClient(repositoryMock.Object); - string token = PrincipalUtil.GetToken(3, 1337, 3); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + repositoryMock.Setup(ir => ir.Update(It.IsAny(), It.IsAny>())).ReturnsAsync((Instance i, List _) => i); + batchRepositoryMock.Setup(ir => ir.Update(It.IsAny(), It.IsAny>(), It.IsAny>())).ReturnsAsync((Instance i, List _, List _) => i); // Act - HttpResponseMessage response = await client.PutAsync(requestUri, jsonString); - string responseContent = await response.Content.ReadAsStringAsync(); - Instance actual = (Instance)JsonConvert.DeserializeObject(responseContent, typeof(Instance)); + using HttpResponseMessage response = await SendUpdateRequest( + useInstanceAndEventsEndpoint, + token: token, + instanceId: "1337/377efa97-80ee-4cc6-8d48-09de12cc273d", + instanceRepository: repositoryMock.Object, + instanceAndEventsRepository: batchRepositoryMock.Object, + configure: state => + { + state.Started = DateTime.Parse("2020-04-29T13:53:01.7020218Z"); + state.StartEvent = "StartEvent_1"; + state.Ended = DateTime.UtcNow; + state.EndEvent = "EndEvent_1"; + }); // Assert + string responseContent = await response.Content.ReadAsStringAsync(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Instance actual = (Instance)JsonConvert.DeserializeObject(responseContent, typeof(Instance)); Assert.True(actual.Status.IsArchived); } @@ -277,50 +295,38 @@ public async Task PutProcess_EndProcess_EnsureArchivedStateIsSet() /// Test case: User pushes process to signing step. /// Expected: An instance event of type "sentToSign" is registered. /// - [Fact] - public async Task PutProcess_MoveToSigning_SentToSignEventGenerated() + [Theory] + [MemberData(nameof(UpdateTestParameters))] + public async Task PutProcess_MoveToSigning_SentToSignEventGenerated(bool useInstanceAndEventsEndpoint) { - // Arrange - string requestUri = $"storage/api/v1/instances/1337/20a1353e-91cf-44d6-8ff7-f68993638ffe/process/"; - ProcessState state = new() - { - CurrentTask = new() - { - ElementId = "Task_2", - AltinnTaskType = "signing", - FlowType = "CompleteCurrentMoveToNext" - } - }; - - JsonContent jsonString = JsonContent.Create(state, new MediaTypeHeaderValue("application/json")); - - var serviceMock = new Mock(); - serviceMock.Setup(m => m.DispatchEvent(It.Is(t => t == InstanceEventType.SentToSign), It.IsAny())); - - HttpClient client = GetTestClient(instanceEventService: serviceMock.Object); + // Arrange string token = PrincipalUtil.GetToken(3, 1337, 3); - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); // Act - HttpResponseMessage response = await client.PutAsync(requestUri, jsonString); + using HttpResponseMessage response = await SendUpdateRequest( + useInstanceAndEventsEndpoint, + token: token, + instanceId: "1337/20a1353e-91cf-44d6-8ff7-f68993638ffe", + configure: state => + { + state.CurrentTask = new ProcessElementInfo + { + ElementId = "Task_2", + AltinnTaskType = "signing", + FlowType = "CompleteCurrentMoveToNext" + }; + }); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); - serviceMock.VerifyAll(); } - private HttpClient GetTestClient(IInstanceRepository instanceRepository = null, IInstanceEventService instanceEventService = null) + private HttpClient GetTestClient(IInstanceRepository instanceRepository = null, IInstanceAndEventsRepository instanceAndEventsRepository = null) { // No setup required for these services. They are not in use by the ApplicationController Mock keyVaultWrapper = new Mock(); Mock partiesWrapper = new Mock(); - if (instanceEventService == null) - { - var mock = new Mock(); - instanceEventService = mock.Object; - } - HttpClient client = _factory.WithWebHostBuilder(builder => { IConfiguration configuration = new ConfigurationBuilder().AddJsonFile(ServiceUtil.GetAppsettingsPath()).Build(); @@ -338,7 +344,7 @@ private HttpClient GetTestClient(IInstanceRepository instanceRepository = null, services.AddSingleton(); services.AddSingleton(); services.AddSingleton, JwtCookiePostConfigureOptionsStub>(); - services.AddSingleton(instanceEventService); + services.AddSingleton(); if (instanceRepository != null) { @@ -348,6 +354,15 @@ private HttpClient GetTestClient(IInstanceRepository instanceRepository = null, { services.AddSingleton(); } + + if (instanceAndEventsRepository != null) + { + services.AddSingleton(instanceAndEventsRepository); + } + else + { + services.AddSingleton(); + } }); }).CreateClient(); diff --git a/test/UnitTest/TestingRepositories/InstanceTests.cs b/test/UnitTest/TestingRepositories/InstanceTests.cs index bad043e3..0f056af4 100644 --- a/test/UnitTest/TestingRepositories/InstanceTests.cs +++ b/test/UnitTest/TestingRepositories/InstanceTests.cs @@ -82,6 +82,54 @@ public async Task Instance_Update_Task_Ok() Assert.Equal(newInstance.LastChangedBy, updatedInstance.LastChangedBy); } + /// + /// Test update task with events + /// + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3)] + public async Task Instance_Update_Task_With_Events_Ok(int eventCount) + { + // Arrange + Instance newInstance = TestData.Instance_1_1.Clone(); + newInstance.Process.CurrentTask.Name = "Before update"; + newInstance.Process.StartEvent = "s1"; + newInstance = await _instanceFixture.InstanceRepo.Create(newInstance); + newInstance.Process.CurrentTask.ElementId = "Task_2"; + newInstance.Process.CurrentTask.Name = "After update"; + newInstance.Process.StartEvent = null; + newInstance.Process.EndEvent = "e1"; + newInstance.LastChanged = DateTime.UtcNow; + newInstance.LastChangedBy = "unittest"; + + List updateProperties = []; + updateProperties.Add(nameof(newInstance.LastChanged)); + updateProperties.Add(nameof(newInstance.LastChangedBy)); + updateProperties.Add(nameof(newInstance.Process)); + + List instanceEvents = []; + for (int i = 0; i < eventCount; i++) + { + InstanceEvent instanceEvent = new() { Id = Guid.NewGuid(), InstanceId = newInstance.Id, EventType = $"et{i}", Created = DateTime.Parse("1994-06-16T11:06:59.0851832Z") }; + instanceEvents.Add(instanceEvent); + } + + // Act + Instance updatedInstance = await _instanceFixture.InstanceAndEventsRepo.Update(newInstance, updateProperties, instanceEvents); + + // Assert + if (instanceEvents.Count > 0) + { + string ids = string.Join(", ", instanceEvents.Select(e => $"'{e.Id}'")); + string sql = $"select count(*) from storage.instanceevents where alternateid in ({ids}) AND instance = '{TestData.Instance_1_1.Id.Split('/').Last()}'"; + int count = await PostgresUtil.RunCountQuery(sql); + Assert.Equal(instanceEvents.Count, count); + } + + Assert.Equal("Task_2", updatedInstance.Process.CurrentTask.ElementId); + } + /// /// Test update status /// @@ -733,12 +781,15 @@ public class InstanceFixture { public IInstanceRepository InstanceRepo { get; set; } + public IInstanceAndEventsRepository InstanceAndEventsRepo { get; set; } + public IDataRepository DataRepo { get; set; } public InstanceFixture() { - var serviceList = ServiceUtil.GetServices(new List() { typeof(IInstanceRepository), typeof(IDataRepository) }); + var serviceList = ServiceUtil.GetServices(new List() { typeof(IInstanceRepository), typeof(IInstanceAndEventsRepository), typeof(IDataRepository) }); InstanceRepo = (IInstanceRepository)serviceList.First(i => i.GetType() == typeof(PgInstanceRepository)); + InstanceAndEventsRepo = (IInstanceAndEventsRepository)serviceList.First(i => i.GetType() == typeof(PgInstanceAndEventsRepository)); DataRepo = (IDataRepository)serviceList.First(i => i.GetType() == typeof(PgDataRepository)); } }