From dd6f1d9e7a668167c696822ff5a029bc48f4c8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Mon, 13 Apr 2026 16:13:14 +0200 Subject: [PATCH 1/8] fix: use email-based user lookup and read phone numbers from SDK Workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fragile name-matching (site.Name vs FirstName+LastName) with email-based lookup (worker.Email ↔ Users.Email) across all 3 services. Read PhoneNumber from SDK Workers table where it's actually stored, not from Angular Users table where it was always NULL. Fixes: 8 occurrences in TimeSettingService, TimePlanningWorkingHoursService, and TimePlanningPlanningService. Adds integration tests verifying phone numbers flow through GetAvailableSitesByCurrentUser correctly. Co-Authored-By: Claude Opus 4.6 --- .../SettingsServicePhoneNumberTests.cs | 385 ++++++++++++++++++ .../TimePlanningPlanningService.cs | 20 +- .../TimeSettingService.cs | 24 +- .../TimePlanningWorkingHoursService.cs | 36 +- 4 files changed, 438 insertions(+), 27 deletions(-) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServicePhoneNumberTests.cs diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServicePhoneNumberTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServicePhoneNumberTests.cs new file mode 100644 index 00000000..0fd9607c --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServicePhoneNumberTests.cs @@ -0,0 +1,385 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microting.eForm.Infrastructure.Constants; +using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; +using TimePlanning.Pn.Infrastructure.Models.Settings; +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; +using NSubstitute; +using NUnit.Framework; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; +using TimePlanning.Pn.Services.TimePlanningSettingService; + +namespace TimePlanning.Pn.Test; + +[TestFixture] +public class SettingsServicePhoneNumberTests : TestBaseSetup +{ + private ISettingService _settingsService; + private IUserService _userService; + private ITimePlanningLocalizationService _localizationService; + private IEFormCoreService _coreService; + private IPluginDbOptions _options; + + [SetUp] + public async Task SetUp() + { + await base.Setup(); + _userService = Substitute.For(); + _userService.UserId.Returns(1); + + _localizationService = Substitute.For(); + _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + + _coreService = Substitute.For(); + var core = await GetCore(); + _coreService.GetCore().Returns(core); + + _options = Substitute.For>(); + _options.Value.Returns(new TimePlanningBaseSettings + { + AutoBreakCalculationActive = "0", + DayOfPayment = 20, + GpsEnabled = "0", + SnapshotEnabled = "0" + }); + + _settingsService = new TimeSettingService( + _options, + TimePlanningPnDbContext, + Substitute.For>(), + _userService, + _localizationService, + null, + _coreService); + } + + [Test] + public async Task GetAvailableSitesByCurrentUser_ReturnsPhoneNumbers_FromSdkWorkers() + { + // Arrange — create 3 SDK sites, site workers, and workers with varying phone numbers + var core = await _coreService.GetCore(); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + + // Worker 1: has phone number + var site1 = new Microting.eForm.Infrastructure.Data.Entities.Site + { + Name = "Site Alice", + MicrotingUid = 100 + }; + await site1.Create(sdkDbContext); + + var worker1 = new Microting.eForm.Infrastructure.Data.Entities.Worker + { + FirstName = "Alice", + LastName = "Smith", + Email = "alice@example.com", + PhoneNumber = "+4512345678", + MicrotingUid = 1001 + }; + await worker1.Create(sdkDbContext); + + var siteWorker1 = new Microting.eForm.Infrastructure.Data.Entities.SiteWorker + { + SiteId = site1.Id, + WorkerId = worker1.Id, + MicrotingUid = 2001 + }; + await siteWorker1.Create(sdkDbContext); + + // Worker 2: no phone number (null) + var site2 = new Microting.eForm.Infrastructure.Data.Entities.Site + { + Name = "Site Bob", + MicrotingUid = 200 + }; + await site2.Create(sdkDbContext); + + var worker2 = new Microting.eForm.Infrastructure.Data.Entities.Worker + { + FirstName = "Bob", + LastName = "Jones", + Email = "bob@example.com", + PhoneNumber = null, + MicrotingUid = 1002 + }; + await worker2.Create(sdkDbContext); + + var siteWorker2 = new Microting.eForm.Infrastructure.Data.Entities.SiteWorker + { + SiteId = site2.Id, + WorkerId = worker2.Id, + MicrotingUid = 2002 + }; + await siteWorker2.Create(sdkDbContext); + + // Worker 3: has different phone number + var site3 = new Microting.eForm.Infrastructure.Data.Entities.Site + { + Name = "Site Carol", + MicrotingUid = 300 + }; + await site3.Create(sdkDbContext); + + var worker3 = new Microting.eForm.Infrastructure.Data.Entities.Worker + { + FirstName = "Carol", + LastName = "Lee", + Email = "carol@example.com", + PhoneNumber = "+4587654321", + MicrotingUid = 1003 + }; + await worker3.Create(sdkDbContext); + + var siteWorker3 = new Microting.eForm.Infrastructure.Data.Entities.SiteWorker + { + SiteId = site3.Id, + WorkerId = worker3.Id, + MicrotingUid = 2003 + }; + await siteWorker3.Create(sdkDbContext); + + // Create units (required by the service) + var unit1 = new Microting.eForm.Infrastructure.Data.Entities.Unit + { + SiteId = site1.Id, + MicrotingUid = 3001, + CustomerNo = 1 + }; + await unit1.Create(sdkDbContext); + + var unit2 = new Microting.eForm.Infrastructure.Data.Entities.Unit + { + SiteId = site2.Id, + MicrotingUid = 3002, + CustomerNo = 2 + }; + await unit2.Create(sdkDbContext); + + var unit3 = new Microting.eForm.Infrastructure.Data.Entities.Unit + { + SiteId = site3.Id, + MicrotingUid = 3003, + CustomerNo = 3 + }; + await unit3.Create(sdkDbContext); + + // Create languages + var language = await sdkDbContext.Languages.FirstOrDefaultAsync(); + if (language == null) + { + language = new Microting.eForm.Infrastructure.Data.Entities.Language + { + LanguageCode = "da", + Name = "Danish" + }; + await language.Create(sdkDbContext); + } + + // Assign language to sites + site1.LanguageId = language.Id; + await site1.Update(sdkDbContext); + site2.LanguageId = language.Id; + await site2.Update(sdkDbContext); + site3.LanguageId = language.Id; + await site3.Update(sdkDbContext); + + // Create AssignedSites in plugin DB (links to SDK sites via MicrotingUid) + var assignedSite1 = new AssignedSiteEntity + { + SiteId = 100, // matches site1.MicrotingUid + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite1.Create(TimePlanningPnDbContext); + + var assignedSite2 = new AssignedSiteEntity + { + SiteId = 200, // matches site2.MicrotingUid + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite2.Create(TimePlanningPnDbContext); + + var assignedSite3 = new AssignedSiteEntity + { + SiteId = 300, // matches site3.MicrotingUid + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite3.Create(TimePlanningPnDbContext); + + // Act + var result = await _settingsService.GetAvailableSitesByCurrentUser(); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Model, Is.Not.Null); + Assert.That(result.Model.Count, Is.EqualTo(3)); + + var alice = result.Model.First(x => x.FirstName == "Alice"); + Assert.That(alice.PhoneNumber, Is.EqualTo("+4512345678")); + Assert.That(alice.LastName, Is.EqualTo("Smith")); + Assert.That(alice.Email, Is.EqualTo("alice@example.com")); + + var bob = result.Model.First(x => x.FirstName == "Bob"); + Assert.That(bob.PhoneNumber, Is.EqualTo("")); + Assert.That(bob.LastName, Is.EqualTo("Jones")); + + var carol = result.Model.First(x => x.FirstName == "Carol"); + Assert.That(carol.PhoneNumber, Is.EqualTo("+4587654321")); + Assert.That(carol.LastName, Is.EqualTo("Lee")); + } + + [Test] + public async Task GetAvailableSitesByCurrentUser_PhoneComesFromWorker_NotFromUser() + { + // This test verifies that even when an Angular User exists with a DIFFERENT phone number, + // the phone number returned comes from the SDK Worker, not the User. + var core = await _coreService.GetCore(); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + + var site = new Microting.eForm.Infrastructure.Data.Entities.Site + { + Name = "Site Dave", + MicrotingUid = 400 + }; + await site.Create(sdkDbContext); + + var worker = new Microting.eForm.Infrastructure.Data.Entities.Worker + { + FirstName = "Dave", + LastName = "Brown", + Email = "dave@example.com", + PhoneNumber = "+45WorkerPhone", + MicrotingUid = 1004 + }; + await worker.Create(sdkDbContext); + + var siteWorker = new Microting.eForm.Infrastructure.Data.Entities.SiteWorker + { + SiteId = site.Id, + WorkerId = worker.Id, + MicrotingUid = 2004 + }; + await siteWorker.Create(sdkDbContext); + + var unit = new Microting.eForm.Infrastructure.Data.Entities.Unit + { + SiteId = site.Id, + MicrotingUid = 3004, + CustomerNo = 4 + }; + await unit.Create(sdkDbContext); + + var language = await sdkDbContext.Languages.FirstOrDefaultAsync(); + if (language == null) + { + language = new Microting.eForm.Infrastructure.Data.Entities.Language + { + LanguageCode = "da", + Name = "Danish" + }; + await language.Create(sdkDbContext); + } + site.LanguageId = language.Id; + await site.Update(sdkDbContext); + + var assignedSite = new AssignedSiteEntity + { + SiteId = 400, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + // Note: baseDbContext is null in this test setup, so no Angular User lookup occurs. + // The key assertion is that PhoneNumber comes from worker.PhoneNumber. + + // Act + var result = await _settingsService.GetAvailableSitesByCurrentUser(); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Model.Count, Is.EqualTo(1)); + + var dave = result.Model.First(); + Assert.That(dave.PhoneNumber, Is.EqualTo("+45WorkerPhone")); + Assert.That(dave.FirstName, Is.EqualTo("Dave")); + Assert.That(dave.LastName, Is.EqualTo("Brown")); + Assert.That(dave.Email, Is.EqualTo("dave@example.com")); + } + + [Test] + public async Task GetAvailableSitesByCurrentUser_ExcludesResignedSites() + { + // Verify resigned sites are excluded from the result + var core = await _coreService.GetCore(); + var sdkDbContext = core.DbContextHelper.GetDbContext(); + + var site = new Microting.eForm.Infrastructure.Data.Entities.Site + { + Name = "Site Resigned", + MicrotingUid = 500 + }; + await site.Create(sdkDbContext); + + var worker = new Microting.eForm.Infrastructure.Data.Entities.Worker + { + FirstName = "Eve", + LastName = "Wilson", + Email = "eve@example.com", + PhoneNumber = "+4511111111", + MicrotingUid = 1005 + }; + await worker.Create(sdkDbContext); + + var siteWorker = new Microting.eForm.Infrastructure.Data.Entities.SiteWorker + { + SiteId = site.Id, + WorkerId = worker.Id, + MicrotingUid = 2005 + }; + await siteWorker.Create(sdkDbContext); + + var unit = new Microting.eForm.Infrastructure.Data.Entities.Unit + { + SiteId = site.Id, + MicrotingUid = 3005, + CustomerNo = 5 + }; + await unit.Create(sdkDbContext); + + var language = await sdkDbContext.Languages.FirstOrDefaultAsync(); + if (language == null) + { + language = new Microting.eForm.Infrastructure.Data.Entities.Language + { + LanguageCode = "da", + Name = "Danish" + }; + await language.Create(sdkDbContext); + } + site.LanguageId = language.Id; + await site.Update(sdkDbContext); + + // Create a resigned assigned site + var assignedSite = new AssignedSiteEntity + { + SiteId = 500, + Resigned = true, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + // Act + var result = await _settingsService.GetAvailableSitesByCurrentUser(); + + // Assert + Assert.That(result.Success, Is.True); + Assert.That(result.Model.Count, Is.EqualTo(0)); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index 1bf8863e..b70ef47c 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -179,6 +179,12 @@ await dbContext.AssignedSites var sitesList = await sdkDbContext.Sites .AsNoTracking() .ToListAsync().ConfigureAwait(false); + var siteWorkersList = await sdkDbContext.SiteWorkers + .AsNoTracking() + .ToListAsync().ConfigureAwait(false); + var workersList = await sdkDbContext.Workers + .AsNoTracking() + .ToListAsync().ConfigureAwait(false); var tasks = assignedSites.Select(async dbAssignedSite => { @@ -200,10 +206,11 @@ await dbContext.AssignedSites PlanningPrDayModels = new List() }; - // do a lookup in the baseDbContext.Users where the concat string of FirstName and LastName toLowerCase() is equal to the site.Name toLowerCase() - // if we find a user, we take the user.EmailSha256 and set the siteModel.AvatarUrl to the gravatar url with the sha256 - var user = usersList.FirstOrDefault(x => (x.FirstName + " " + x.LastName).Replace(" ", "").ToLower() == - site.Name.Replace(" ", "").ToLower()); + var siteWorker = siteWorkersList.FirstOrDefault(x => x.SiteId == site.Id); + var worker = siteWorker != null ? workersList.FirstOrDefault(x => x.Id == siteWorker.WorkerId) : null; + var workerEmail = (worker?.Email ?? "").Trim().ToLower(); + var user = string.IsNullOrEmpty(workerEmail) ? null + : usersList.FirstOrDefault(x => (x.Email ?? "").Trim().ToLower() == workerEmail); if (user != null) { siteModel.AvatarUrl = user.ProfilePictureSnapshot != null @@ -418,8 +425,9 @@ public async Task> IndexByCurrent siteModel.SoftwareVersionIsValid = false; } - var user = await baseDbContext.Users - .Where(x => (x.FirstName + " " + x.LastName).Replace(" ", "").ToLower() == site.Name.Replace(" ", "").ToLower()) + var workerEmail2 = (worker.Email ?? "").Trim().ToLower(); + var user = string.IsNullOrEmpty(workerEmail2) ? null : await baseDbContext.Users + .Where(x => x.Email.ToLower() == workerEmail2) .FirstOrDefaultAsync().ConfigureAwait(false); if (user != null) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs index bd863d4c..e7c6d337 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs @@ -400,17 +400,17 @@ planRegistrationForToday is ResignedAtDate = assignedSite.ResignedAtDate, SnapshotEnabled = assignedSite.SnapshotEnabled }; - var normalizedSiteName = (site.Name ?? "").Replace(" ", "").ToLower(); - var user = baseDbContext == null ? null : await baseDbContext.Users - .Where(x => (x.FirstName + " " + x.LastName).Replace(" ", "").ToLower() == normalizedSiteName) + var workerEmail = (worker.Email ?? "").Trim().ToLower(); + var user = baseDbContext == null || string.IsNullOrEmpty(workerEmail) ? null : await baseDbContext.Users + .Where(x => x.Email.ToLower() == workerEmail) .FirstOrDefaultAsync().ConfigureAwait(false); if (user != null) { newSite.AvatarUrl = user.ProfilePictureSnapshot != null ? $"api/images/login-page-images?fileName={user.ProfilePictureSnapshot}" : $"https://www.gravatar.com/avatar/{user.EmailSha256}?s=32&d=identicon"; - newSite.PhoneNumber = user.PhoneNumber ?? ""; } + newSite.PhoneNumber = worker.PhoneNumber ?? ""; sites.Add(newSite); } } @@ -536,17 +536,17 @@ planRegistrationForToday is ResignedAtDate = assignedSite.ResignedAtDate, SnapshotEnabled = assignedSite.SnapshotEnabled }; - var normalizedSiteName = (site.Name ?? "").Replace(" ", "").ToLower(); - var user = baseDbContext == null ? null : await baseDbContext.Users - .Where(x => (x.FirstName + " " + x.LastName).Replace(" ", "").ToLower() == normalizedSiteName) + var workerEmail = (worker.Email ?? "").Trim().ToLower(); + var user = baseDbContext == null || string.IsNullOrEmpty(workerEmail) ? null : await baseDbContext.Users + .Where(x => x.Email.ToLower() == workerEmail) .FirstOrDefaultAsync().ConfigureAwait(false); if (user != null) { newSite.AvatarUrl = user.ProfilePictureSnapshot != null ? $"api/images/login-page-images?fileName={user.ProfilePictureSnapshot}" : $"https://www.gravatar.com/avatar/{user.EmailSha256}?s=32&d=identicon"; - newSite.PhoneNumber = user.PhoneNumber ?? ""; } + newSite.PhoneNumber = worker.PhoneNumber ?? ""; sites.Add(newSite); } @@ -816,17 +816,17 @@ planRegistrationForToday is Resigned = assignedSite.Resigned, ResignedAtDate = assignedSite.ResignedAtDate, }; - var normalizedSiteName = (site.Name ?? "").Replace(" ", "").ToLower(); - var user = baseDbContext == null ? null : await baseDbContext.Users - .Where(x => (x.FirstName + " " + x.LastName).Replace(" ", "").ToLower() == normalizedSiteName) + var workerEmail = (worker.Email ?? "").Trim().ToLower(); + var user = baseDbContext == null || string.IsNullOrEmpty(workerEmail) ? null : await baseDbContext.Users + .Where(x => x.Email.ToLower() == workerEmail) .FirstOrDefaultAsync().ConfigureAwait(false); if (user != null) { newSite.AvatarUrl = user.ProfilePictureSnapshot != null ? $"api/images/login-page-images?fileName={user.ProfilePictureSnapshot}" : $"https://www.gravatar.com/avatar/{user.EmailSha256}?s=32&d=identicon"; - newSite.PhoneNumber = user.PhoneNumber ?? ""; } + newSite.PhoneNumber = worker.PhoneNumber ?? ""; sites.Add(newSite); } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs index a10fe567..6441ec25 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs @@ -535,11 +535,17 @@ public async Task> ReadS var currentUserAsync = await userService.GetCurrentUserAsync(); var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); - var fullName = currentUser.FirstName.Trim() + " " + currentUser.LastName.Trim(); + var userEmail = (currentUser.Email ?? "").Trim().ToLower(); var core = await coreHelper.GetCore(); var sdkContext = core.DbContextHelper.GetDbContext(); - var sdkSite = await sdkContext.Sites.SingleOrDefaultAsync(x => - x.Name.Replace(" ", "") == fullName.Replace(" ", "") && + var sdkWorker = await sdkContext.Workers.FirstOrDefaultAsync(x => + x.Email.ToLower() == userEmail && + x.WorkflowState != Constants.WorkflowStates.Removed); + var sdkSiteWorker = sdkWorker == null ? null : await sdkContext.SiteWorkers.FirstOrDefaultAsync(x => + x.WorkerId == sdkWorker.Id && + x.WorkflowState != Constants.WorkflowStates.Removed); + var sdkSite = sdkSiteWorker == null ? null : await sdkContext.Sites.FirstOrDefaultAsync(x => + x.Id == sdkSiteWorker.SiteId && x.WorkflowState != Constants.WorkflowStates.Removed); if (sdkSite == null) @@ -663,9 +669,15 @@ public async Task> CalculateH var currentUserAsync = await userService.GetCurrentUserAsync(); var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); - var fullName = currentUser.FirstName.Trim() + " " + currentUser.LastName.Trim(); - var sdkSite = await sdkContext.Sites.SingleOrDefaultAsync(x => - x.Name.Replace(" ", "") == fullName.Replace(" ", "") && + var userEmail = (currentUser.Email ?? "").Trim().ToLower(); + var sdkWorker = await sdkContext.Workers.FirstOrDefaultAsync(x => + x.Email.ToLower() == userEmail && + x.WorkflowState != Constants.WorkflowStates.Removed); + var sdkSiteWorker = sdkWorker == null ? null : await sdkContext.SiteWorkers.FirstOrDefaultAsync(x => + x.WorkerId == sdkWorker.Id && + x.WorkflowState != Constants.WorkflowStates.Removed); + var sdkSite = sdkSiteWorker == null ? null : await sdkContext.Sites.FirstOrDefaultAsync(x => + x.Id == sdkSiteWorker.SiteId && x.WorkflowState != Constants.WorkflowStates.Removed); if (sdkSite == null) @@ -749,9 +761,15 @@ public async Task UpdateWorkingHour(TimePlanningWorkingHoursUpd var currentUserAsync = await userService.GetCurrentUserAsync(); var currentUser = baseDbContext.Users .Single(x => x.Id == currentUserAsync.Id); - var fullName = currentUser.FirstName.Trim() + " " + currentUser.LastName.Trim(); - var sdkSite = await sdkDbContext.Sites.SingleOrDefaultAsync(x => - x.Name.Replace(" ", "") == fullName.Replace(" ", "") && + var userEmail = (currentUser.Email ?? "").Trim().ToLower(); + var sdkWorker = await sdkDbContext.Workers.FirstOrDefaultAsync(x => + x.Email.ToLower() == userEmail && + x.WorkflowState != Constants.WorkflowStates.Removed); + var sdkSiteWorker = sdkWorker == null ? null : await sdkDbContext.SiteWorkers.FirstOrDefaultAsync(x => + x.WorkerId == sdkWorker.Id && + x.WorkflowState != Constants.WorkflowStates.Removed); + var sdkSite = sdkSiteWorker == null ? null : await sdkDbContext.Sites.FirstOrDefaultAsync(x => + x.Id == sdkSiteWorker.SiteId && x.WorkflowState != Constants.WorkflowStates.Removed); if (sdkSite == null) From 9add5c7bb452c1d5a019b9f2b41c25225a3007e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 14 Apr 2026 09:06:36 +0200 Subject: [PATCH 2/8] fix: persist planned shifts 3-5 on Update + UpdateByCurrentUserNam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The planning save paths only copied shifts 1-2 from the request model onto the PlanRegistration entity, silently dropping shifts 3-5 even though the entity columns exist and the read path sums all five. UpdateByCurrentUserNam (the path reached by the Flutter worker app via gRPC) was missing the assignments entirely. Adds a C# integration test that round-trips all 5 shifts through Update() + EF, and a Playwright E2E that exercises the full web UI save/reload of all 5 shifts in the workday-entity dialog — both serve as regression guards. Co-Authored-By: Claude Opus 4.6 --- .../PlanningServiceMultiShiftTests.cs | 141 ++++++++++++++++++ .../TimePlanningPlanningService.cs | 25 ++++ .../b/dashboard-edit-multishift.spec.ts | 129 ++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs create mode 100644 eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs new file mode 100644 index 00000000..912f0192 --- /dev/null +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microting.eForm.Infrastructure.Constants; +using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; +using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; +using NSubstitute; +using NUnit.Framework; +using TimePlanning.Pn.Infrastructure.Helpers; +using TimePlanning.Pn.Infrastructure.Models.Planning; +using TimePlanning.Pn.Infrastructure.Models.Settings; +using TimePlanning.Pn.Services.TimePlanningLocalizationService; +using TimePlanning.Pn.Services.TimePlanningPlanningService; + +namespace TimePlanning.Pn.Test; + +[TestFixture] +public class PlanningServiceMultiShiftTests : TestBaseSetup +{ + private ITimePlanningPlanningService _service; + private IUserService _userService; + private ITimePlanningLocalizationService _localizationService; + private IEFormCoreService _coreService; + private ITimePlanningDbContextHelper _dbContextHelper; + private IPluginDbOptions _options; + + [SetUp] + public async Task SetUpTest() + { + await base.Setup(); + + _userService = Substitute.For(); + _userService.UserId.Returns(1); + + _localizationService = Substitute.For(); + _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); + + _coreService = Substitute.For(); + var core = await GetCore(); + _coreService.GetCore().Returns(core); + + _dbContextHelper = Substitute.For(); + _dbContextHelper.GetDbContext().Returns(TimePlanningPnDbContext); + + _options = Substitute.For>(); + _options.Value.Returns(new TimePlanningBaseSettings + { + AutoBreakCalculationActive = "0", + DayOfPayment = 20, + GpsEnabled = "0", + SnapshotEnabled = "0" + }); + + _service = new TimePlanningPlanningService( + Substitute.For>(), + _options, + TimePlanningPnDbContext, + _dbContextHelper, + _userService, + _localizationService, + null, + _coreService); + } + + [Test] + public async Task Update_PersistsAllFiveShifts_RoundTrip() + { + // Arrange — seed AssignedSite + PlanRegistration + var assignedSite = new AssignedSiteEntity + { + SiteId = 900, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + var planning = new PlanRegistration + { + SdkSitId = 900, + Date = DateTime.UtcNow.Date, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await planning.Create(TimePlanningPnDbContext); + + // Build model with 5 shifts: + // Shift 1: 00:00-01:00 (0-60) break 5 + // Shift 2: 02:00-03:00 (120-180) break 10 + // Shift 3: 04:00-05:00 (240-300) break 15 + // Shift 4: 06:00-07:00 (360-420) break 20 + // Shift 5: 07:00-08:00 (420-480) break 25 + var model = new TimePlanningPlanningPrDayModel + { + Id = planning.Id, + Date = planning.Date, + CommentOffice = "", + PlannedStartOfShift1 = 0, PlannedEndOfShift1 = 60, PlannedBreakOfShift1 = 5, + PlannedStartOfShift2 = 120, PlannedEndOfShift2 = 180, PlannedBreakOfShift2 = 10, + PlannedStartOfShift3 = 240, PlannedEndOfShift3 = 300, PlannedBreakOfShift3 = 15, + PlannedStartOfShift4 = 360, PlannedEndOfShift4 = 420, PlannedBreakOfShift4 = 20, + PlannedStartOfShift5 = 420, PlannedEndOfShift5 = 480, PlannedBreakOfShift5 = 25, + }; + + // Act + var result = await _service.Update(planning.Id, model); + + // Assert + Assert.That(result.Success, Is.True, result.Message); + + var reloaded = await TimePlanningPnDbContext.PlanRegistrations + .AsNoTracking() + .FirstAsync(x => x.Id == planning.Id); + + Assert.Multiple(() => + { + Assert.That(reloaded.PlannedStartOfShift1, Is.EqualTo(0)); + Assert.That(reloaded.PlannedEndOfShift1, Is.EqualTo(60)); + Assert.That(reloaded.PlannedBreakOfShift1, Is.EqualTo(5)); + + Assert.That(reloaded.PlannedStartOfShift2, Is.EqualTo(120)); + Assert.That(reloaded.PlannedEndOfShift2, Is.EqualTo(180)); + Assert.That(reloaded.PlannedBreakOfShift2, Is.EqualTo(10)); + + Assert.That(reloaded.PlannedStartOfShift3, Is.EqualTo(240)); + Assert.That(reloaded.PlannedEndOfShift3, Is.EqualTo(300)); + Assert.That(reloaded.PlannedBreakOfShift3, Is.EqualTo(15)); + + Assert.That(reloaded.PlannedStartOfShift4, Is.EqualTo(360)); + Assert.That(reloaded.PlannedEndOfShift4, Is.EqualTo(420)); + Assert.That(reloaded.PlannedBreakOfShift4, Is.EqualTo(20)); + + Assert.That(reloaded.PlannedStartOfShift5, Is.EqualTo(420)); + Assert.That(reloaded.PlannedEndOfShift5, Is.EqualTo(480)); + Assert.That(reloaded.PlannedBreakOfShift5, Is.EqualTo(25)); + }); + } +} diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs index b70ef47c..81451050 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs @@ -570,6 +570,15 @@ public async Task Update(int id, TimePlanningPlanningPrDayModel planning.PlannedStartOfShift2 = model.PlannedStartOfShift2; planning.PlannedBreakOfShift2 = model.PlannedBreakOfShift2; planning.PlannedEndOfShift2 = model.PlannedEndOfShift2; + planning.PlannedStartOfShift3 = model.PlannedStartOfShift3; + planning.PlannedBreakOfShift3 = model.PlannedBreakOfShift3; + planning.PlannedEndOfShift3 = model.PlannedEndOfShift3; + planning.PlannedStartOfShift4 = model.PlannedStartOfShift4; + planning.PlannedBreakOfShift4 = model.PlannedBreakOfShift4; + planning.PlannedEndOfShift4 = model.PlannedEndOfShift4; + planning.PlannedStartOfShift5 = model.PlannedStartOfShift5; + planning.PlannedBreakOfShift5 = model.PlannedBreakOfShift5; + planning.PlannedEndOfShift5 = model.PlannedEndOfShift5; planning.CommentOffice = model.CommentOffice; planning.NettoHoursOverride = model.NettoHoursOverride; planning.NettoHoursOverrideActive = model.NettoHoursOverrideActive; @@ -1125,6 +1134,22 @@ public async Task UpdateByCurrentUserNam( localizationService.GetString("PlanningNotFound")); } + planning.PlannedStartOfShift1 = model.PlannedStartOfShift1; + planning.PlannedBreakOfShift1 = model.PlannedBreakOfShift1; + planning.PlannedEndOfShift1 = model.PlannedEndOfShift1; + planning.PlannedStartOfShift2 = model.PlannedStartOfShift2; + planning.PlannedBreakOfShift2 = model.PlannedBreakOfShift2; + planning.PlannedEndOfShift2 = model.PlannedEndOfShift2; + planning.PlannedStartOfShift3 = model.PlannedStartOfShift3; + planning.PlannedBreakOfShift3 = model.PlannedBreakOfShift3; + planning.PlannedEndOfShift3 = model.PlannedEndOfShift3; + planning.PlannedStartOfShift4 = model.PlannedStartOfShift4; + planning.PlannedBreakOfShift4 = model.PlannedBreakOfShift4; + planning.PlannedEndOfShift4 = model.PlannedEndOfShift4; + planning.PlannedStartOfShift5 = model.PlannedStartOfShift5; + planning.PlannedBreakOfShift5 = model.PlannedBreakOfShift5; + planning.PlannedEndOfShift5 = model.PlannedEndOfShift5; + if (!assignedSite.UseDetailedPauseEditing) { planning.Pause1Id = model.Pause1Id ?? planning.Pause1Id; diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts new file mode 100644 index 00000000..e2586090 --- /dev/null +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts @@ -0,0 +1,129 @@ +import { test, expect, Page } from '@playwright/test'; +import { LoginPage } from '../../../Page objects/Login.page'; + +/** + * Regression guard for the multi-shift (3-5) save + render pipeline. + * + * Prior bug: the C# `Update()` method only copied shift 1-2 from the request + * model onto the PlanRegistration entity — shifts 3-5 were silently dropped. + * A round-trip that fills all 5 shifts in the workday-entity dialog and + * re-reads them from the table cell + dialog is the minimum guard against + * that regression ever coming back. + * + * Shift layout used by this test: + * Shift 1: 00:00-01:00 break 00:05 + * Shift 2: 02:00-03:00 break 00:10 + * Shift 3: 04:00-05:00 break 00:15 + * Shift 4: 06:00-07:00 break 00:20 + * Shift 5: 07:00-08:00 break 00:25 + */ + +async function waitForSpinner(page: Page) { + if (await page.locator('.overlay-spinner').count() > 0) { + await page.locator('.overlay-spinner').waitFor({ state: 'hidden', timeout: 30000 }); + } +} + +async function pickTime(page: Page, timeStr: string) { + const [h, m] = timeStr.split(':').map(s => parseInt(s, 10)); + const hourDeg = (360 / 12) * (h % 12); + const minuteDeg = (360 / 60) * m; + + // For hours ≥ 12 the Material time picker uses the inner ring (height 85px). + if (h >= 12) { + await page.locator(`[style="height: 85px; transform: rotateZ(${hourDeg}deg) translateX(-50%);"] > span`).click(); + } else if (hourDeg === 0) { + // "12" sits at 0deg on the outer ring + await page.locator(`[style="transform: rotateZ(0deg) translateX(-50%);"] > span`).click(); + } else { + await page.locator(`[style="transform: rotateZ(${hourDeg}deg) translateX(-50%);"] > span`).click(); + } + + if (minuteDeg > 0) { + await page.locator(`[style="transform: rotateZ(${minuteDeg}deg) translateX(-50%);"] > span`).click({ force: true }); + } + await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click(); +} + +async function setShift(page: Page, shiftId: 1|2|3|4|5, start: string, end: string, breakStr: string) { + await page.locator(`[data-testid="plannedStartOfShift${shiftId}"]`).click(); + await pickTime(page, start); + await expect(page.locator(`[data-testid="plannedStartOfShift${shiftId}"]`)).toHaveValue(start); + + await page.locator(`[data-testid="plannedEndOfShift${shiftId}"]`).click(); + await pickTime(page, end); + await expect(page.locator(`[data-testid="plannedEndOfShift${shiftId}"]`)).toHaveValue(end); + + await page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`).click(); + await pickTime(page, breakStr); + await expect(page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`)).toHaveValue(breakStr); +} + +const allFiveShifts = [ + { id: 1 as const, start: '00:00', end: '01:00', break: '00:05' }, + { id: 2 as const, start: '02:00', end: '03:00', break: '00:10' }, + { id: 3 as const, start: '04:00', end: '05:00', break: '00:15' }, + { id: 4 as const, start: '06:00', end: '07:00', break: '00:20' }, + { id: 5 as const, start: '07:00', end: '08:00', break: '00:25' }, +]; + +test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:4200'); + await new LoginPage(page).login(); + }); + + test('persists all 5 planned shifts through save + reload', async ({ page }) => { + await page.locator('mat-nested-tree-node').filter({ hasText: 'Timeregistrering' }).click(); + const indexPromise = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST'); + await page.locator('mat-tree-node').filter({ hasText: 'Dashboard' }).click(); + await indexPromise; + await waitForSpinner(page); + + // Pick any visible day cell — column 3 (worker index), first date in the range. + const cellId = '#cell3_0'; + await page.locator(cellId).scrollIntoViewIfNeeded(); + await page.locator(cellId).click(); + await expect(page.locator('#planHours')).toBeVisible(); + + // Fill all 5 shifts. + for (const s of allFiveShifts) { + await setShift(page, s.id, s.start, s.end, s.break); + } + + // Save. + const updatePromise = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/') && r.request().method() === 'PUT'); + const reindexPromise = page.waitForResponse(r => + r.url().includes('/api/time-planning-pn/plannings/index') && r.request().method() === 'POST'); + await page.locator('#saveButton').click(); + await updatePromise; + await reindexPromise; + await waitForSpinner(page); + await page.waitForTimeout(500); + + // Re-open the same cell and assert every shift round-tripped — + // this is the bit that failed before the fix: shifts 3-5 came back as 00:00. + await page.locator(cellId).scrollIntoViewIfNeeded(); + await page.locator(cellId).click(); + await expect(page.locator('#planHours')).toBeVisible(); + + for (const s of allFiveShifts) { + await expect( + page.locator(`[data-testid="plannedStartOfShift${s.id}"]`), + `shift ${s.id} start should round-trip` + ).toHaveValue(s.start); + await expect( + page.locator(`[data-testid="plannedEndOfShift${s.id}"]`), + `shift ${s.id} end should round-trip` + ).toHaveValue(s.end); + await expect( + page.locator(`[data-testid="plannedBreakOfShift${s.id}"]`), + `shift ${s.id} break should round-trip` + ).toHaveValue(s.break); + } + + await page.locator('#cancelButton').click(); + }); +}); From 06951dae795975a01d6fd9cc696ec4274200908f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 14 Apr 2026 10:38:14 +0200 Subject: [PATCH 3/8] test: stub GetCurrentUserAsync + use non-zero hours in multi-shift E2E - PlanningServiceMultiShiftTests: Update() dereferences currentUserAsync.Id; stub IUserService.GetCurrentUserAsync to return EformUser { Id = 1 } so the persistence assertion exercises the real save path instead of swallowing an NRE as "ErrorWhileUpdatingPlanning". - dashboard-edit-multishift.spec: simplify pickTime to match the existing helper pattern and shift all 5 slots away from 00:xx (Material timepicker's "12" sits at a non-rotateZ position that broke the click locator). Co-Authored-By: Claude Opus 4.6 --- .../PlanningServiceMultiShiftTests.cs | 2 ++ .../b/dashboard-edit-multishift.spec.ts | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs index 912f0192..c212c13e 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs @@ -6,6 +6,7 @@ using Microting.eForm.Infrastructure.Constants; using Microting.eFormApi.BasePn.Abstractions; using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; +using Microting.eFormApi.BasePn.Infrastructure.Models.Auth; using Microting.TimePlanningBase.Infrastructure.Data.Entities; using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; using NSubstitute; @@ -35,6 +36,7 @@ public async Task SetUpTest() _userService = Substitute.For(); _userService.UserId.Returns(1); + _userService.GetCurrentUserAsync().Returns(new EformUser { Id = 1 }); _localizationService = Substitute.For(); _localizationService.GetString(Arg.Any()).Returns(x => x[0]?.ToString()); diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts index e2586090..cdf4e546 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts @@ -26,15 +26,11 @@ async function waitForSpinner(page: Page) { async function pickTime(page: Page, timeStr: string) { const [h, m] = timeStr.split(':').map(s => parseInt(s, 10)); - const hourDeg = (360 / 12) * (h % 12); + const hourDeg = (360 / 12) * h; const minuteDeg = (360 / 60) * m; - // For hours ≥ 12 the Material time picker uses the inner ring (height 85px). - if (h >= 12) { + if (hourDeg > 360) { await page.locator(`[style="height: 85px; transform: rotateZ(${hourDeg}deg) translateX(-50%);"] > span`).click(); - } else if (hourDeg === 0) { - // "12" sits at 0deg on the outer ring - await page.locator(`[style="transform: rotateZ(0deg) translateX(-50%);"] > span`).click(); } else { await page.locator(`[style="transform: rotateZ(${hourDeg}deg) translateX(-50%);"] > span`).click(); } @@ -59,12 +55,14 @@ async function setShift(page: Page, shiftId: 1|2|3|4|5, start: string, end: stri await expect(page.locator(`[data-testid="plannedBreakOfShift${shiftId}"]`)).toHaveValue(breakStr); } +// Times chosen to avoid hour==0 (the Material timepicker's "12" selector +// sits at a non-rotateZ position that breaks the degree-math helper above). const allFiveShifts = [ - { id: 1 as const, start: '00:00', end: '01:00', break: '00:05' }, - { id: 2 as const, start: '02:00', end: '03:00', break: '00:10' }, - { id: 3 as const, start: '04:00', end: '05:00', break: '00:15' }, - { id: 4 as const, start: '06:00', end: '07:00', break: '00:20' }, - { id: 5 as const, start: '07:00', end: '08:00', break: '00:25' }, + { id: 1 as const, start: '01:00', end: '02:00', break: '00:05' }, + { id: 2 as const, start: '03:00', end: '04:00', break: '00:10' }, + { id: 3 as const, start: '05:00', end: '06:00', break: '00:15' }, + { id: 4 as const, start: '07:00', end: '08:00', break: '00:20' }, + { id: 5 as const, start: '09:00', end: '10:00', break: '00:25' }, ]; test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', () => { From 46261c72ef8b9e4a5c9e454bb8a3bc66c74e3bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 14 Apr 2026 15:39:02 +0200 Subject: [PATCH 4/8] fix(tests): correct EformUser namespace (Database.Entities, not Models.Auth) CI build failed with CS0246: EformUser lives in Microting.eFormApi.BasePn.Infrastructure.Database.Entities, not ...Infrastructure.Models.Auth. Verified with `dotnet build` locally: 0 errors. Co-Authored-By: Claude Opus 4.6 --- .../TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs index c212c13e..0c6caffe 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PlanningServiceMultiShiftTests.cs @@ -6,7 +6,7 @@ using Microting.eForm.Infrastructure.Constants; using Microting.eFormApi.BasePn.Abstractions; using Microting.eFormApi.BasePn.Infrastructure.Helpers.PluginDbOptions; -using Microting.eFormApi.BasePn.Infrastructure.Models.Auth; +using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.TimePlanningBase.Infrastructure.Data.Entities; using AssignedSiteEntity = Microting.TimePlanningBase.Infrastructure.Data.Entities.AssignedSite; using NSubstitute; From 937f9394c9cbfe214775165c33a47fd102586513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 14 Apr 2026 16:38:49 +0200 Subject: [PATCH 5/8] fix(playwright): position-based clock clicks in multishift spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rotateZ-selector approach breaks for hour=0 (the "12" position doesn't render with a stable `rotateZ(0deg)` computed style), so every break-time click (00:05/00:10/…) timed out. Switch to coordinate-based clicks on `.clock-face`, same strategy as time-planning-settings.spec.ts, which handles h=0 uniformly. Co-Authored-By: Claude Opus 4.6 --- .../b/dashboard-edit-multishift.spec.ts | 45 ++++++++++++++----- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts index cdf4e546..5cb54a9d 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts @@ -25,19 +25,40 @@ async function waitForSpinner(page: Page) { } async function pickTime(page: Page, timeStr: string) { - const [h, m] = timeStr.split(':').map(s => parseInt(s, 10)); - const hourDeg = (360 / 12) * h; - const minuteDeg = (360 / 60) * m; - - if (hourDeg > 360) { - await page.locator(`[style="height: 85px; transform: rotateZ(${hourDeg}deg) translateX(-50%);"] > span`).click(); - } else { - await page.locator(`[style="transform: rotateZ(${hourDeg}deg) translateX(-50%);"] > span`).click(); - } + // Position-based clock-face clicks (same approach as time-planning-settings.spec.ts). + // Works uniformly for h=0 (break times), unlike rotateZ-selector strategies. + const [hourStr, minuteStr] = timeStr.split(':'); + const h = parseInt(hourStr, 10); + const m = parseInt(minuteStr, 10); + + const cx = 145, cy = 145; + + const hourFace = page.locator('.clock-face'); + await hourFace.first().waitFor({ state: 'visible', timeout: 5000 }); + const hourAngle = (h % 12) * 30; + const hourR = (h === 0 || h > 12) ? 60 : 100; + const hourRad = hourAngle * Math.PI / 180; + await hourFace.first().click({ + position: { + x: Math.round(cx + hourR * Math.sin(hourRad)), + y: Math.round(cy - hourR * Math.cos(hourRad)) + (Math.abs(Math.cos(hourRad)) < 0.01 ? 1 : 0), + }, + }); - if (minuteDeg > 0) { - await page.locator(`[style="transform: rotateZ(${minuteDeg}deg) translateX(-50%);"] > span`).click({ force: true }); - } + await page.waitForTimeout(500); + const minuteFace = page.locator('.clock-face'); + await minuteFace.first().waitFor({ state: 'visible', timeout: 5000 }); + const minuteAngle = m * 6; + const minuteR = 100; + const minuteRad = minuteAngle * Math.PI / 180; + await minuteFace.first().click({ + position: { + x: Math.round(cx + minuteR * Math.sin(minuteRad)), + y: Math.round(cy - minuteR * Math.cos(minuteRad)) + (Math.abs(Math.cos(minuteRad)) < 0.01 ? 1 : 0), + }, + }); + + await page.waitForTimeout(500); await page.locator('.timepicker-button span').filter({ hasText: 'Ok' }).click(); } From ef84a80f70df1a8ac4f3222900ddcd646e1cf7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 14 Apr 2026 17:16:53 +0200 Subject: [PATCH 6/8] fix(playwright): enable shifts 3-5 on assigned site before multishift test The workday-entity dialog only renders shift rows 3-5 when thirdShiftActive/fourthShiftActive/fifthShiftActive are true on the assigned site. CI defaults them to false, so the test timed out waiting for [data-testid="plannedStartOfShift3"]. Open the assigned-site dialog via #firstColumn0 first and tick the three cascading checkboxes, then proceed with the day-cell edit. Co-Authored-By: Claude Opus 4.6 --- .../b/dashboard-edit-multishift.spec.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts index 5cb54a9d..c3db0fce 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts @@ -100,6 +100,30 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', () await indexPromise; await waitForSpinner(page); + // Shifts 3-5 are only rendered in the workday-entity dialog when the + // assigned site has thirdShiftActive / fourthShiftActive / fifthShiftActive + // flipped on (see workday-entity-dialog.component.ts:354-363). CI seed + // defaults them all to false, so we open the assigned-site dialog first + // and enable the three cascading checkboxes before editing the day. + await page.locator('#firstColumn0').click(); + await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 10000 }); + + for (const id of ['#thirdShiftActive', '#fourthShiftActive', '#fifthShiftActive']) { + const cb = page.locator(`${id} input[type="checkbox"]`); + await cb.waitFor({ state: 'attached', timeout: 10000 }); + if (!(await cb.isChecked())) { + await page.locator(id).click({ force: true }); + } + await expect(cb).toBeChecked(); + } + + const assignSitePromise = page.waitForResponse( + r => r.url().includes('/api/time-planning-pn/settings/assigned-site') && r.request().method() === 'PUT'); + await page.locator('#saveButton').click({ force: true }); + await assignSitePromise; + await waitForSpinner(page); + await expect(page.locator('mat-dialog-container')).toHaveCount(0, { timeout: 10000 }); + // Pick any visible day cell — column 3 (worker index), first date in the range. const cellId = '#cell3_0'; await page.locator(cellId).scrollIntoViewIfNeeded(); From 687a03edbe73ec1ef70f0339af806461034b4745 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 14 Apr 2026 17:52:50 +0200 Subject: [PATCH 7/8] fix(playwright): enable shifts 3-5 via three save cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assigned-site dialog gates the 4th checkbox on `data.thirdShiftActive` and the 5th on `data.fourthShiftActive` — both bound to the dialog input snapshot, not the live form control. So each flag only materialises after a save + reopen. Loop three times: open dialog, tick the next checkbox, save, close. Co-Authored-By: Claude Opus 4.6 --- .../b/dashboard-edit-multishift.spec.ts | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts index c3db0fce..b4d241a8 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts @@ -103,26 +103,28 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', () // Shifts 3-5 are only rendered in the workday-entity dialog when the // assigned site has thirdShiftActive / fourthShiftActive / fifthShiftActive // flipped on (see workday-entity-dialog.component.ts:354-363). CI seed - // defaults them all to false, so we open the assigned-site dialog first - // and enable the three cascading checkboxes before editing the day. - await page.locator('#firstColumn0').click(); - await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 10000 }); - - for (const id of ['#thirdShiftActive', '#fourthShiftActive', '#fifthShiftActive']) { - const cb = page.locator(`${id} input[type="checkbox"]`); + // defaults them all to false. The assigned-site dialog also gates the + // 4th/5th checkboxes behind `data.thirdShiftActive` / `data.fourthShiftActive` + // — those bindings reflect the snapshot passed into the dialog, so each + // new checkbox only materialises after a save + reopen cycle. + for (const id of ['thirdShiftActive', 'fourthShiftActive', 'fifthShiftActive']) { + await page.locator('#firstColumn0').click(); + await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 10000 }); + + const cb = page.locator(`#${id} input[type="checkbox"]`); await cb.waitFor({ state: 'attached', timeout: 10000 }); if (!(await cb.isChecked())) { - await page.locator(id).click({ force: true }); + await page.locator(`#${id}`).click({ force: true }); } await expect(cb).toBeChecked(); - } - const assignSitePromise = page.waitForResponse( - r => r.url().includes('/api/time-planning-pn/settings/assigned-site') && r.request().method() === 'PUT'); - await page.locator('#saveButton').click({ force: true }); - await assignSitePromise; - await waitForSpinner(page); - await expect(page.locator('mat-dialog-container')).toHaveCount(0, { timeout: 10000 }); + const assignSitePromise = page.waitForResponse( + r => r.url().includes('/api/time-planning-pn/settings/assigned-site') && r.request().method() === 'PUT'); + await page.locator('#saveButton').click({ force: true }); + await assignSitePromise; + await waitForSpinner(page); + await expect(page.locator('mat-dialog-container')).toHaveCount(0, { timeout: 10000 }); + } // Pick any visible day cell — column 3 (worker index), first date in the range. const cellId = '#cell3_0'; From 3786235f539eea4123394755776ad9f6ac59ce4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 14 Apr 2026 18:25:55 +0200 Subject: [PATCH 8/8] fix(playwright): enable shifts on the same worker we then edit The prelude was ticking thirdShiftActive on row 0 (#firstColumn0) but the test edits row 3 (#cell3_0), so the workday dialog still opened with the default 2-shift assigned site and timed out waiting for plannedStartOfShift3. Align the assigned-site dialog target with the day-cell target. Co-Authored-By: Claude Opus 4.6 --- .../time-planning-pn/b/dashboard-edit-multishift.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts index b4d241a8..b9c31f06 100644 --- a/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts +++ b/eform-client/playwright/e2e/plugins/time-planning-pn/b/dashboard-edit-multishift.spec.ts @@ -108,7 +108,7 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', () // — those bindings reflect the snapshot passed into the dialog, so each // new checkbox only materialises after a save + reopen cycle. for (const id of ['thirdShiftActive', 'fourthShiftActive', 'fifthShiftActive']) { - await page.locator('#firstColumn0').click(); + await page.locator('#firstColumn3').click(); await expect(page.locator('mat-dialog-container')).toBeVisible({ timeout: 10000 }); const cb = page.locator(`#${id} input[type="checkbox"]`); @@ -126,7 +126,8 @@ test.describe('Dashboard — multi-shift (3-5) round-trip regression guard', () await expect(page.locator('mat-dialog-container')).toHaveCount(0, { timeout: 10000 }); } - // Pick any visible day cell — column 3 (worker index), first date in the range. + // Day cell id is `cell{rowIndex}_{colField}` — row 3 matches the worker + // whose assigned-site row (#firstColumn3) we just configured above. const cellId = '#cell3_0'; await page.locator(cellId).scrollIntoViewIfNeeded(); await page.locator(cellId).click();