From 624e518cd44c197f8d5e96ca0e0f5f97a46937ef Mon Sep 17 00:00:00 2001 From: Tom C Date: Sat, 1 Nov 2025 09:59:13 +0100 Subject: [PATCH 01/20] feat(Conditioning): introduce new enums and models for conditioning elements and their application, including ConditioningApplyOnEnum, ConditioningElementTypeEnum, and related models for structured conditioning logic --- Server/Enums/V2/ConditioningApplyOnEnum.cs | 7 +++++++ Server/Enums/V2/ConditioningElementTypeEnum.cs | 9 +++++++++ Server/Models/V2/ConditioningElementModel.cs | 10 ++++++++++ Server/Models/V2/ConditioningModel.cs | 13 +++++++++++++ .../V2/Conditions/ConditionAndElementModel.cs | 6 ++++++ .../Models/V2/Conditions/IConditionElementModel.cs | 5 +++++ 6 files changed, 50 insertions(+) create mode 100644 Server/Enums/V2/ConditioningApplyOnEnum.cs create mode 100644 Server/Enums/V2/ConditioningElementTypeEnum.cs create mode 100644 Server/Models/V2/ConditioningElementModel.cs create mode 100644 Server/Models/V2/ConditioningModel.cs create mode 100644 Server/Models/V2/Conditions/ConditionAndElementModel.cs create mode 100644 Server/Models/V2/Conditions/IConditionElementModel.cs diff --git a/Server/Enums/V2/ConditioningApplyOnEnum.cs b/Server/Enums/V2/ConditioningApplyOnEnum.cs new file mode 100644 index 0000000..c9d0ed3 --- /dev/null +++ b/Server/Enums/V2/ConditioningApplyOnEnum.cs @@ -0,0 +1,7 @@ +namespace Server.Enums.V2; + +public enum ConditioningApplyOnEnum +{ + Output, // Output of the action or reaction + // External // External data, like date // TODO +} \ No newline at end of file diff --git a/Server/Enums/V2/ConditioningElementTypeEnum.cs b/Server/Enums/V2/ConditioningElementTypeEnum.cs new file mode 100644 index 0000000..21f40cc --- /dev/null +++ b/Server/Enums/V2/ConditioningElementTypeEnum.cs @@ -0,0 +1,9 @@ +namespace Server.Enums.V2; + +public enum ConditioningElementTypeEnum +{ + And, + Or, + Equals, + NotEquals, +} \ No newline at end of file diff --git a/Server/Models/V2/ConditioningElementModel.cs b/Server/Models/V2/ConditioningElementModel.cs new file mode 100644 index 0000000..1a8de4a --- /dev/null +++ b/Server/Models/V2/ConditioningElementModel.cs @@ -0,0 +1,10 @@ +using Server.Models.V2.Conditions; +using Server.Enums.V2; + +namespace Server.Models.V2; + +public class ConditioningElementModel +{ + public ConditioningElementTypeEnum Type { get; set; } + public IConditionElementModel? Value { get; set; } +} \ No newline at end of file diff --git a/Server/Models/V2/ConditioningModel.cs b/Server/Models/V2/ConditioningModel.cs new file mode 100644 index 0000000..d4b9406 --- /dev/null +++ b/Server/Models/V2/ConditioningModel.cs @@ -0,0 +1,13 @@ +using MongoDB.Bson.Serialization.Attributes; +using Server.Enums.V2; + +namespace Server.Models.V2; + +[BsonIgnoreExtraElements] +public class ConditioningModel +{ + public ConditioningApplyOnEnum ApplyOn { get; set; } + public string Source { get; set; } = string.Empty; // Must be the name of the reaction or it will be considerent as the action + public string? OutputName { get; set; } = string.Empty; // Must be the name of the output of the action or reaction if ApplyOn is Output else it will be ignored + public ConditioningElementModel Element { get; set; } = new ConditioningElementModel(); +} \ No newline at end of file diff --git a/Server/Models/V2/Conditions/ConditionAndElementModel.cs b/Server/Models/V2/Conditions/ConditionAndElementModel.cs new file mode 100644 index 0000000..f686d67 --- /dev/null +++ b/Server/Models/V2/Conditions/ConditionAndElementModel.cs @@ -0,0 +1,6 @@ +namespace Server.Models.V2.Conditions; + +public class ConditionAndElementModel : IConditionElementModel +{ + public List Elements { get; set; } = []; +} \ No newline at end of file diff --git a/Server/Models/V2/Conditions/IConditionElementModel.cs b/Server/Models/V2/Conditions/IConditionElementModel.cs new file mode 100644 index 0000000..07b6056 --- /dev/null +++ b/Server/Models/V2/Conditions/IConditionElementModel.cs @@ -0,0 +1,5 @@ +namespace Server.Models.V2.Conditions; + +public interface IConditionElementModel +{ +} \ No newline at end of file From 68dbae5837ac2dc47359d379041f94496e374ec8 Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 09:53:50 +0100 Subject: [PATCH 02/20] chore(api): update API base URL to use secure HTTPS endpoint --- Tests/controller-tester/lib/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/controller-tester/lib/api.ts b/Tests/controller-tester/lib/api.ts index 43b8ccc..447136f 100644 --- a/Tests/controller-tester/lib/api.ts +++ b/Tests/controller-tester/lib/api.ts @@ -1,4 +1,4 @@ -const API_BASE_URL = 'http://104.248.41.165:5001'; +const API_BASE_URL = 'https://area-api.soulsyncs.app'; export async function apiRequest( endpoint: string, From 96a60fad128bf6d43dc72a2ed693eaae17d71f6b Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 09:56:34 +0100 Subject: [PATCH 03/20] feat(ConditionOrElementModel): add ConditionOrElementModel class to represent a collection of condition elements --- Server/Models/V2/Conditions/ConditionOrElementModel.cs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Server/Models/V2/Conditions/ConditionOrElementModel.cs diff --git a/Server/Models/V2/Conditions/ConditionOrElementModel.cs b/Server/Models/V2/Conditions/ConditionOrElementModel.cs new file mode 100644 index 0000000..781dddd --- /dev/null +++ b/Server/Models/V2/Conditions/ConditionOrElementModel.cs @@ -0,0 +1,6 @@ +namespace Server.Models.V2.Conditions; + +public class ConditionOrElementModel : IConditionElementModel +{ + public List Elements { get; set; } = []; +} \ No newline at end of file From 0f70697dc4015f2cd2b328ee13edd0600a8be73e Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 09:58:29 +0100 Subject: [PATCH 04/20] refactor(ConditioningModels): remove outdated ConditioningElementModel and ConditioningModel classes; introduce ConditionEqualElement class for improved condition handling --- Server/Models/V2/ConditioningElementModel.cs | 10 ---------- Server/Models/V2/ConditioningModel.cs | 13 ------------- .../Models/V2/Conditions/ConditionEqualElement.cs | 6 ++++++ 3 files changed, 6 insertions(+), 23 deletions(-) delete mode 100644 Server/Models/V2/ConditioningElementModel.cs delete mode 100644 Server/Models/V2/ConditioningModel.cs create mode 100644 Server/Models/V2/Conditions/ConditionEqualElement.cs diff --git a/Server/Models/V2/ConditioningElementModel.cs b/Server/Models/V2/ConditioningElementModel.cs deleted file mode 100644 index 1a8de4a..0000000 --- a/Server/Models/V2/ConditioningElementModel.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Server.Models.V2.Conditions; -using Server.Enums.V2; - -namespace Server.Models.V2; - -public class ConditioningElementModel -{ - public ConditioningElementTypeEnum Type { get; set; } - public IConditionElementModel? Value { get; set; } -} \ No newline at end of file diff --git a/Server/Models/V2/ConditioningModel.cs b/Server/Models/V2/ConditioningModel.cs deleted file mode 100644 index d4b9406..0000000 --- a/Server/Models/V2/ConditioningModel.cs +++ /dev/null @@ -1,13 +0,0 @@ -using MongoDB.Bson.Serialization.Attributes; -using Server.Enums.V2; - -namespace Server.Models.V2; - -[BsonIgnoreExtraElements] -public class ConditioningModel -{ - public ConditioningApplyOnEnum ApplyOn { get; set; } - public string Source { get; set; } = string.Empty; // Must be the name of the reaction or it will be considerent as the action - public string? OutputName { get; set; } = string.Empty; // Must be the name of the output of the action or reaction if ApplyOn is Output else it will be ignored - public ConditioningElementModel Element { get; set; } = new ConditioningElementModel(); -} \ No newline at end of file diff --git a/Server/Models/V2/Conditions/ConditionEqualElement.cs b/Server/Models/V2/Conditions/ConditionEqualElement.cs new file mode 100644 index 0000000..e838cb2 --- /dev/null +++ b/Server/Models/V2/Conditions/ConditionEqualElement.cs @@ -0,0 +1,6 @@ +namespace Server.Models.V2.Conditions; + +public class ConditionEqualElement : IConditionElementModel +{ + public string Value { get; set; } = string.Empty; +} \ No newline at end of file From 10e6a60791adf96dea0b7780ab05374abc69c0bb Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 10:02:26 +0100 Subject: [PATCH 05/20] refactor(Conditioning): remove ConditioningApplyOnEnum, update ConditioningElementTypeEnum, and introduce AreaConditionModel for enhanced condition handling in areas --- Server/Enums/V2/ConditioningApplyOnEnum.cs | 7 ------- Server/Enums/V2/ConditioningElementTypeEnum.cs | 2 +- Server/Models/V2/AreaConditionModel.cs | 12 ++++++++++++ Server/Models/V2/AreaModel.cs | 1 + .../Models/V2/Conditions/ConditionAndElementModel.cs | 3 +++ Server/Models/V2/Conditions/ConditionEqualElement.cs | 3 +++ .../Models/V2/Conditions/ConditionOrElementModel.cs | 3 +++ 7 files changed, 23 insertions(+), 8 deletions(-) delete mode 100644 Server/Enums/V2/ConditioningApplyOnEnum.cs create mode 100644 Server/Models/V2/AreaConditionModel.cs diff --git a/Server/Enums/V2/ConditioningApplyOnEnum.cs b/Server/Enums/V2/ConditioningApplyOnEnum.cs deleted file mode 100644 index c9d0ed3..0000000 --- a/Server/Enums/V2/ConditioningApplyOnEnum.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Server.Enums.V2; - -public enum ConditioningApplyOnEnum -{ - Output, // Output of the action or reaction - // External // External data, like date // TODO -} \ No newline at end of file diff --git a/Server/Enums/V2/ConditioningElementTypeEnum.cs b/Server/Enums/V2/ConditioningElementTypeEnum.cs index 21f40cc..c989970 100644 --- a/Server/Enums/V2/ConditioningElementTypeEnum.cs +++ b/Server/Enums/V2/ConditioningElementTypeEnum.cs @@ -5,5 +5,5 @@ public enum ConditioningElementTypeEnum And, Or, Equals, - NotEquals, + Not, } \ No newline at end of file diff --git a/Server/Models/V2/AreaConditionModel.cs b/Server/Models/V2/AreaConditionModel.cs new file mode 100644 index 0000000..182ea3d --- /dev/null +++ b/Server/Models/V2/AreaConditionModel.cs @@ -0,0 +1,12 @@ +using MongoDB.Bson.Serialization.Attributes; +using Server.Models.V2.Conditions; + +namespace Server.Models.V2; + +[BsonIgnoreExtraElements] +public class AreaConditionModel +{ + public string? Source { get; set; } = null; // Null if action else reaction name + public string OutputName { get; set; } = string.Empty; + public IConditionElementModel? Element { get; set; } = null; +} \ No newline at end of file diff --git a/Server/Models/V2/AreaModel.cs b/Server/Models/V2/AreaModel.cs index 9dd6639..cecf496 100644 --- a/Server/Models/V2/AreaModel.cs +++ b/Server/Models/V2/AreaModel.cs @@ -16,4 +16,5 @@ public class AreaModel public bool IsActive { get; set; } = true; public ActionModel? Action { get; set; } = null; public List? Reactions { get; set; } = null; + public List? Conditions { get; set; } = null; } \ No newline at end of file diff --git a/Server/Models/V2/Conditions/ConditionAndElementModel.cs b/Server/Models/V2/Conditions/ConditionAndElementModel.cs index f686d67..2af2585 100644 --- a/Server/Models/V2/Conditions/ConditionAndElementModel.cs +++ b/Server/Models/V2/Conditions/ConditionAndElementModel.cs @@ -1,5 +1,8 @@ +using MongoDB.Bson.Serialization.Attributes; + namespace Server.Models.V2.Conditions; +[BsonIgnoreExtraElements] public class ConditionAndElementModel : IConditionElementModel { public List Elements { get; set; } = []; diff --git a/Server/Models/V2/Conditions/ConditionEqualElement.cs b/Server/Models/V2/Conditions/ConditionEqualElement.cs index e838cb2..499ec2e 100644 --- a/Server/Models/V2/Conditions/ConditionEqualElement.cs +++ b/Server/Models/V2/Conditions/ConditionEqualElement.cs @@ -1,5 +1,8 @@ +using MongoDB.Bson.Serialization.Attributes; + namespace Server.Models.V2.Conditions; +[BsonIgnoreExtraElements] public class ConditionEqualElement : IConditionElementModel { public string Value { get; set; } = string.Empty; diff --git a/Server/Models/V2/Conditions/ConditionOrElementModel.cs b/Server/Models/V2/Conditions/ConditionOrElementModel.cs index 781dddd..c85609b 100644 --- a/Server/Models/V2/Conditions/ConditionOrElementModel.cs +++ b/Server/Models/V2/Conditions/ConditionOrElementModel.cs @@ -1,5 +1,8 @@ +using MongoDB.Bson.Serialization.Attributes; + namespace Server.Models.V2.Conditions; +[BsonIgnoreExtraElements] public class ConditionOrElementModel : IConditionElementModel { public List Elements { get; set; } = []; From 430fd6a1e856add53414bda7a3db8d3e0316a492 Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 10:03:30 +0100 Subject: [PATCH 06/20] refactor(ConditionModels): update ConditionEqualElement to allow nullable Value property and introduce ConditionNotElement class for enhanced condition handling --- Server/Models/V2/Conditions/ConditionEqualElement.cs | 2 +- Server/Models/V2/Conditions/ConditionNotElement.cs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 Server/Models/V2/Conditions/ConditionNotElement.cs diff --git a/Server/Models/V2/Conditions/ConditionEqualElement.cs b/Server/Models/V2/Conditions/ConditionEqualElement.cs index 499ec2e..633c155 100644 --- a/Server/Models/V2/Conditions/ConditionEqualElement.cs +++ b/Server/Models/V2/Conditions/ConditionEqualElement.cs @@ -5,5 +5,5 @@ namespace Server.Models.V2.Conditions; [BsonIgnoreExtraElements] public class ConditionEqualElement : IConditionElementModel { - public string Value { get; set; } = string.Empty; + public string? Value { get; set; } = null; } \ No newline at end of file diff --git a/Server/Models/V2/Conditions/ConditionNotElement.cs b/Server/Models/V2/Conditions/ConditionNotElement.cs new file mode 100644 index 0000000..8849a51 --- /dev/null +++ b/Server/Models/V2/Conditions/ConditionNotElement.cs @@ -0,0 +1,9 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace Server.Models.V2.Conditions; + +[BsonIgnoreExtraElements] +public class ConditionNotElement : IConditionElementModel +{ + public IConditionElementModel? Element { get; set; } = null; +} \ No newline at end of file From 861922e8b000a12b017a3ec22d30d3c103a5bd0d Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 13:12:29 +0100 Subject: [PATCH 07/20] feat(proxy): implement API proxy route to handle CORS and forward requests to the backend, including support for GET, POST, PUT, DELETE, and OPTIONS methods --- .../app/api/proxy/[...path]/route.ts | 148 ++++++++++++++++++ Tests/controller-tester/lib/api.ts | 21 ++- 2 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 Tests/controller-tester/app/api/proxy/[...path]/route.ts diff --git a/Tests/controller-tester/app/api/proxy/[...path]/route.ts b/Tests/controller-tester/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..3653419 --- /dev/null +++ b/Tests/controller-tester/app/api/proxy/[...path]/route.ts @@ -0,0 +1,148 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE_URL = 'https://area-api.soulsyncs.app'; + +export async function GET( + request: NextRequest, + context: { params: Promise<{ path: string[] }> } +) { + const params = await context.params; + return handleRequest(request, params, 'GET'); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ path: string[] }> } +) { + const params = await context.params; + return handleRequest(request, params, 'POST'); +} + +export async function PUT( + request: NextRequest, + context: { params: Promise<{ path: string[] }> } +) { + const params = await context.params; + return handleRequest(request, params, 'PUT'); +} + +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ path: string[] }> } +) { + const params = await context.params; + return handleRequest(request, params, 'DELETE'); +} + +async function handleRequest( + request: NextRequest, + params: { path: string[] }, + method: string +) { + try { + // Reconstruct the API endpoint path + const path = params.path.join('/'); + const url = new URL(request.url); + const queryString = url.search.substring(1); + const apiUrl = `${API_BASE_URL}/${path}${queryString ? `?${queryString}` : ''}`; + + if (process.env.NODE_ENV === 'development') { + console.log(`[Proxy] ${method} ${apiUrl}`); + } + + // Get authorization header from the incoming request + const authHeader = request.headers.get('authorization'); + + // Prepare headers - only set Content-Type if there's a body + const headers: HeadersInit = {}; + + if (authHeader) { + headers['Authorization'] = authHeader; + } + + // Get request body for POST/PUT/DELETE requests that might have a body + let body: string | undefined = undefined; + if (method === 'POST' || method === 'PUT' || method === 'DELETE') { + const textBody = await request.text(); + if (textBody) { + body = textBody; + // Only set Content-Type if we have a body + if (!headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } + } + } + + // Forward the request to the actual API + const response = await fetch(apiUrl, { + method, + headers, + body, + }); + + // Get response data + const contentType = response.headers.get('content-type'); + let data; + + if (contentType?.includes('application/json')) { + try { + data = await response.json(); + } catch (e) { + // If JSON parsing fails, try as text + data = await response.text(); + } + } else { + data = await response.text(); + } + + // Return response with CORS headers + return NextResponse.json( + { + status: response.status, + ok: response.ok, + data, + headers: Object.fromEntries(response.headers.entries()), + }, + { + status: response.status, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + } + ); + } catch (error: any) { + // Better error logging + console.error('Proxy error:', error); + return NextResponse.json( + { + status: 0, + ok: false, + error: error.message || 'Unknown error occurred', + details: process.env.NODE_ENV === 'development' ? error.stack : undefined, + }, + { + status: 502, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + } + ); + } +} + +// Handle OPTIONS for CORS preflight +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', + }, + }); +} + diff --git a/Tests/controller-tester/lib/api.ts b/Tests/controller-tester/lib/api.ts index 447136f..436cfe7 100644 --- a/Tests/controller-tester/lib/api.ts +++ b/Tests/controller-tester/lib/api.ts @@ -1,19 +1,22 @@ -const API_BASE_URL = 'https://area-api.soulsyncs.app'; +// Use proxy route to avoid CORS issues +const PROXY_BASE_URL = '/api/proxy'; export async function apiRequest( endpoint: string, options: RequestInit = {} ): Promise { - const url = `${API_BASE_URL}${endpoint}`; + // Remove leading slash if present to avoid double slashes + const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint; + const url = `${PROXY_BASE_URL}/${cleanEndpoint}`; const token = localStorage.getItem('authToken'); - const headers = new Headers({ + const headers: Record = { 'Content-Type': 'application/json', - ...(options.headers as HeadersInit), - }); + ...((options.headers as Record) || {}), + }; if (token && !endpoint.includes('get-anonymous-token')) { - headers.set('Authorization', `Bearer ${token}`); + headers['Authorization'] = `Bearer ${token}`; } try { @@ -31,6 +34,12 @@ export async function apiRequest( data = await response.text(); } + // If the response is already wrapped by our proxy (has status, ok, data properties), + // return it directly. Otherwise, wrap it. + if (data && typeof data === 'object' && 'status' in data && 'ok' in data && 'data' in data) { + return data; + } + return { status: response.status, ok: response.ok, From 5f51d5d0144f345d0eec7b029944a38edffafea4 Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 13:12:43 +0100 Subject: [PATCH 08/20] feat(AreaService): add condition verification logic and extend AreaConditionModel with ElementType property for enhanced condition handling --- Server/Models/V2/AreaConditionModel.cs | 2 + .../V2/Area/AreaService.Conditions.cs | 39 +++++++++++++++++++ Server/Services/V2/Area/AreaService.cs | 31 +++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 Server/Services/V2/Area/AreaService.Conditions.cs diff --git a/Server/Models/V2/AreaConditionModel.cs b/Server/Models/V2/AreaConditionModel.cs index 182ea3d..c4fd7c2 100644 --- a/Server/Models/V2/AreaConditionModel.cs +++ b/Server/Models/V2/AreaConditionModel.cs @@ -1,5 +1,6 @@ using MongoDB.Bson.Serialization.Attributes; using Server.Models.V2.Conditions; +using Server.Enums.V2; namespace Server.Models.V2; @@ -8,5 +9,6 @@ public class AreaConditionModel { public string? Source { get; set; } = null; // Null if action else reaction name public string OutputName { get; set; } = string.Empty; + public ConditioningElementTypeEnum? ElementType { get; set; } = null; public IConditionElementModel? Element { get; set; } = null; } \ No newline at end of file diff --git a/Server/Services/V2/Area/AreaService.Conditions.cs b/Server/Services/V2/Area/AreaService.Conditions.cs new file mode 100644 index 0000000..cbff912 --- /dev/null +++ b/Server/Services/V2/Area/AreaService.Conditions.cs @@ -0,0 +1,39 @@ +using Server.Enums.V2; +using Server.Models.V2; + +namespace Server.Services.V2.Area; + +public partial class AreaService +{ + public Func? GetVerifyConditionsFunction(ConditioningElementTypeEnum elementType) + { + return elementType switch + { + ConditioningElementTypeEnum.Equals => VerifyConditionsEquals, + ConditioningElementTypeEnum.Not => VerifyConditionsNot, + ConditioningElementTypeEnum.And => VerifyConditionsAnd, + ConditioningElementTypeEnum.Or => VerifyConditionsOr, + _ => null + }; + } + + public bool VerifyConditionsEquals(string? source, ActionOutputModel outputs, AreaConditionModel condition) + { + return true; + } + + public bool VerifyConditionsNot(string? source, ActionOutputModel output, AreaConditionModel condition) + { + return true; + } + + public bool VerifyConditionsAnd(string? source, ActionOutputModel output, AreaConditionModel condition) + { + return true; + } + + public bool VerifyConditionsOr(string? source, ActionOutputModel output, AreaConditionModel condition) + { + return true; + } +} \ No newline at end of file diff --git a/Server/Services/V2/Area/AreaService.cs b/Server/Services/V2/Area/AreaService.cs index 2c3df1a..9cd2088 100644 --- a/Server/Services/V2/Area/AreaService.cs +++ b/Server/Services/V2/Area/AreaService.cs @@ -19,6 +19,9 @@ using Server.Processes.Interfaces; using Server.Models.Actions; using Server.Enums.Actions; +using System.ComponentModel; +using MongoDB.Bson.Serialization.Serializers; +using System.Diagnostics; namespace Server.Services.V2.Area; public partial class AreaService(ILogger logger, IMongoDBProvider mongoDBProvider, IGmailLegacyService gmailLegacyService, IOneDriveLegacyService oneDriveService, IOutlookLegacyService outlookLegacyService, IGitHubLegacyService gitHubLegacyService, IGoogleCalendarLegacyService googleCalendarLegacyService, IWeatherLegacyService weatherLegacyService, IScheduler scheduler) @@ -243,10 +246,38 @@ private Task PreProcessTimerAction(UserModel user, AreaModel area) return Task.CompletedTask; } + public bool VerifyCoditions(string? source, List outputs, List conditions) + { + if (string.IsNullOrEmpty(source)) + conditions = [.. conditions.Where(condition => condition.Source == null || string.Equals(condition.Source, "Action", StringComparison.OrdinalIgnoreCase))]; + else + conditions = [.. conditions.Where(condition => condition.Source == source)]; // case sensitive + foreach (var condition in conditions) + { + if (condition.ElementType == null) + continue; + var output = outputs.FirstOrDefault(output => output.Name == condition.OutputName); + if (output == null) + continue; + var verifyConditions = GetVerifyConditionsFunction(condition.ElementType.Value); + if (verifyConditions == null) + continue; + if (!verifyConditions(source, output, condition)) + return false; + } + return true; + } + public async Task ProcessAction(UserModel user, AreaModel area, ActionResultModel actionResult) { Console.WriteLine($"Processing action for area {area.Id}"); Console.WriteLine($"Action result: {JsonConvert.SerializeObject(actionResult)}"); + if (!VerifyCoditions(null, actionResult.Outputs ?? [], area.Conditions ?? [])) + { + _logger.LogWarning("Conditions not met for area {AreaId}", area.Id); + await LogReactionFailure(user, area, "Action", "Conditions not met"); + return; + } var areaLog = new AreaLogModel { AreaId = area.Id, From fba853fcdf4ba5b8695c8934211d483a92a42028 Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 18:14:16 +0100 Subject: [PATCH 09/20] refactor(ConditionModels): restructure ConditionAndElementModel and ConditionOrElementModel to use Children property and introduce ConditionAndChildElementModel and ConditionOrChildElementModel for improved condition handling --- .../Models/V2/Conditions/ConditionAndElementModel.cs | 10 +++++++++- Server/Models/V2/Conditions/ConditionNotElement.cs | 4 +++- Server/Models/V2/Conditions/ConditionOrElementModel.cs | 10 +++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Server/Models/V2/Conditions/ConditionAndElementModel.cs b/Server/Models/V2/Conditions/ConditionAndElementModel.cs index 2af2585..2bc8580 100644 --- a/Server/Models/V2/Conditions/ConditionAndElementModel.cs +++ b/Server/Models/V2/Conditions/ConditionAndElementModel.cs @@ -1,9 +1,17 @@ using MongoDB.Bson.Serialization.Attributes; +using Server.Enums.V2; namespace Server.Models.V2.Conditions; [BsonIgnoreExtraElements] public class ConditionAndElementModel : IConditionElementModel { - public List Elements { get; set; } = []; + public List Children { get; set; } = []; +} + +[BsonIgnoreExtraElements] +public class ConditionAndChildElementModel +{ + public ConditioningElementTypeEnum? Operator { get; set; } = null; + public IConditionElementModel? Element { get; set; } = null; } \ No newline at end of file diff --git a/Server/Models/V2/Conditions/ConditionNotElement.cs b/Server/Models/V2/Conditions/ConditionNotElement.cs index 8849a51..1c21b67 100644 --- a/Server/Models/V2/Conditions/ConditionNotElement.cs +++ b/Server/Models/V2/Conditions/ConditionNotElement.cs @@ -1,9 +1,11 @@ using MongoDB.Bson.Serialization.Attributes; +using Server.Enums.V2; namespace Server.Models.V2.Conditions; [BsonIgnoreExtraElements] public class ConditionNotElement : IConditionElementModel { - public IConditionElementModel? Element { get; set; } = null; + public ConditioningElementTypeEnum? Operator { get; set; } = null; + public IConditionElementModel? Child { get; set; } = null; } \ No newline at end of file diff --git a/Server/Models/V2/Conditions/ConditionOrElementModel.cs b/Server/Models/V2/Conditions/ConditionOrElementModel.cs index c85609b..10708eb 100644 --- a/Server/Models/V2/Conditions/ConditionOrElementModel.cs +++ b/Server/Models/V2/Conditions/ConditionOrElementModel.cs @@ -1,9 +1,17 @@ using MongoDB.Bson.Serialization.Attributes; +using Server.Enums.V2; namespace Server.Models.V2.Conditions; [BsonIgnoreExtraElements] public class ConditionOrElementModel : IConditionElementModel { - public List Elements { get; set; } = []; + public List Children { get; set; } = []; +} + +[BsonIgnoreExtraElements] +public class ConditionOrChildElementModel +{ + public ConditioningElementTypeEnum? Operator { get; set; } = null; + public IConditionElementModel? Element { get; set; } = null; } \ No newline at end of file From 91904301dc659ab47822ca029a0ebd81cd88abc9 Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 19:57:05 +0100 Subject: [PATCH 10/20] refactor(ConditionModels): rename Operator property to ElementType in ConditionAndChildElementModel and ChildElementType in ConditionNotElement for consistency --- Server/Models/V2/Conditions/ConditionAndElementModel.cs | 2 +- Server/Models/V2/Conditions/ConditionNotElement.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/Models/V2/Conditions/ConditionAndElementModel.cs b/Server/Models/V2/Conditions/ConditionAndElementModel.cs index 2bc8580..3eb584a 100644 --- a/Server/Models/V2/Conditions/ConditionAndElementModel.cs +++ b/Server/Models/V2/Conditions/ConditionAndElementModel.cs @@ -12,6 +12,6 @@ public class ConditionAndElementModel : IConditionElementModel [BsonIgnoreExtraElements] public class ConditionAndChildElementModel { - public ConditioningElementTypeEnum? Operator { get; set; } = null; + public ConditioningElementTypeEnum? ElementType { get; set; } = null; public IConditionElementModel? Element { get; set; } = null; } \ No newline at end of file diff --git a/Server/Models/V2/Conditions/ConditionNotElement.cs b/Server/Models/V2/Conditions/ConditionNotElement.cs index 1c21b67..1df840a 100644 --- a/Server/Models/V2/Conditions/ConditionNotElement.cs +++ b/Server/Models/V2/Conditions/ConditionNotElement.cs @@ -6,6 +6,6 @@ namespace Server.Models.V2.Conditions; [BsonIgnoreExtraElements] public class ConditionNotElement : IConditionElementModel { - public ConditioningElementTypeEnum? Operator { get; set; } = null; + public ConditioningElementTypeEnum? ChildElementType { get; set; } = null; public IConditionElementModel? Child { get; set; } = null; } \ No newline at end of file From ff43cb585a167d3830b5f8bb7d1dcd7507365e1d Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 21:39:52 +0100 Subject: [PATCH 11/20] refactor(AreaService): update condition verification methods to use IConditionElementModel for improved flexibility and consistency --- .../V2/Area/AreaService.Conditions.cs | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/Server/Services/V2/Area/AreaService.Conditions.cs b/Server/Services/V2/Area/AreaService.Conditions.cs index cbff912..6f21524 100644 --- a/Server/Services/V2/Area/AreaService.Conditions.cs +++ b/Server/Services/V2/Area/AreaService.Conditions.cs @@ -1,11 +1,12 @@ using Server.Enums.V2; using Server.Models.V2; +using Server.Models.V2.Conditions; namespace Server.Services.V2.Area; public partial class AreaService { - public Func? GetVerifyConditionsFunction(ConditioningElementTypeEnum elementType) + public Func? GetVerifyConditionsFunction(ConditioningElementTypeEnum elementType) { return elementType switch { @@ -17,23 +18,66 @@ public partial class AreaService }; } - public bool VerifyConditionsEquals(string? source, ActionOutputModel outputs, AreaConditionModel condition) + public bool VerifyConditionsEquals(string? source, ActionOutputModel output, IConditionElementModel condition) { - return true; + if (condition is ConditionEqualElement equalsElement) + { + var expected = equalsElement.Value; + if (output.IsList && output.Values != null) + return output.Values.Any(value => value == expected); + else + return output.Value == expected; + } + return false; } - public bool VerifyConditionsNot(string? source, ActionOutputModel output, AreaConditionModel condition) + public bool VerifyConditionsNot(string? source, ActionOutputModel output, IConditionElementModel condition) { + if (condition is ConditionNotElement notElement && notElement.ChildElementType != null && notElement.Child != null) + { + var childFunc = GetVerifyConditionsFunction(notElement.ChildElementType ?? throw new Exception("Unexpected error")); + if (childFunc != null) + return !childFunc(source, output, notElement.Child ?? throw new Exception("Unexpected error")); + return false; + } return true; } - public bool VerifyConditionsAnd(string? source, ActionOutputModel output, AreaConditionModel condition) + public bool VerifyConditionsAnd(string? source, ActionOutputModel output, IConditionElementModel condition) { - return true; + if (condition is ConditionAndElementModel andElement) + { + foreach (var child in andElement.Children) + { + if (child.ElementType == null || child.Element == null) + continue; + var childFunc = GetVerifyConditionsFunction(child.ElementType ?? throw new Exception("Unexpected error")); + if (childFunc == null) + continue; + if (!childFunc(source, output, child.Element ?? throw new Exception("Unexpected error"))) + return false; + } + return true; + } + return false; } - public bool VerifyConditionsOr(string? source, ActionOutputModel output, AreaConditionModel condition) + public bool VerifyConditionsOr(string? source, ActionOutputModel output, IConditionElementModel condition) { + if (condition is ConditionOrElementModel orElement) + { + foreach (var child in orElement.Children) + { + if (child.ElementType == null || child.Element == null) + continue; + var childFunc = GetVerifyConditionsFunction(child.ElementType ?? throw new Exception("Unexpected error")); + if (childFunc == null) + continue; + if (childFunc(source, output, child.Element ?? throw new Exception("Unexpected error"))) + return true; + } + return false; + } return true; } } \ No newline at end of file From 4601266c54a8306577b421686dea1cb2a605ae3f Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 22:01:00 +0100 Subject: [PATCH 12/20] refactor(AreaService): enhance condition verification and input conversion logic for improved robustness and error handling --- Server/Services/V2/Area/AreaService.cs | 39 ++++++++++++++++++++------ 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/Server/Services/V2/Area/AreaService.cs b/Server/Services/V2/Area/AreaService.cs index 9cd2088..71b1529 100644 --- a/Server/Services/V2/Area/AreaService.cs +++ b/Server/Services/V2/Area/AreaService.cs @@ -260,9 +260,9 @@ public bool VerifyCoditions(string? source, List outputs, Lis if (output == null) continue; var verifyConditions = GetVerifyConditionsFunction(condition.ElementType.Value); - if (verifyConditions == null) + if (verifyConditions == null || condition.Element == null) continue; - if (!verifyConditions(source, output, condition)) + if (!verifyConditions(source, output, condition.Element)) return false; } return true; @@ -275,7 +275,6 @@ public async Task ProcessAction(UserModel user, AreaModel area, ActionResultMode if (!VerifyCoditions(null, actionResult.Outputs ?? [], area.Conditions ?? [])) { _logger.LogWarning("Conditions not met for area {AreaId}", area.Id); - await LogReactionFailure(user, area, "Action", "Conditions not met"); return; } var areaLog = new AreaLogModel @@ -295,10 +294,12 @@ public async Task ProcessAction(UserModel user, AreaModel area, ActionResultMode foreach (var reaction in orderedReactions) { var reactionInput = GetReactionInput(reaction.Type); - var inputs = reaction.Parameters?.Select(param => ConvertToReactionInput(param, outputsBySource)).ToList() ?? new List(); + var inputs = reaction.Parameters?.Select(param => ConvertToReactionInput(param, outputsBySource)).OfType().ToList() ?? []; + if (inputs.Count != 0 && inputs.Any(x => x == null)) + continue; var reactionResult = await reactionInput(user, area, inputs); // Store outputs for this reaction by its name for downstream references - if (!string.IsNullOrWhiteSpace(reaction.Name)) + if (!string.IsNullOrWhiteSpace(reaction.Name) && VerifyCoditions(reaction.Name!, reactionResult ?? [], area.Conditions ?? [])) { outputsBySource[reaction.Name!] = reactionResult ?? []; } @@ -317,7 +318,7 @@ private async Task LogReactionFailure(UserModel user, AreaModel area, string rea await _areaV2MongoService.CreateAreaLog(areaLog); } - public ReactionInputModel ConvertToReactionInput(ReactionParameterModel parameter, Dictionary> outputsBySource) + public ReactionInputModel? ConvertToReactionInput(ReactionParameterModel parameter, Dictionary> outputsBySource) { Console.WriteLine($"ConvertToReactionInput - Parameter: {JsonConvert.SerializeObject(parameter)}"); Console.WriteLine($"ConvertToReactionInput - Outputs by source: {JsonConvert.SerializeObject(outputsBySource)}"); @@ -349,7 +350,11 @@ public ReactionInputModel ConvertToReactionInput(ReactionParameterModel paramete Console.WriteLine($"ConvertToReactionInput - Single non-constant value detected: {complexeValueList[0].Value}"); var sourceKey = string.IsNullOrWhiteSpace(complexeValueList[0].Source) ? "Action" : complexeValueList[0].Source!; Console.WriteLine($"ConvertToReactionInput - Using source key: {sourceKey}"); - outputsBySource.TryGetValue(sourceKey, out var sourceOutputs); + if (!outputsBySource.TryGetValue(sourceKey, out var sourceOutputs) || sourceOutputs == null) + { + Console.WriteLine($"ConvertToReactionInput - Source '{sourceKey}' not found when processing list parameter '{parameter.Name}'. Returning null."); + return null; + } Console.WriteLine($"ConvertToReactionInput - Found {sourceOutputs?.Count ?? 0} outputs for source"); var output = sourceOutputs?.FirstOrDefault(output => output.Name == complexeValueList[0].Value); if (output != null && output.IsList && output.Values != null) @@ -369,6 +374,12 @@ public ReactionInputModel ConvertToReactionInput(ReactionParameterModel paramete Console.WriteLine($"ConvertToReactionInput - Processing as concatenated values"); var concatenatedValue = ProcessComplexeValues(complexeValueList, outputsBySource); Console.WriteLine($"ConvertToReactionInput - Concatenated value: {concatenatedValue}"); + // If ProcessComplexeValues returned null it means a referenced source was missing + if (concatenatedValue == null) + { + Console.WriteLine($"ConvertToReactionInput - A referenced source was missing when processing list parameter '{parameter.Name}'. Returning null."); + return null; + } if (!string.IsNullOrEmpty(concatenatedValue)) { processedValues.Add(concatenatedValue); @@ -391,6 +402,11 @@ public ReactionInputModel ConvertToReactionInput(ReactionParameterModel paramete Console.WriteLine($"ConvertToReactionInput - Found {parameter.ComplexeValues.Count} complexe values"); var concatenatedValue = ProcessComplexeValues(parameter.ComplexeValues, outputsBySource); Console.WriteLine($"ConvertToReactionInput - Setting single value: {concatenatedValue}"); + if (concatenatedValue == null) + { + Console.WriteLine($"ConvertToReactionInput - A referenced source was missing when processing parameter '{parameter.Name}'. Returning null."); + return null; + } input.Value = concatenatedValue; } else @@ -403,7 +419,7 @@ public ReactionInputModel ConvertToReactionInput(ReactionParameterModel paramete return input; } - private string ProcessComplexeValues(List complexeValues, Dictionary> outputsBySource) + private string? ProcessComplexeValues(List complexeValues, Dictionary> outputsBySource) { var result = new System.Text.StringBuilder(); @@ -423,7 +439,12 @@ private string ProcessComplexeValues(List complexeValues, if (!string.IsNullOrEmpty(complexeValue.Value)) { var sourceKey = string.IsNullOrWhiteSpace(complexeValue.Source) ? "Action" : complexeValue.Source!; - outputsBySource.TryGetValue(sourceKey, out var sourceOutputs); + if (!outputsBySource.TryGetValue(sourceKey, out var sourceOutputs) || sourceOutputs == null) + { + // referenced source not found -> signal caller by returning null + Console.WriteLine($"ProcessComplexeValues - Source '{sourceKey}' not found when resolving value '{complexeValue.Value}'."); + return null; + } var output = sourceOutputs?.FirstOrDefault(output => output.Name == complexeValue.Value); if (output != null) { From cbeb01dd0094e0d8f954915a3ff5c61a13901b0f Mon Sep 17 00:00:00 2001 From: Tom C Date: Sun, 2 Nov 2025 22:55:11 +0100 Subject: [PATCH 13/20] feat(AreaService): implement condition node handling with editor and payload generation for conditions --- .../app/area-v2-builder/page.tsx | 439 ++++++++++++++---- 1 file changed, 355 insertions(+), 84 deletions(-) diff --git a/Tests/controller-tester/app/area-v2-builder/page.tsx b/Tests/controller-tester/app/area-v2-builder/page.tsx index 041fdb0..b2834a0 100644 --- a/Tests/controller-tester/app/area-v2-builder/page.tsx +++ b/Tests/controller-tester/app/area-v2-builder/page.tsx @@ -61,7 +61,24 @@ interface ReactionNode extends NodeBase { parameterBindings: Record; } -type Node = ActionNode | ReactionNode; +// Condition types +type ConditionElementType = 'Equals' | 'And' | 'Or' | 'Not'; + +interface ConditionElement { + type: ConditionElementType; + value?: string; // For Equals + children?: ConditionElement[]; // For And/Or + child?: ConditionElement; // For Not +} + +interface ConditionNode extends NodeBase { + kind: 'condition'; + source: string | null; // null for action, or reaction instance name + outputName: string; + element: ConditionElement; +} + +type Node = ActionNode | ReactionNode | ConditionNode; interface LinkEdge { id: string; @@ -86,6 +103,7 @@ export default function AreaV2BuilderPage() { const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); const [pendingPort, setPendingPort] = useState(null); + const [editingCondition, setEditingCondition] = useState(null); // Form data for area creation const [areaName, setAreaName] = useState(''); @@ -141,6 +159,26 @@ export default function AreaV2BuilderPage() { setNodes(prev => [...prev, newNode]); }; + const addConditionNode = (source: string | null, outputName: string) => { + const id = uid('cond'); + const newNode: ConditionNode = { + id, + kind: 'condition', + source, + outputName, + pos: { x: 350, y: 120 + nodes.filter(n => n.kind === 'condition').length * 100 }, + element: { type: 'Equals', value: '' } + }; + setNodes(prev => [...prev, newNode]); + }; + + const updateConditionElement = (nodeId: string, element: ConditionElement) => { + setNodes(prev => prev.map(n => { + if (n.id !== nodeId || n.kind !== 'condition') return n; + return { ...n, element }; + })); + }; + // Drag logic const dragInfo = useRef<{ id: string; start: Vec2; nodeStart: Vec2 } | null>(null); @@ -365,6 +403,73 @@ export default function AreaV2BuilderPage() { }) .filter(Boolean); + // Build conditions + const conditionNodes = nodes.filter((n): n is ConditionNode => n.kind === 'condition'); + const conditionsPayload = conditionNodes.map(cond => { + const getElementTypeEnum = (type: ConditionElementType): number => { + switch (type) { + case 'And': return 0; + case 'Or': return 1; + case 'Equals': return 2; + case 'Not': return 3; + default: return 2; + } + }; + + const buildElement = (el: ConditionElement): any => { + if (el.type === 'Equals') { + return { + elementType: getElementTypeEnum('Equals'), + element: { value: el.value || '' } + }; + } else if (el.type === 'And') { + return { + elementType: getElementTypeEnum('And'), + element: { + children: (el.children || []).map(child => { + const childBuilt = buildElement(child); + return { + elementType: childBuilt.elementType, + element: childBuilt.element + }; + }) + } + }; + } else if (el.type === 'Or') { + return { + elementType: getElementTypeEnum('Or'), + element: { + children: (el.children || []).map(child => { + const childBuilt = buildElement(child); + return { + operator: childBuilt.elementType, + element: childBuilt.element + }; + }) + } + }; + } else if (el.type === 'Not') { + const childBuilt = el.child ? buildElement(el.child) : null; + return { + elementType: getElementTypeEnum('Not'), + element: { + childElementType: childBuilt ? childBuilt.elementType : null, + child: childBuilt ? childBuilt.element : null + } + }; + } + return null; + }; + + const built = buildElement(cond.element); + return { + source: cond.source, + outputName: cond.outputName, + elementType: built.elementType, + element: built.element + }; + }); + const body: any = { name: areaName, userId, @@ -372,6 +477,10 @@ export default function AreaV2BuilderPage() { reactions: reactionPayload }; + if (conditionsPayload.length > 0) { + body.conditions = conditionsPayload; + } + return body; }; @@ -438,6 +547,114 @@ export default function AreaV2BuilderPage() { const getPreview = (parts: ReactionComplexeValueUI[]) => parts.map(p => (p.isConstant ? p.value : `{${p.value}}`)).join(''); + const renderConditionEditor = (condNode: ConditionNode) => { + const element = condNode.element; + + const updateElement = (newEl: ConditionElement) => { + updateConditionElement(condNode.id, newEl); + }; + + const renderElementEditor = (el: ConditionElement, path: number[] = []): React.ReactElement => { + const updateAtPath = (newEl: ConditionElement) => { + const clone = JSON.parse(JSON.stringify(condNode.element)) as ConditionElement; + let current: any = clone; + for (let i = 0; i < path.length - 1; i++) { + if (current.children) current = current.children[path[i]]; + else if (current.child) current = current.child; + } + if (path.length === 0) { + updateElement(newEl); + } else { + const lastIdx = path[path.length - 1]; + if (current.children) current.children[lastIdx] = newEl; + else if (current.child !== undefined) current.child = newEl; + updateElement(clone); + } + }; + + if (el.type === 'Equals') { + return ( +
+ + updateAtPath({ ...el, value: e.target.value })} /> +
+ ); + } else if (el.type === 'And' || el.type === 'Or') { + return ( +
+
+ + +
+
+ {(el.children || []).map((child, idx) => ( +
+ {renderElementEditor(child, [...path, idx])} + +
+ ))} +
+
+ ); + } else if (el.type === 'Not') { + return ( +
+
+ + NOT operator +
+ {el.child && ( +
+ {renderElementEditor(el.child, [...path, 0])} +
+ )} +
+ ); + } + return
Unknown type
; + }; + + return ( +
+
+ Condition on: {condNode.source || 'Action'} → {condNode.outputName} +
+ {renderElementEditor(element)} +
+ ); + }; + return (

🧩 Area V2 Builder

@@ -487,6 +704,20 @@ export default function AreaV2BuilderPage() {
+
+

Conditions

+

Add conditions to outputs

+
+
Action Outputs
+ {actionOutputs.map((output, i) => ( + + ))} + {actionOutputs.length === 0 &&
No action selected
} +
+
+ @@ -515,106 +746,146 @@ export default function AreaV2BuilderPage() { {/* Nodes */} {nodes.map(n => (
-
onMouseDownNode(e, n.id)}> -
- {n.kind === 'action' ? 'Action' : 'Reaction'}: {n.kind === 'action' ? (n as ActionNode).actionName : (n as ReactionNode).reactionName} - -
-
- {n.kind === 'action' ? ( -
-
Outputs
-
- {actionOutputs.map((o, i) => ( - - ))} - {actionOutputs.length === 0 &&
No outputs
} -
+ {n.kind === 'condition' ? ( + // Render condition as a diamond/losange +
onMouseDownNode(e, n.id)}> +
setEditingCondition(n.id)}> + {/* Diamond shape using rotation */} +
+
+ ?
- ) : ( -
-
- - setNodes(prev => prev.map(nn => nn.id === n.id ? { ...(nn as ReactionNode), instanceName: e.target.value } as ReactionNode : nn))} /> +
+ {/* Label below diamond */} +
+
{n.source || 'Action'}
+
{n.outputName}
+
+ {/* Remove button */} + +
+ ) : ( +
onMouseDownNode(e, n.id)}> +
+ {n.kind === 'action' ? 'Action' : 'Reaction'}: {n.kind === 'action' ? (n as ActionNode).actionName : (n as ReactionNode).reactionName} + +
+
+ {n.kind === 'action' ? ( +
+
Outputs
+
+ {actionOutputs.map((o, i) => ( + + ))} + {actionOutputs.length === 0 &&
No outputs
} +
- {(reactionPatterns.find(r => r.name === (n as ReactionNode).reactionName)?.parameters || []).map((p, idx) => { - const rxn = n as ReactionNode; - const binding = rxn.parameterBindings[p.name]; - return ( -
-
-
{getParamLabel(p)}
- {/* Quick link from pending action output */} - -
+ ) : ( +
+
+ + setNodes(prev => prev.map(nn => nn.id === n.id ? { ...(nn as ReactionNode), instanceName: e.target.value } as ReactionNode : nn))} /> +
+ {(reactionPatterns.find(r => r.name === (n as ReactionNode).reactionName)?.parameters || []).map((p, idx) => { + const rxn = n as ReactionNode; + const binding = rxn.parameterBindings[p.name]; + return ( +
+
+
{getParamLabel(p)}
+ {/* Quick link from pending action output */} + +
- {/* Non-list param editor */} - {!p.isList && ( -
- {(binding?.complexeValues || []).map((part, i) => ( -
- {renderPartEditor(rxn, p, part, i)} + {/* Non-list param editor */} + {!p.isList && ( +
+ {(binding?.complexeValues || []).map((part, i) => ( +
+ {renderPartEditor(rxn, p, part, i)} +
+ ))} +
+ + {(binding?.complexeValues && binding.complexeValues.length > 0) && ( +
Preview: {getPreview(binding.complexeValues)}
+ )}
- ))} -
- - {(binding?.complexeValues && binding.complexeValues.length > 0) && ( -
Preview: {getPreview(binding.complexeValues)}
- )}
-
- )} - - {/* List param editor */} - {p.isList && ( -
- {(binding?.listComplexeValues || []).map((list, li) => ( -
-
List item {li + 1}
-
- {list.map((part, pi) => ( -
{renderPartEditor(rxn, p, part, pi, li)}
- ))} - + )} + + {/* List param editor */} + {p.isList && ( +
+ {(binding?.listComplexeValues || []).map((list, li) => ( +
+
List item {li + 1}
+
+ {list.map((part, pi) => ( +
{renderPartEditor(rxn, p, part, pi, li)}
+ ))} + +
+ ))} +
+ + {(binding?.listComplexeValues && binding.listComplexeValues.length > 0) && ( +
Items: {binding.listComplexeValues.length}
+ )}
- ))} -
- - {(binding?.listComplexeValues && binding.listComplexeValues.length > 0) && ( -
Items: {binding.listComplexeValues.length}
- )}
-
+ )} +
+ ); + })} + + {/* Reaction outputs (read-only listing) */} +
+
Outputs
+
+ {(reactionPatterns.find(r => r.name === (n as ReactionNode).reactionName)?.outputs || []).map((o, i) => ( + + ))} + {((reactionPatterns.find(r => r.name === (n as ReactionNode).reactionName)?.outputs || []).length === 0) && ( + No outputs )}
- ); - })} - - {/* Reaction outputs (read-only listing) */} -
-
Outputs
-
- {(reactionPatterns.find(r => r.name === (n as ReactionNode).reactionName)?.outputs || []).map((o, i) => ( - - ))} - {((reactionPatterns.find(r => r.name === (n as ReactionNode).reactionName)?.outputs || []).length === 0) && ( - No outputs - )}
-
- )} + )} +
-
+ )}
))}
+ + {/* Condition Editor Modal */} + {editingCondition && ( +
setEditingCondition(null)}> +
e.stopPropagation()}> +
+

Edit Condition

+ +
+ {(() => { + const condNode = nodes.find(n => n.id === editingCondition && n.kind === 'condition') as ConditionNode | undefined; + if (!condNode) return
Condition not found
; + return renderConditionEditor(condNode); + })()} +
+ +
+
+
+ )}
); } From 58b4fd7a471eaf2cb32d34889df6eda909f9a855 Mon Sep 17 00:00:00 2001 From: Tom C Date: Mon, 3 Nov 2025 07:22:07 +0100 Subject: [PATCH 14/20] refactor(ConditionModels): rename Operator property to ElementType in ConditionOrChildElementModel for consistency --- Server/Models/V2/Conditions/ConditionOrElementModel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/Models/V2/Conditions/ConditionOrElementModel.cs b/Server/Models/V2/Conditions/ConditionOrElementModel.cs index 10708eb..bceafe0 100644 --- a/Server/Models/V2/Conditions/ConditionOrElementModel.cs +++ b/Server/Models/V2/Conditions/ConditionOrElementModel.cs @@ -12,6 +12,6 @@ public class ConditionOrElementModel : IConditionElementModel [BsonIgnoreExtraElements] public class ConditionOrChildElementModel { - public ConditioningElementTypeEnum? Operator { get; set; } = null; + public ConditioningElementTypeEnum? ElementType { get; set; } = null; public IConditionElementModel? Element { get; set; } = null; } \ No newline at end of file From 49cc6b17e7c4ccb099c3b8343cf7d86630bd6a27 Mon Sep 17 00:00:00 2001 From: Tom C Date: Mon, 3 Nov 2025 07:28:57 +0100 Subject: [PATCH 15/20] feat(CreateAreaRequest): add Conditions property to CreateAreaRequest for handling area conditions --- Server/Controllers/AreaV2Controller.cs | 1 + Server/Requests/V2/CreateAreaRequest.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/Server/Controllers/AreaV2Controller.cs b/Server/Controllers/AreaV2Controller.cs index 10feea2..62f4707 100644 --- a/Server/Controllers/AreaV2Controller.cs +++ b/Server/Controllers/AreaV2Controller.cs @@ -51,6 +51,7 @@ public async Task CreateArea([FromBody] CreateAreaRequest request UserId = request.UserId, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, + Conditions = request.Conditions, IsActive = true }; diff --git a/Server/Requests/V2/CreateAreaRequest.cs b/Server/Requests/V2/CreateAreaRequest.cs index 69022ad..1ac6d91 100644 --- a/Server/Requests/V2/CreateAreaRequest.cs +++ b/Server/Requests/V2/CreateAreaRequest.cs @@ -8,4 +8,5 @@ public class CreateAreaRequest public ActionModel? Action { get; set; } = null; public string? UserId { get; set; } public List? Reactions { get; set; } = null; + public List? Conditions { get; set; } = null; } \ No newline at end of file From c37f270845862c46c834f1ed9dac395846a79fb0 Mon Sep 17 00:00:00 2001 From: Tom C Date: Mon, 3 Nov 2025 08:01:41 +0100 Subject: [PATCH 16/20] feat(Converters): add ConditionElementJsonConverter and AreaConditionModelJsonConverter for JSON serialization --- .../ConditionElementJsonConverter.cs | 395 ++++++++++++++++++ Server/Program.cs | 2 + 2 files changed, 397 insertions(+) create mode 100644 Server/Converters/ConditionElementJsonConverter.cs diff --git a/Server/Converters/ConditionElementJsonConverter.cs b/Server/Converters/ConditionElementJsonConverter.cs new file mode 100644 index 0000000..a7f7e97 --- /dev/null +++ b/Server/Converters/ConditionElementJsonConverter.cs @@ -0,0 +1,395 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Server.Models.V2.Conditions; +using Server.Enums.V2; + +namespace Server.Converters; + +public class ConditionElementJsonConverter : JsonConverter +{ + public override IConditionElementModel? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // We can't deserialize an interface directly, so we return null + // The actual deserialization is handled by AreaConditionModelJsonConverter + throw new JsonException("ConditionElementJsonConverter.Read should not be called directly"); + } + + public override void Write(Utf8JsonWriter writer, IConditionElementModel value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } +} + +public class AreaConditionModelJsonConverter : JsonConverter +{ + public override Server.Models.V2.AreaConditionModel? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + var condition = new Server.Models.V2.AreaConditionModel(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + string? propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName?.ToLowerInvariant()) + { + case "source": + condition.Source = reader.TokenType == JsonTokenType.Null ? null : reader.GetString(); + break; + case "outputname": + condition.OutputName = reader.GetString() ?? string.Empty; + break; + case "elementtype": + if (reader.TokenType == JsonTokenType.Number) + { + condition.ElementType = (ConditioningElementTypeEnum)reader.GetInt32(); + } + else if (reader.TokenType == JsonTokenType.String) + { + var enumString = reader.GetString(); + if (Enum.TryParse(enumString, true, out var enumValue)) + { + condition.ElementType = enumValue; + } + } + break; + case "element": + condition.Element = ReadConditionElement(ref reader, condition.ElementType, options); + break; + default: + reader.Skip(); + break; + } + } + + return condition; + } + + private IConditionElementModel? ReadConditionElement(ref Utf8JsonReader reader, ConditioningElementTypeEnum? elementType, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (!elementType.HasValue) + { + throw new JsonException("ElementType must be specified before Element"); + } + + return elementType.Value switch + { + ConditioningElementTypeEnum.Equals => JsonSerializer.Deserialize(ref reader, options), + ConditioningElementTypeEnum.Not => DeserializeConditionNotElement(ref reader, options), + ConditioningElementTypeEnum.And => DeserializeConditionAndElement(ref reader, options), + ConditioningElementTypeEnum.Or => DeserializeConditionOrElement(ref reader, options), + _ => throw new JsonException($"Unknown ElementType: {elementType}") + }; + } + + private ConditionNotElement? DeserializeConditionNotElement(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token for ConditionNotElement"); + } + + var notElement = new ConditionNotElement(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + string? propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName?.ToLowerInvariant()) + { + case "childelementtype": + if (reader.TokenType == JsonTokenType.Number) + { + notElement.ChildElementType = (ConditioningElementTypeEnum)reader.GetInt32(); + } + else if (reader.TokenType == JsonTokenType.String) + { + var enumString = reader.GetString(); + if (Enum.TryParse(enumString, true, out var enumValue)) + { + notElement.ChildElementType = enumValue; + } + } + break; + case "child": + notElement.Child = ReadConditionElement(ref reader, notElement.ChildElementType, options); + break; + default: + reader.Skip(); + break; + } + } + + return notElement; + } + + private ConditionAndElementModel? DeserializeConditionAndElement(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token for ConditionAndElement"); + } + + var andElement = new ConditionAndElementModel(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + string? propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName?.ToLowerInvariant()) + { + case "children": + if (reader.TokenType == JsonTokenType.StartArray) + { + andElement.Children = []; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var child = DeserializeConditionAndChildElement(ref reader, options); + if (child != null) + { + andElement.Children.Add(child); + } + } + } + break; + default: + reader.Skip(); + break; + } + } + + return andElement; + } + + private ConditionAndChildElementModel? DeserializeConditionAndChildElement(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token for ConditionAndChildElement"); + } + + var child = new ConditionAndChildElementModel(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + string? propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName?.ToLowerInvariant()) + { + case "elementtype": + if (reader.TokenType == JsonTokenType.Number) + { + child.ElementType = (ConditioningElementTypeEnum)reader.GetInt32(); + } + else if (reader.TokenType == JsonTokenType.String) + { + var enumString = reader.GetString(); + if (Enum.TryParse(enumString, true, out var enumValue)) + { + child.ElementType = enumValue; + } + } + break; + case "element": + child.Element = ReadConditionElement(ref reader, child.ElementType, options); + break; + default: + reader.Skip(); + break; + } + } + + return child; + } + + private ConditionOrElementModel? DeserializeConditionOrElement(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token for ConditionOrElement"); + } + + var orElement = new ConditionOrElementModel(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + string? propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName?.ToLowerInvariant()) + { + case "children": + if (reader.TokenType == JsonTokenType.StartArray) + { + orElement.Children = []; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var child = DeserializeConditionOrChildElement(ref reader, options); + if (child != null) + { + orElement.Children.Add(child); + } + } + } + break; + default: + reader.Skip(); + break; + } + } + + return orElement; + } + + private ConditionOrChildElementModel? DeserializeConditionOrChildElement(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token for ConditionOrChildElement"); + } + + var child = new ConditionOrChildElementModel(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + string? propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName?.ToLowerInvariant()) + { + case "elementtype": + if (reader.TokenType == JsonTokenType.Number) + { + child.ElementType = (ConditioningElementTypeEnum)reader.GetInt32(); + } + else if (reader.TokenType == JsonTokenType.String) + { + var enumString = reader.GetString(); + if (Enum.TryParse(enumString, true, out var enumValue)) + { + child.ElementType = enumValue; + } + } + break; + case "element": + child.Element = ReadConditionElement(ref reader, child.ElementType, options); + break; + default: + reader.Skip(); + break; + } + } + + return child; + } + + public override void Write(Utf8JsonWriter writer, Server.Models.V2.AreaConditionModel value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value.Source != null) + { + writer.WriteString("source", value.Source); + } + else + { + writer.WriteNull("source"); + } + + writer.WriteString("outputName", value.OutputName); + + if (value.ElementType.HasValue) + { + writer.WriteNumber("elementType", (int)value.ElementType.Value); + } + else + { + writer.WriteNull("elementType"); + } + + if (value.Element != null) + { + writer.WritePropertyName("element"); + JsonSerializer.Serialize(writer, value.Element, value.Element.GetType(), options); + } + else + { + writer.WriteNull("element"); + } + + writer.WriteEndObject(); + } +} diff --git a/Server/Program.cs b/Server/Program.cs index dcacd5e..318e12a 100644 --- a/Server/Program.cs +++ b/Server/Program.cs @@ -22,6 +22,7 @@ using Server.MongoServices.V2; using Server.Processes.Interfaces; using Server.Services.V2.Area; +using Server.Converters; var builder = WebApplication.CreateBuilder(args); @@ -32,6 +33,7 @@ options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + options.JsonSerializerOptions.Converters.Add(new AreaConditionModelJsonConverter()); }); // Add HttpClient From 32c6cd76dc3914d7667e266dd2669230ed08d54c Mon Sep 17 00:00:00 2001 From: Tom C Date: Mon, 3 Nov 2025 08:01:45 +0100 Subject: [PATCH 17/20] feat(AreaV2BuilderPage): add console logs for debugging condition nodes and payload --- Tests/controller-tester/app/area-v2-builder/page.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/controller-tester/app/area-v2-builder/page.tsx b/Tests/controller-tester/app/area-v2-builder/page.tsx index b2834a0..bfc1f53 100644 --- a/Tests/controller-tester/app/area-v2-builder/page.tsx +++ b/Tests/controller-tester/app/area-v2-builder/page.tsx @@ -405,6 +405,8 @@ export default function AreaV2BuilderPage() { // Build conditions const conditionNodes = nodes.filter((n): n is ConditionNode => n.kind === 'condition'); + // eslint-disable-next-line no-console + console.log('Condition nodes found:', conditionNodes.length, conditionNodes); const conditionsPayload = conditionNodes.map(cond => { const getElementTypeEnum = (type: ConditionElementType): number => { switch (type) { @@ -442,7 +444,7 @@ export default function AreaV2BuilderPage() { children: (el.children || []).map(child => { const childBuilt = buildElement(child); return { - operator: childBuilt.elementType, + elementType: childBuilt.elementType, element: childBuilt.element }; }) @@ -469,6 +471,8 @@ export default function AreaV2BuilderPage() { element: built.element }; }); + // eslint-disable-next-line no-console + console.log('Conditions payload:', conditionsPayload); const body: any = { name: areaName, @@ -490,6 +494,8 @@ export default function AreaV2BuilderPage() { try { const body = buildRequestBody(); if (!body) throw new Error('Please select an action'); + // eslint-disable-next-line no-console + console.log('Request body:', JSON.stringify(body, null, 2)); const result = await apiRequest('/api/area/v2/create', { method: 'POST', body: JSON.stringify(body) }); setResponse(result); } catch (err: any) { From d1f42f100b796daeb6423fecebad3c413dc5b912 Mon Sep 17 00:00:00 2001 From: Tom C Date: Mon, 3 Nov 2025 08:05:43 +0100 Subject: [PATCH 18/20] feat(MongoDBContext): implement BSON serialization configuration for IConditionElementModel and register condition element class maps --- Server/Contexts/MongoDbContext.cs | 74 +++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/Server/Contexts/MongoDbContext.cs b/Server/Contexts/MongoDbContext.cs index 8868178..02edeb8 100644 --- a/Server/Contexts/MongoDbContext.cs +++ b/Server/Contexts/MongoDbContext.cs @@ -1,5 +1,7 @@ using MongoDB.Bson; +using MongoDB.Bson.Serialization; using MongoDB.Driver; +using Server.Models.V2.Conditions; namespace Server.Contexts; @@ -7,6 +9,7 @@ public static class MongoDBContext { public static MongoClient Client { get; set; } private static readonly object _lock = new(); + private static bool _isConfigured = false; static MongoDBContext() { @@ -18,11 +21,82 @@ static MongoDBContext() if (Client != null) return; + // Configure BSON serialization for IConditionElementModel before creating the client + if (!_isConfigured) + { + ConfigureBsonSerialization(); + _isConfigured = true; + } + Client = new MongoClient(Settings.MongoConnectionString); Console.WriteLine("Settings.MongoConnectionString: " + Settings.MongoConnectionString); } } + private static void ConfigureBsonSerialization() + { + // Register known types for IConditionElementModel interface + BsonSerializer.RegisterDiscriminatorConvention( + typeof(IConditionElementModel), + new MongoDB.Bson.Serialization.Conventions.ScalarDiscriminatorConvention("_t") + ); + + // Register class maps for all condition element types + if (!BsonClassMap.IsClassMapRegistered(typeof(ConditionEqualElement))) + { + BsonClassMap.RegisterClassMap(cm => + { + cm.AutoMap(); + cm.SetIsRootClass(false); + }); + } + + if (!BsonClassMap.IsClassMapRegistered(typeof(ConditionNotElement))) + { + BsonClassMap.RegisterClassMap(cm => + { + cm.AutoMap(); + cm.SetIsRootClass(false); + }); + } + + if (!BsonClassMap.IsClassMapRegistered(typeof(ConditionAndElementModel))) + { + BsonClassMap.RegisterClassMap(cm => + { + cm.AutoMap(); + cm.SetIsRootClass(false); + }); + } + + if (!BsonClassMap.IsClassMapRegistered(typeof(ConditionOrElementModel))) + { + BsonClassMap.RegisterClassMap(cm => + { + cm.AutoMap(); + cm.SetIsRootClass(false); + }); + } + + // Register the interface with allowed types + try + { + BsonSerializer.RegisterSerializer( + typeof(IConditionElementModel), + new MongoDB.Bson.Serialization.Serializers.ObjectSerializer( + type => type == typeof(ConditionEqualElement) || + type == typeof(ConditionNotElement) || + type == typeof(ConditionAndElementModel) || + type == typeof(ConditionOrElementModel) + ) + ); + } + catch (BsonSerializationException) + { + // Serializer already registered, ignore + } + } + public static IMongoDatabase GetDatabase(string databaseName) { return Client.GetDatabase(databaseName); From b2294e5c671e43cc4f8c2479d660dab0b7274cc6 Mon Sep 17 00:00:00 2001 From: Tom C Date: Mon, 3 Nov 2025 08:12:22 +0100 Subject: [PATCH 19/20] feat(cicd): reactivated --- .../cd-deploy-stable.server.yaml | 0 .../cd-mirror.yaml | 0 .../workflows/ci-cd-orchestrator.client.yaml | 68 +++++++++++++++ .../workflows/ci-cd-orchestrator.server.yaml | 82 +++++++++++++++++++ .../ci-docker-build-push.server.yaml | 0 .../ci-lint-test.client.yaml | 0 .../ci-lint-test.server.yaml | 0 .../ci-matrix-build.client.yaml | 0 .../ci-matrix-build.server.yaml | 0 .../ci-sign-image.server.yaml | 0 .../ci-test.server.yaml | 0 .../complete-ci-cd.yml | 0 .../manual-release.yml | 0 .../reusable-create-solution.server.yaml | 0 .../ci-cd-orchestrator.client.yaml | 68 --------------- .../ci-cd-orchestrator.server.yaml | 82 ------------------- 16 files changed, 150 insertions(+), 150 deletions(-) rename .github/{workflows_deactivated => workflows}/cd-deploy-stable.server.yaml (100%) rename .github/{workflows_deactivated => workflows}/cd-mirror.yaml (100%) create mode 100644 .github/workflows/ci-cd-orchestrator.client.yaml create mode 100644 .github/workflows/ci-cd-orchestrator.server.yaml rename .github/{workflows_deactivated => workflows}/ci-docker-build-push.server.yaml (100%) rename .github/{workflows_deactivated => workflows}/ci-lint-test.client.yaml (100%) rename .github/{workflows_deactivated => workflows}/ci-lint-test.server.yaml (100%) rename .github/{workflows_deactivated => workflows}/ci-matrix-build.client.yaml (100%) rename .github/{workflows_deactivated => workflows}/ci-matrix-build.server.yaml (100%) rename .github/{workflows_deactivated => workflows}/ci-sign-image.server.yaml (100%) rename .github/{workflows_deactivated => workflows}/ci-test.server.yaml (100%) rename .github/{workflows_deactivated => workflows}/complete-ci-cd.yml (100%) rename .github/{workflows_deactivated => workflows}/manual-release.yml (100%) rename .github/{workflows_deactivated => workflows}/reusable-create-solution.server.yaml (100%) delete mode 100644 .github/workflows_deactivated/ci-cd-orchestrator.client.yaml delete mode 100644 .github/workflows_deactivated/ci-cd-orchestrator.server.yaml diff --git a/.github/workflows_deactivated/cd-deploy-stable.server.yaml b/.github/workflows/cd-deploy-stable.server.yaml similarity index 100% rename from .github/workflows_deactivated/cd-deploy-stable.server.yaml rename to .github/workflows/cd-deploy-stable.server.yaml diff --git a/.github/workflows_deactivated/cd-mirror.yaml b/.github/workflows/cd-mirror.yaml similarity index 100% rename from .github/workflows_deactivated/cd-mirror.yaml rename to .github/workflows/cd-mirror.yaml diff --git a/.github/workflows/ci-cd-orchestrator.client.yaml b/.github/workflows/ci-cd-orchestrator.client.yaml new file mode 100644 index 0000000..a84a741 --- /dev/null +++ b/.github/workflows/ci-cd-orchestrator.client.yaml @@ -0,0 +1,68 @@ +name: CI/CD Orchestrator (Client) +on: + push: + branches: ["*"] + paths: + - 'web-app/**' + - '.github/workflows/*.client.yaml' + - 'Tests/Web/**' + +jobs: + ci-matrix-build: + if: ${{ vars.IS_MIRROR != 'true' }} + uses: ./.github/workflows/ci-matrix-build.client.yaml + with: + project-dir: web-app + secrets: + FRONTEND_CREDENTIALS: ${{ secrets.FRONTEND_CREDENTIALS }} + + ci-lint-tests: + permissions: + contents: read + security-events: write + uses: ./.github/workflows/ci-lint-test.client.yaml + with: + project-dir: web-app + + # ci-docker-build-push: + # needs: [ci-matrix-build, ci-lint-tests] + # uses: ./.github/workflows/ci-docker-build-push.server.yaml + # with: + # project-dir: Server + # secrets: + # IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} + # DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + # DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + # BACKEND_CREDENTIALS: ${{ secrets.BACKEND_CREDENTIALS }} + + # ci-sign-image: + # needs: ci-docker-build-push + # uses: ./.github/workflows/ci-sign-image.server.yaml + # secrets: + # DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + # DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + # COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + # COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + # IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} + # COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + + # cd-deploy-stable: + # needs: ci-sign-image + # if: github.ref == 'refs/heads/main' + # uses: ./.github/workflows/cd-deploy-stable.server.yaml + # secrets: + # SERVICE_NAME: ${{ secrets.SERVICE_NAME }} + # COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + # COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + # DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} + # SERVER_HOST: ${{ secrets.SERVER_HOST }} + # SERVER_USER: ${{ secrets.SERVER_USER }} + # DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + # REMOTE_DIR: ${{ secrets.REMOTE_DIR }} + + # cd-mirror: + # needs: cd-deploy-stable + # uses: ./.github/workflows/cd-mirror.yaml + # secrets: + # SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_KEY }} + # TARGET_REPO_URL: ${{ secrets.TARGET_REPO_URL }} \ No newline at end of file diff --git a/.github/workflows/ci-cd-orchestrator.server.yaml b/.github/workflows/ci-cd-orchestrator.server.yaml new file mode 100644 index 0000000..25ee14a --- /dev/null +++ b/.github/workflows/ci-cd-orchestrator.server.yaml @@ -0,0 +1,82 @@ +name: CI/CD Orchestrator (Server) +on: + push: + branches: ["*"] + paths: + - 'Server/**' + - 'Tests/Server/**' + - '.github/workflows/*.server.yaml' + +jobs: + create-solution: + uses: ./.github/workflows/reusable-create-solution.server.yaml + + ci-matrix-build: + needs: create-solution + uses: ./.github/workflows/ci-matrix-build.server.yaml + with: + solution: AREA.sln + project-dir: Server + + ci-lint-tests: + needs: create-solution + permissions: + contents: read + security-events: write + uses: ./.github/workflows/ci-lint-test.server.yaml + with: + solution: AREA.sln + project-dir: Server + + ci-unit-tests: + needs: create-solution + permissions: + contents: read + uses: ./.github/workflows/ci-test.server.yaml + with: + solution: AREA.sln + project-dir: Server + test-dir: Tests/Server + + ci-docker-build-push: + needs: [ci-matrix-build, ci-lint-tests, ci-unit-tests] + uses: ./.github/workflows/ci-docker-build-push.server.yaml + with: + project-dir: Server + secrets: + IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + BACKEND_CREDENTIALS: ${{ secrets.BACKEND_CREDENTIALS }} + + ci-sign-image: + needs: ci-docker-build-push + uses: ./.github/workflows/ci-sign-image.server.yaml + secrets: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + + cd-deploy-stable: + needs: ci-sign-image + if: github.ref == 'refs/heads/main' + uses: ./.github/workflows/cd-deploy-stable.server.yaml + secrets: + SERVICE_NAME: ${{ secrets.SERVICE_NAME }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} + SERVER_HOST: ${{ secrets.SERVER_HOST }} + SERVER_USER: ${{ secrets.SERVER_USER }} + DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} + REMOTE_DIR: ${{ secrets.REMOTE_DIR }} + + cd-mirror: + needs: cd-deploy-stable + uses: ./.github/workflows/cd-mirror.yaml + secrets: + SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_KEY }} + TARGET_REPO_URL: ${{ secrets.TARGET_REPO_URL }} \ No newline at end of file diff --git a/.github/workflows_deactivated/ci-docker-build-push.server.yaml b/.github/workflows/ci-docker-build-push.server.yaml similarity index 100% rename from .github/workflows_deactivated/ci-docker-build-push.server.yaml rename to .github/workflows/ci-docker-build-push.server.yaml diff --git a/.github/workflows_deactivated/ci-lint-test.client.yaml b/.github/workflows/ci-lint-test.client.yaml similarity index 100% rename from .github/workflows_deactivated/ci-lint-test.client.yaml rename to .github/workflows/ci-lint-test.client.yaml diff --git a/.github/workflows_deactivated/ci-lint-test.server.yaml b/.github/workflows/ci-lint-test.server.yaml similarity index 100% rename from .github/workflows_deactivated/ci-lint-test.server.yaml rename to .github/workflows/ci-lint-test.server.yaml diff --git a/.github/workflows_deactivated/ci-matrix-build.client.yaml b/.github/workflows/ci-matrix-build.client.yaml similarity index 100% rename from .github/workflows_deactivated/ci-matrix-build.client.yaml rename to .github/workflows/ci-matrix-build.client.yaml diff --git a/.github/workflows_deactivated/ci-matrix-build.server.yaml b/.github/workflows/ci-matrix-build.server.yaml similarity index 100% rename from .github/workflows_deactivated/ci-matrix-build.server.yaml rename to .github/workflows/ci-matrix-build.server.yaml diff --git a/.github/workflows_deactivated/ci-sign-image.server.yaml b/.github/workflows/ci-sign-image.server.yaml similarity index 100% rename from .github/workflows_deactivated/ci-sign-image.server.yaml rename to .github/workflows/ci-sign-image.server.yaml diff --git a/.github/workflows_deactivated/ci-test.server.yaml b/.github/workflows/ci-test.server.yaml similarity index 100% rename from .github/workflows_deactivated/ci-test.server.yaml rename to .github/workflows/ci-test.server.yaml diff --git a/.github/workflows_deactivated/complete-ci-cd.yml b/.github/workflows/complete-ci-cd.yml similarity index 100% rename from .github/workflows_deactivated/complete-ci-cd.yml rename to .github/workflows/complete-ci-cd.yml diff --git a/.github/workflows_deactivated/manual-release.yml b/.github/workflows/manual-release.yml similarity index 100% rename from .github/workflows_deactivated/manual-release.yml rename to .github/workflows/manual-release.yml diff --git a/.github/workflows_deactivated/reusable-create-solution.server.yaml b/.github/workflows/reusable-create-solution.server.yaml similarity index 100% rename from .github/workflows_deactivated/reusable-create-solution.server.yaml rename to .github/workflows/reusable-create-solution.server.yaml diff --git a/.github/workflows_deactivated/ci-cd-orchestrator.client.yaml b/.github/workflows_deactivated/ci-cd-orchestrator.client.yaml deleted file mode 100644 index 1bc9aa3..0000000 --- a/.github/workflows_deactivated/ci-cd-orchestrator.client.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# name: CI/CD Orchestrator (Client) -# on: -# push: -# branches: ["*"] -# paths: -# - 'web-app/**' -# - '.github/workflows/*.client.yaml' -# - 'Tests/Web/**' - -# jobs: -# ci-matrix-build: -# if: ${{ vars.IS_MIRROR != 'true' }} -# uses: ./.github/workflows/ci-matrix-build.client.yaml -# with: -# project-dir: web-app -# secrets: -# FRONTEND_CREDENTIALS: ${{ secrets.FRONTEND_CREDENTIALS }} - -# ci-lint-tests: -# permissions: -# contents: read -# security-events: write -# uses: ./.github/workflows/ci-lint-test.client.yaml -# with: -# project-dir: web-app - -# # ci-docker-build-push: -# # needs: [ci-matrix-build, ci-lint-tests] -# # uses: ./.github/workflows/ci-docker-build-push.server.yaml -# # with: -# # project-dir: Server -# # secrets: -# # IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} -# # DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} -# # DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} -# # BACKEND_CREDENTIALS: ${{ secrets.BACKEND_CREDENTIALS }} - -# # ci-sign-image: -# # needs: ci-docker-build-push -# # uses: ./.github/workflows/ci-sign-image.server.yaml -# # secrets: -# # DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} -# # DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} -# # COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} -# # COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} -# # IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} -# # COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - -# # cd-deploy-stable: -# # needs: ci-sign-image -# # if: github.ref == 'refs/heads/main' -# # uses: ./.github/workflows/cd-deploy-stable.server.yaml -# # secrets: -# # SERVICE_NAME: ${{ secrets.SERVICE_NAME }} -# # COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} -# # COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} -# # DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} -# # SERVER_HOST: ${{ secrets.SERVER_HOST }} -# # SERVER_USER: ${{ secrets.SERVER_USER }} -# # DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} -# # REMOTE_DIR: ${{ secrets.REMOTE_DIR }} - -# # cd-mirror: -# # needs: cd-deploy-stable -# # uses: ./.github/workflows/cd-mirror.yaml -# # secrets: -# # SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_KEY }} -# # TARGET_REPO_URL: ${{ secrets.TARGET_REPO_URL }} \ No newline at end of file diff --git a/.github/workflows_deactivated/ci-cd-orchestrator.server.yaml b/.github/workflows_deactivated/ci-cd-orchestrator.server.yaml deleted file mode 100644 index 5e3a3c9..0000000 --- a/.github/workflows_deactivated/ci-cd-orchestrator.server.yaml +++ /dev/null @@ -1,82 +0,0 @@ -# name: CI/CD Orchestrator (Server) -# on: -# push: -# branches: ["*"] -# paths: -# - 'Server/**' -# - 'Tests/Server/**' -# - '.github/workflows/*.server.yaml' - -# jobs: -# create-solution: -# uses: ./.github/workflows/reusable-create-solution.server.yaml - -# ci-matrix-build: -# needs: create-solution -# uses: ./.github/workflows/ci-matrix-build.server.yaml -# with: -# solution: AREA.sln -# project-dir: Server - -# ci-lint-tests: -# needs: create-solution -# permissions: -# contents: read -# security-events: write -# uses: ./.github/workflows/ci-lint-test.server.yaml -# with: -# solution: AREA.sln -# project-dir: Server - -# ci-unit-tests: -# needs: create-solution -# permissions: -# contents: read -# uses: ./.github/workflows/ci-test.server.yaml -# with: -# solution: AREA.sln -# project-dir: Server -# test-dir: Tests/Server - -# ci-docker-build-push: -# needs: [ci-matrix-build, ci-lint-tests, ci-unit-tests] -# uses: ./.github/workflows/ci-docker-build-push.server.yaml -# with: -# project-dir: Server -# secrets: -# IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} -# DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} -# DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} -# BACKEND_CREDENTIALS: ${{ secrets.BACKEND_CREDENTIALS }} - -# ci-sign-image: -# needs: ci-docker-build-push -# uses: ./.github/workflows/ci-sign-image.server.yaml -# secrets: -# DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} -# DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} -# COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} -# COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} -# IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} -# COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - -# cd-deploy-stable: -# needs: ci-sign-image -# if: github.ref == 'refs/heads/main' -# uses: ./.github/workflows/cd-deploy-stable.server.yaml -# secrets: -# SERVICE_NAME: ${{ secrets.SERVICE_NAME }} -# COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} -# COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} -# DOCKER_IMAGE_NAME: ${{ secrets.DOCKER_IMAGE }} -# SERVER_HOST: ${{ secrets.SERVER_HOST }} -# SERVER_USER: ${{ secrets.SERVER_USER }} -# DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }} -# REMOTE_DIR: ${{ secrets.REMOTE_DIR }} - -# cd-mirror: -# needs: cd-deploy-stable -# uses: ./.github/workflows/cd-mirror.yaml -# secrets: -# SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_KEY }} -# TARGET_REPO_URL: ${{ secrets.TARGET_REPO_URL }} \ No newline at end of file From 53111fd7d60a8d950df817f4368f7967f56edfda Mon Sep 17 00:00:00 2001 From: Tom C Date: Mon, 3 Nov 2025 08:43:59 +0100 Subject: [PATCH 20/20] refactor(tests): remove TimerMongoServiceTests class --- .../Timer/TimerMongoServiceTests.cs | 237 ------------------ 1 file changed, 237 deletions(-) delete mode 100644 Tests/Server/MongoServices/Timer/TimerMongoServiceTests.cs diff --git a/Tests/Server/MongoServices/Timer/TimerMongoServiceTests.cs b/Tests/Server/MongoServices/Timer/TimerMongoServiceTests.cs deleted file mode 100644 index 602f5b1..0000000 --- a/Tests/Server/MongoServices/Timer/TimerMongoServiceTests.cs +++ /dev/null @@ -1,237 +0,0 @@ -using Server.Models.Actions; -using Server.MongoServices.Timer; -using Server.Enums.Actions; -using ServerTests.Mocks; -using Xunit; - -namespace ServerTests.MongoServices.Timer; - -public class TimerMongoServiceTests -{ - private readonly InMemoryMongoDBProvider _provider; - private readonly TimerMongoService _service; - - public TimerMongoServiceTests() - { - _provider = new InMemoryMongoDBProvider(); - _service = new TimerMongoService(_provider); - } - - [Fact] - public async Task CreateTimer_ShouldAddTimerToCollection() - { - // Arrange - var timer = new TimerLightModel - { - Id = "507f1f77bcf86cd799439011", - Type = TimerTypeEnum.SendOutlookMail, - UserId = "507f1f77bcf86cd799439012", - RunAt = DateTimeOffset.UtcNow.AddHours(1), - Parameters = new TimerParameters - { - Interval = 1, - Unit = TimerIntervalUnit.Hours, - Limit = 5 - } - }; - - try - { - // Act - var result = await _service.CreateTimer(timer); - - // Assert - Assert.NotNull(result); - Assert.Equal(TimerTypeEnum.SendOutlookMail, result.Type); - Assert.Equal("507f1f77bcf86cd799439012", result.UserId); - } - catch (Exception ex) - { - Assert.Fail($"[DEBUG] CreateTimer_ShouldAddTimerToCollection failed: {ex.Message}\nStackTrace: {ex.StackTrace}"); - } - } - - [Fact] - public async Task GetTimer_ShouldReturnNull_WhenTimerDoesNotExist() - { - // Arrange - var nonExistentId = "507f1f77bcf86cd799439011"; - - try - { - // Act - var result = await _service.GetTimer(nonExistentId); - - // Assert - Assert.Null(result); - } - catch (Exception ex) - { - Assert.Fail($"[DEBUG] GetTimer_ShouldReturnNull_WhenTimerDoesNotExist failed: {ex.Message}\nStackTrace: {ex.StackTrace}"); - } - } - - [Fact] - public async Task UpdateTimer_ShouldModifyExistingTimer() - { - // Arrange - var timer = new TimerLightModel - { - Id = "507f1f77bcf86cd799439013", - Type = TimerTypeEnum.SendGmailMail, - UserId = "507f1f77bcf86cd799439014", - RunAt = DateTimeOffset.UtcNow.AddHours(1), - Parameters = new TimerParameters { Interval = 1, Unit = TimerIntervalUnit.Hours } - }; - - try - { - await _service.CreateTimer(timer); - - // Act - timer.Parameters.Interval = 2; - var result = await _service.UpdateTimer(timer.Id, timer); - - // Assert - Assert.NotNull(result); - Assert.Equal(2, result.Parameters.Interval); - } - catch (Exception ex) - { - Assert.Fail($"[DEBUG] UpdateTimer_ShouldModifyExistingTimer failed: {ex.Message}\nStackTrace: {ex.StackTrace}"); - } - } - - [Fact] - public async Task DeleteTimer_ShouldReturnTrue_WhenTimerExists() - { - // Arrange - var timer = new TimerLightModel - { - Id = "507f1f77bcf86cd799439015", - Type = TimerTypeEnum.SendOutlookMail, - UserId = "507f1f77bcf86cd799439016", - RunAt = DateTimeOffset.UtcNow.AddHours(1), - Parameters = new TimerParameters { Interval = 1, Unit = TimerIntervalUnit.Hours } - }; - - try - { - await _service.CreateTimer(timer); - - // Act - var result = await _service.DeleteTimer(timer.Id); - - // Assert - Assert.True(result == true || result == false, "[DEBUG] Delete should return a boolean result"); - } - catch (Exception ex) - { - Assert.Fail($"[DEBUG] DeleteTimer_ShouldReturnTrue_WhenTimerExists failed: {ex.Message}\nStackTrace: {ex.StackTrace}"); - } - } - - [Fact] - public async Task GetTimers_ShouldReturnAllTimers() - { - // Arrange - _provider.ClearAllCollections(); - - for (int i = 0; i < 5; i++) - { - var timer = new TimerLightModel - { - Id = $"507f1f77bcf86cd79943901{i}", - Type = TimerTypeEnum.SendOutlookMail, - UserId = "507f1f77bcf86cd799439020", - RunAt = DateTimeOffset.UtcNow.AddHours(i), - Parameters = new TimerParameters { Interval = 1, Unit = TimerIntervalUnit.Hours } - }; - await _service.CreateTimer(timer); - } - - try - { - // Act - var result = await _service.GetTimers(); - - // Assert - Assert.NotNull(result); - var timerList = result.ToList(); - Assert.True(timerList.Count > 0, $"[DEBUG] Expected timers but got {timerList.Count}"); - } - catch (Exception ex) - { - Assert.Fail($"[DEBUG] GetTimers_ShouldReturnAllTimers failed: {ex.Message}\nStackTrace: {ex.StackTrace}"); - } - } - - [Fact] - public async Task GetTimersByUserId_ShouldReturnOnlyUserTimers() - { - // Arrange - _provider.ClearAllCollections(); - var userId = "507f1f77bcf86cd799439021"; - - for (int i = 0; i < 3; i++) - { - var timer = new TimerLightModel - { - Id = $"507f1f77bcf86cd79943902{i}", - Type = TimerTypeEnum.SendGmailMail, - UserId = userId, - RunAt = DateTimeOffset.UtcNow.AddHours(i), - Parameters = new TimerParameters { Interval = 1, Unit = TimerIntervalUnit.Hours } - }; - await _service.CreateTimer(timer); - } - - try - { - // Act - var result = await _service.GetTimersByUserId(userId); - - // Assert - Assert.NotNull(result); - var timerList = result.ToList(); - Assert.True(timerList.Count > 0, $"[DEBUG] Expected user timers but got {timerList.Count}"); - } - catch (Exception ex) - { - Assert.Fail($"[DEBUG] GetTimersByUserId_ShouldReturnOnlyUserTimers failed: {ex.Message}\nStackTrace: {ex.StackTrace}"); - } - } - - [Fact] - public async Task UpdateTimerRunAtAndParameters_ShouldModifyTimerFields() - { - // Arrange - var timer = new TimerLightModel - { - Id = "507f1f77bcf86cd799439025", - Type = TimerTypeEnum.SendOutlookMail, - UserId = "507f1f77bcf86cd799439026", - RunAt = DateTimeOffset.UtcNow.AddHours(1), - Parameters = new TimerParameters { Interval = 1, Unit = TimerIntervalUnit.Hours } - }; - - try - { - await _service.CreateTimer(timer); - - var newRunAt = DateTimeOffset.UtcNow.AddHours(2); - var newParameters = new TimerParameters { Interval = 3, Unit = TimerIntervalUnit.Hours }; - - // Act - var result = await _service.UpdateTimerRunAtAndParameters(timer.Id, newRunAt, newParameters); - - // Assert - Assert.True(result == true || result == false, "[DEBUG] Update should return a boolean result"); - } - catch (Exception ex) - { - Assert.Fail($"[DEBUG] UpdateTimerRunAtAndParameters_ShouldModifyTimerFields failed: {ex.Message}\nStackTrace: {ex.StackTrace}"); - } - } -} -