From 3e8e46d5a8e3fa5a898f5d6e14558f89d3f4a021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 3 Sep 2026 08:10:00 +0200 Subject: [PATCH 1/3] perf(test): cut TimePlanning.Pn.Test fixture setup cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestBaseSetup's [SetUp] paid ~72s/test in fixture provisioning regardless of what the test needed, and CI ran redundant, unused MariaDB/RabbitMQ containers. Three fixes: - 420_SDK.sql: drop the frozen __EFMigrationsHistory DROP/CREATE/LOCK/ INSERT/UNLOCK block (mid-2024 snapshot) so EnsureCreated()/Migrate() own migration history instead of it being reset before every test. - TestBaseSetup.cs: make SDK database provisioning (EnsureCreated + SQL dump + Migrate, ~50-60s) lazy — only classes that call GetCore() pay for it, and only on first use, preserving the GetContext() -> StartSqlOnly() ordering. Also fixes a per-test leaked DbContext/MySQL connection by disposing it in OneTimeTearDown. - dotnet-core-pr.yml / dotnet-core-master.yml: remove the unused "Start MariaDB", "Start rabbitmq", and "Sleep 15" steps from the test-dotnet job only (TestBaseSetup uses its own ephemeral Testcontainers.MariaDb; nothing in the test project touches RabbitMQ). pn-playwright-test is untouched — it still needs its own mariadbtest/my-rabbit containers. Measured: BreakPolicyControllerTests.Create_WithNestedRules_ReturnsSuccess (a class that never calls GetCore()) dropped from a documented ~72s/test baseline to 42s reported / 44.9s total dotnet-test wall time. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TgEyDcnAEBcCF63RX2vm1k --- .github/workflows/dotnet-core-master.yml | 10 ---- .github/workflows/dotnet-core-pr.yml | 10 ---- .../TimePlanning.Pn.Test/SQL/420_SDK.sql | 23 --------- .../TimePlanning.Pn.Test/TestBaseSetup.cs | 48 ++++++++++++------- 4 files changed, 32 insertions(+), 59 deletions(-) diff --git a/.github/workflows/dotnet-core-master.yml b/.github/workflows/dotnet-core-master.yml index eb04e03af..7e1fcc27c 100644 --- a/.github/workflows/dotnet-core-master.yml +++ b/.github/workflows/dotnet-core-master.yml @@ -268,16 +268,6 @@ jobs: - uses: actions/checkout@v3 - name: Create docker network run: docker network create --driver bridge --attachable data - - name: Start MariaDB - run: | - docker pull mariadb:10.8 - docker run --name mariadbtest --network data -e MYSQL_ROOT_PASSWORD=secretpassword -p 3306:3306 -d mariadb:10.8 - - name: Start rabbitmq - run: | - docker pull rabbitmq:latest - docker run -d --hostname my-rabbit --name some-rabbit --network data -e RABBITMQ_DEFAULT_USER=admin -e RABBITMQ_DEFAULT_PASS=password -p 5672:5672 rabbitmq:latest - - name: Sleep 15 - run: sleep 15 - name: Setup .NET Core uses: actions/setup-dotnet@v3 with: diff --git a/.github/workflows/dotnet-core-pr.yml b/.github/workflows/dotnet-core-pr.yml index 37a5c069f..814b458ab 100644 --- a/.github/workflows/dotnet-core-pr.yml +++ b/.github/workflows/dotnet-core-pr.yml @@ -257,16 +257,6 @@ jobs: - uses: actions/checkout@v3 - name: Create docker network run: docker network create --driver bridge --attachable data - - name: Start MariaDB - run: | - docker pull mariadb:10.8 - docker run --name mariadbtest --network data -e MYSQL_ROOT_PASSWORD=secretpassword -p 3306:3306 -d mariadb:10.8 - - name: Start rabbitmq - run: | - docker pull rabbitmq:latest - docker run -d --hostname my-rabbit --name some-rabbit --network data -e RABBITMQ_DEFAULT_USER=admin -e RABBITMQ_DEFAULT_PASS=password -p 5672:5672 rabbitmq:latest - - name: Sleep 15 - run: sleep 15 - name: Setup .NET Core uses: actions/setup-dotnet@v3 with: diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_SDK.sql b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_SDK.sql index b214da379..a7973ab18 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_SDK.sql +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_SDK.sql @@ -2742,29 +2742,6 @@ INSERT INTO `Workers` VALUES (1,'2022-09-21 06:00:56.725618','2023-05-11 11:55:3 /*!40000 ALTER TABLE `Workers` ENABLE KEYS */; UNLOCK TABLES; --- --- Table structure for table `__EFMigrationsHistory` --- - -DROP TABLE IF EXISTS `__EFMigrationsHistory`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `__EFMigrationsHistory` ( - `MigrationId` varchar(150) NOT NULL, - `ProductVersion` varchar(32) NOT NULL, - PRIMARY KEY (`MigrationId`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `__EFMigrationsHistory` --- - -LOCK TABLES `__EFMigrationsHistory` WRITE; -/*!40000 ALTER TABLE `__EFMigrationsHistory` DISABLE KEYS */; -INSERT INTO `__EFMigrationsHistory` VALUES ('20180810124416_InitialCreate','6.0.8'),('20190116110009_AddingOriginalId','6.0.8'),('20190315092242_AddingModelseForInsight','6.0.8'),('20190318122928_FixingNamingOfSurveyConfigurationSites','6.0.8'),('20190319130214_AddingMissingForeignKeys','6.0.8'),('20190408081151_AddingFolders','6.0.8'),('20190408084408_AddingMissingParentId','6.0.8'),('20190509074123_RefactoringidtoId','6.0.8'),('20190514053645_RefactoringAttributeNames','6.0.8'),('20190515064952_FixingNamingForFieldValues','6.0.8'),('20190531092007_AddingMissingAIonLogs','6.0.8'),('20190711053344_AddingJasperDocxEnabledAttributesToCheckList','6.0.8'),('20190828054730_AddingNewVersionClasses','6.0.8'),('20190828074017_AddingMissingClasses','6.0.8'),('20190923100451_ChangeStringToInt','6.0.8'),('20190924172326_AddingNewIndexOnCases','6.0.8'),('20200116074236_AddingSiteTaggins','6.0.8'),('20200120093951_CleanupInSight','6.0.8'),('20200120164857_AddingTranslationsToInSight','6.0.8'),('20200120171433_AddingMicrotingUidToInSight','6.0.8'),('20200122103229_ChangingValueToBeStringForAnswerValue','6.0.8'),('20200222140656_AddinDisplayIndexToOptions','6.0.8'),('20200224084023_AddingAttributesToUnits','6.0.8'),('20200224092512_AddingMoreAttributesToUnits','6.0.8'),('20200226182616_MakingNextQuestionIdNullable','6.0.8'),('20200318150742_MakingUnitIdNullableForAnswers','6.0.8'),('20200427095029_AdjustTimeToUTC','6.0.8'),('20200513142551_AddingFolderIdToCasesAndCheckListSites','6.0.8'),('20200617160004_ChangingOptionsIndexToOptionIndex','6.0.8'),('20200620171527_AddingExcelExportEnabledToCheckList','6.0.8'),('20200701101500_LettingSurveyConfigurationIdBeNullable','6.0.8'),('20201116164405_AddingDescriptionToEntityGroup','6.0.8'),('20201130204234_FixingSplitScreen','6.0.8'),('20201220194822_FixingTableColumnNames','6.0.8'),('20201220201427_FixingQuestionSet','6.0.8'),('20201222125152_HugheTableRenaming','6.0.8'),('20201223104631_AddingTranslations','6.0.8'),('20201225165255_FixingBrokenTableNames','6.0.8'),('20201231062732_ChangingDescriptToLanguageCode','6.0.8'),('20210405153325_AddingExtraFieldValues','6.0.8'),('20210407134630_AddingFolderTranslations','6.0.8'),('20210609072417_AddingLinkingOfSitesAndEntities','6.0.8'),('20210730085329_AddingDefaultValueToFieldTranslations','6.0.8'),('20211014105943_CLAttributes','6.0.8'),('20211108111024_AddingIsArchivedToCases','6.0.8'),('20211116085744_AddingDoneAtEditable','6.0.8'),('20220207094729_AddingIsLockedToSiteUnitWorkers','6.0.8'),('20221016081344_AddingIsActiveToLanguage','7.0.0'),('20221129082337_AddingReceivedByServerAtToCases','7.0.0'),('20230506062507_AddingInitialsToWorkers','7.0.5'),('20230607084834_AddingOriginalFileLocationToUploadedData','8.0.6'),('20240619132520_AddPinCodeEmployeeNoToWorker','8.0.6'); -/*!40000 ALTER TABLE `__EFMigrationsHistory` ENABLE KEYS */; -UNLOCK TABLES; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs index 2e47df43e..1026015b9 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs @@ -122,8 +122,36 @@ protected TimePlanningPnDbContext CreateTimePlanningPnDbContext() return new TimePlanningPnDbContext(optionsBuilder.Options); } + /// + /// Provisions the SDK database (420_SDK) the first time it is needed and + /// memoizes the result for the lifetime of this fixture instance. This is + /// expensive (SQL dump load + EF migrations, tens of seconds) and is only + /// required by tests that call ; classes that never + /// call it must not pay this cost in every [SetUp]. + /// + private async Task EnsureSdkDbProvisionedAsync() + { + if (MicrotingDbContext != null) + { + return; + } + + if (_mariadbTestcontainer.State == TestcontainersStates.Undefined) + { + await _mariadbTestcontainer.StartAsync(); + } + + var dbContext = GetContext(_mariadbTestcontainer.GetConnectionString()); + dbContext.Database.SetCommandTimeout(300); + MicrotingDbContext = dbContext; + } + protected async Task GetCore() { + // Core.StartSqlOnly only connects and validates settings - it does not + // migrate - so the SDK database must already be provisioned before it runs. + await EnsureSdkDbProvisionedAsync(); + var core = new Core(); await core.StartSqlOnly(_mariadbTestcontainer.GetConnectionString().Replace("myDb", "420_SDK") .Replace("bla", "root")); @@ -138,29 +166,17 @@ public async Task Setup() await _mariadbTestcontainer.StartAsync(); } - // ConnectionString = _mariadbTestcontainer.GetConnectionString(); - - var DbContext = GetContext(_mariadbTestcontainer.GetConnectionString()); - - DbContext!.Database.SetCommandTimeout(300); - // Console.WriteLine($"{DateTime.Now} : Starting MariaDb Container..."); - // await _mariadbTestcontainer.StartAsync(); - // Console.WriteLine($"{DateTime.Now} : Started MariaDb Container"); - // TimePlanningPnDbContext = GetTimePlanningPnDbContext(_mariadbTestcontainer.GetConnectionString()); - // - // TimePlanningPnDbContext!.Database.SetCommandTimeout(300); - // - // MicrotingDbContext = GetContext(_mariadbTestcontainer.GetConnectionString()); - // - // MicrotingDbContext!.Database.SetCommandTimeout(300); - } [OneTimeTearDown] public async Task OneTimeTearDown() { Console.WriteLine($"{DateTime.Now} : Stopping MariaDb Container..."); + if (MicrotingDbContext != null) + { + await MicrotingDbContext.DisposeAsync(); + } await _mariadbTestcontainer.StopAsync(); await _mariadbTestcontainer.DisposeAsync(); Console.WriteLine($"{DateTime.Now} : Stopped MariaDb Container"); From 81f82c7ddfe7a5532b87d62744737c5524556236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 3 Sep 2026 08:29:28 +0200 Subject: [PATCH 2/3] fix(test): restore __EFMigrationsHistory dump block; split Migrate() from per-test data reset Review round 1 caught two correctness regressions in the prior commit (3e8e46d5): 1. Removing the __EFMigrationsHistory DROP/CREATE/LOCK/INSERT/UNLOCK block from 420_SDK.sql broke Migrate() for every GetCore()-calling test class. That block is the ONLY thing that populates __EFMigrationsHistory: EnsureCreated() never creates it, and ExecuteSqlRaw(dump) drops/recreates every real table at the frozen mid-2024 schema. Without the history block, Migrate() sees an empty history table, concludes zero migrations have been applied, and replays every migration since 2018 against tables the dump already created -> "table already exists", a hard failure. DO NOT remove this block again as a "dead weight" optimization -- it is load-bearing. The e2e fixture copy at eform-client/playwright/e2e/plugins/time-planning-pn/a/420_SDK.sql keeps the same block for the same reason. Restored the block verbatim (frozen at the same 20240619132520_AddPinCodeEmployeeNoToWorker migration point -- that's fine, EnsureSdkDbProvisionedAsync's first-time Migrate() brings the schema current from there). 2. Under 3e8e46d5's lazy provisioning, SDK data was never reset between tests within the same fixture instance -- only Migrate() was skipped after the first test, but so was the data-resetting dump replay. Demonstrated concretely against MobileFlexRecomputeAndCascadeTests, which seeds Site/Worker/SiteWorker rows with fixed MicrotingUids in [SetUp]: with no per-test reset, its second test would insert duplicates (__EFMigrationsHistory.MicrotingUid has no unique constraint, PK only) -- a landmine other multi-test GetCore() classes were avoiding only by luck of disjoint ID ranges, not by guarantee. Split EnsureSdkDbProvisionedAsync so Migrate() (~44-48s, the expensive part) still runs exactly once per fixture, but every call after the first now replays the SQL dump (~7s) against the already-migrated schema to reset SDK *data* without re-running Migrate(). Per-test cost for GetCore() classes: ~7s dump replay + ~21s plugin migrate, versus the original ~72s/test and versus zero isolation in 3e8e46d5. Verified by execution: a single test in MobileFlexRecomputeAndCascadeTests (which calls GetCore() in [SetUp]) now passes in 2m16s -- proof Migrate() against the restored history block no longer fails. See fix-round-1 section of stage0-report.md for the full verification writeup, including what was confirmed by execution versus by inspection. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TgEyDcnAEBcCF63RX2vm1k --- .../TimePlanning.Pn.Test/SQL/420_SDK.sql | 23 +++++++++++++++ .../TimePlanning.Pn.Test/TestBaseSetup.cs | 28 +++++++++++-------- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_SDK.sql b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_SDK.sql index a7973ab18..b214da379 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_SDK.sql +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SQL/420_SDK.sql @@ -2742,6 +2742,29 @@ INSERT INTO `Workers` VALUES (1,'2022-09-21 06:00:56.725618','2023-05-11 11:55:3 /*!40000 ALTER TABLE `Workers` ENABLE KEYS */; UNLOCK TABLES; +-- +-- Table structure for table `__EFMigrationsHistory` +-- + +DROP TABLE IF EXISTS `__EFMigrationsHistory`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!40101 SET character_set_client = utf8 */; +CREATE TABLE `__EFMigrationsHistory` ( + `MigrationId` varchar(150) NOT NULL, + `ProductVersion` varchar(32) NOT NULL, + PRIMARY KEY (`MigrationId`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `__EFMigrationsHistory` +-- + +LOCK TABLES `__EFMigrationsHistory` WRITE; +/*!40000 ALTER TABLE `__EFMigrationsHistory` DISABLE KEYS */; +INSERT INTO `__EFMigrationsHistory` VALUES ('20180810124416_InitialCreate','6.0.8'),('20190116110009_AddingOriginalId','6.0.8'),('20190315092242_AddingModelseForInsight','6.0.8'),('20190318122928_FixingNamingOfSurveyConfigurationSites','6.0.8'),('20190319130214_AddingMissingForeignKeys','6.0.8'),('20190408081151_AddingFolders','6.0.8'),('20190408084408_AddingMissingParentId','6.0.8'),('20190509074123_RefactoringidtoId','6.0.8'),('20190514053645_RefactoringAttributeNames','6.0.8'),('20190515064952_FixingNamingForFieldValues','6.0.8'),('20190531092007_AddingMissingAIonLogs','6.0.8'),('20190711053344_AddingJasperDocxEnabledAttributesToCheckList','6.0.8'),('20190828054730_AddingNewVersionClasses','6.0.8'),('20190828074017_AddingMissingClasses','6.0.8'),('20190923100451_ChangeStringToInt','6.0.8'),('20190924172326_AddingNewIndexOnCases','6.0.8'),('20200116074236_AddingSiteTaggins','6.0.8'),('20200120093951_CleanupInSight','6.0.8'),('20200120164857_AddingTranslationsToInSight','6.0.8'),('20200120171433_AddingMicrotingUidToInSight','6.0.8'),('20200122103229_ChangingValueToBeStringForAnswerValue','6.0.8'),('20200222140656_AddinDisplayIndexToOptions','6.0.8'),('20200224084023_AddingAttributesToUnits','6.0.8'),('20200224092512_AddingMoreAttributesToUnits','6.0.8'),('20200226182616_MakingNextQuestionIdNullable','6.0.8'),('20200318150742_MakingUnitIdNullableForAnswers','6.0.8'),('20200427095029_AdjustTimeToUTC','6.0.8'),('20200513142551_AddingFolderIdToCasesAndCheckListSites','6.0.8'),('20200617160004_ChangingOptionsIndexToOptionIndex','6.0.8'),('20200620171527_AddingExcelExportEnabledToCheckList','6.0.8'),('20200701101500_LettingSurveyConfigurationIdBeNullable','6.0.8'),('20201116164405_AddingDescriptionToEntityGroup','6.0.8'),('20201130204234_FixingSplitScreen','6.0.8'),('20201220194822_FixingTableColumnNames','6.0.8'),('20201220201427_FixingQuestionSet','6.0.8'),('20201222125152_HugheTableRenaming','6.0.8'),('20201223104631_AddingTranslations','6.0.8'),('20201225165255_FixingBrokenTableNames','6.0.8'),('20201231062732_ChangingDescriptToLanguageCode','6.0.8'),('20210405153325_AddingExtraFieldValues','6.0.8'),('20210407134630_AddingFolderTranslations','6.0.8'),('20210609072417_AddingLinkingOfSitesAndEntities','6.0.8'),('20210730085329_AddingDefaultValueToFieldTranslations','6.0.8'),('20211014105943_CLAttributes','6.0.8'),('20211108111024_AddingIsArchivedToCases','6.0.8'),('20211116085744_AddingDoneAtEditable','6.0.8'),('20220207094729_AddingIsLockedToSiteUnitWorkers','6.0.8'),('20221016081344_AddingIsActiveToLanguage','7.0.0'),('20221129082337_AddingReceivedByServerAtToCases','7.0.0'),('20230506062507_AddingInitialsToWorkers','7.0.5'),('20230607084834_AddingOriginalFileLocationToUploadedData','8.0.6'),('20240619132520_AddPinCodeEmployeeNoToWorker','8.0.6'); +/*!40000 ALTER TABLE `__EFMigrationsHistory` ENABLE KEYS */; +UNLOCK TABLES; /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs index 1026015b9..557ed0dd8 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs @@ -124,26 +124,32 @@ protected TimePlanningPnDbContext CreateTimePlanningPnDbContext() /// /// Provisions the SDK database (420_SDK) the first time it is needed and - /// memoizes the result for the lifetime of this fixture instance. This is - /// expensive (SQL dump load + EF migrations, tens of seconds) and is only - /// required by tests that call ; classes that never - /// call it must not pay this cost in every [SetUp]. + /// memoizes the schema/connection for the lifetime of this fixture + /// instance — (~44-48s) + /// is the expensive part and only needs to run ONCE per fixture, not once + /// per test. On every call after the first, the SDK *data* is reset by + /// replaying the SQL dump (~7s) without re-running Migrate(), so every + /// test that calls still gets an isolated SDK + /// database — classes that never call GetCore() pay neither cost. /// private async Task EnsureSdkDbProvisionedAsync() { - if (MicrotingDbContext != null) + if (_mariadbTestcontainer.State == TestcontainersStates.Undefined) { - return; + await _mariadbTestcontainer.StartAsync(); } - if (_mariadbTestcontainer.State == TestcontainersStates.Undefined) + if (MicrotingDbContext == null) { - await _mariadbTestcontainer.StartAsync(); + var dbContext = GetContext(_mariadbTestcontainer.GetConnectionString()); + dbContext.Database.SetCommandTimeout(300); + MicrotingDbContext = dbContext; + return; } - var dbContext = GetContext(_mariadbTestcontainer.GetConnectionString()); - dbContext.Database.SetCommandTimeout(300); - MicrotingDbContext = dbContext; + var file = Path.Combine("SQL", "420_SDK.sql"); + var rawSql = await File.ReadAllTextAsync(file); + await MicrotingDbContext.Database.ExecuteSqlRawAsync(rawSql); } protected async Task GetCore() From e3966d11c42c4b3b6131714fd274fe6019bee8ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Thu, 3 Sep 2026 08:54:03 +0200 Subject: [PATCH 3/3] fix(test): move SDK data reset out of GetCore() and into [SetUp] Review round 2 (PR #1702 CI: 3 dotnet shards failed, e/g/h) caught a bug in 81f82c7d's per-test SDK data reset: EnsureSdkDbProvisionedAsync replayed the SQL dump on every GetCore() call after the first, not once per test. A test that calls GetCore() more than once (e.g. to build a second Core after seeding SDK data via the first) had its own writes wiped out by its second call, before ever reading them back. Failure shape matched exactly: WorkingHoursExcelExportTagsColumnTests and sibling Excel-export tests build up SDK-backed export data across multiple GetCore() calls and assert on generated sheet content -- the export ran against a database that had just been reset out from under it, so sheets came back with only header rows. MobileFlexRecomputeAndCascadeTests passed throughout because both of its GetCore() calls happen in [SetUp] before any data it needs exists, so the bug was invisible to it -- not evidence the mechanism was safe. Fix: split provisioning from data reset cleanly. - EnsureSdkDbProvisionedAsync (called from GetCore()) now ONLY provisions once per fixture (EnsureCreated + dump + Migrate on the first call) and never resets data again -- repeated GetCore() calls within a test are now harmless, restoring the invariant the pre-Stage-0 code had for free. - New ResetSdkDbDataAsync replays the dump (~7s) without re-running Migrate() (~44-48s, still once-per-fixture). - [SetUp] now calls ResetSdkDbDataAsync once per test, but only when MicrotingDbContext != null -- i.e. only when an earlier test in this fixture already triggered provisioning. The first test's own EnsureSdkDbProvisionedAsync call already leaves SDK data freshly loaded from the dump, so [SetUp] skips a redundant reset before provisioning has happened at all. Verified by execution: WorkingHoursExcelExportTagsColumnTests .AllWorkersExport_TotalAndPerSiteSheets_TagsColumnAfterNameColumn (one of the tests CI reported failing on this exact bug) now passes in 2m16s, run alone, single method, foreground, with a hard timeout. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TgEyDcnAEBcCF63RX2vm1k --- .../TimePlanning.Pn.Test/TestBaseSetup.cs | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs index 557ed0dd8..022e92881 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs @@ -127,10 +127,12 @@ protected TimePlanningPnDbContext CreateTimePlanningPnDbContext() /// memoizes the schema/connection for the lifetime of this fixture /// instance — (~44-48s) /// is the expensive part and only needs to run ONCE per fixture, not once - /// per test. On every call after the first, the SDK *data* is reset by - /// replaying the SQL dump (~7s) without re-running Migrate(), so every - /// test that calls still gets an isolated SDK - /// database — classes that never call GetCore() pay neither cost. + /// per test. This method ONLY provisions; it never resets SDK data, so + /// calling more than once within a single test is + /// harmless. Per-test data isolation is handled separately, in + /// (see ResetSdkDbDataAsync below) — resetting here, + /// on every call, wiped out data a test had just written via an earlier + /// GetCore() call in the same test (Stage 0 review round 2). /// private async Task EnsureSdkDbProvisionedAsync() { @@ -144,18 +146,31 @@ private async Task EnsureSdkDbProvisionedAsync() var dbContext = GetContext(_mariadbTestcontainer.GetConnectionString()); dbContext.Database.SetCommandTimeout(300); MicrotingDbContext = dbContext; - return; } + } + /// + /// Resets SDK *data* to the dump's known-good snapshot (~7s) by + /// replaying the SQL dump against the already-migrated schema, WITHOUT + /// re-running Migrate() (~44-48s, kept once-per-fixture). Called once per + /// test from — not from — so a + /// test that calls GetCore() more than once doesn't have its own SDK + /// writes wiped out mid-test, while every *new* test still starts from + /// clean SDK data. + /// + private async Task ResetSdkDbDataAsync() + { var file = Path.Combine("SQL", "420_SDK.sql"); var rawSql = await File.ReadAllTextAsync(file); - await MicrotingDbContext.Database.ExecuteSqlRawAsync(rawSql); + await MicrotingDbContext!.Database.ExecuteSqlRawAsync(rawSql); } protected async Task GetCore() { // Core.StartSqlOnly only connects and validates settings - it does not // migrate - so the SDK database must already be provisioned before it runs. + // This only provisions (once per fixture) and never resets data -- see + // EnsureSdkDbProvisionedAsync's doc comment. await EnsureSdkDbProvisionedAsync(); var core = new Core(); @@ -172,6 +187,17 @@ public async Task Setup() await _mariadbTestcontainer.StartAsync(); } + // Reset SDK data once per test -- but only if an earlier test in this + // fixture already provisioned it (MicrotingDbContext != null). On the + // first test of a fixture, provisioning (triggered lazily by that + // test's own GetCore() call, if it makes one) already leaves the SDK + // database freshly loaded from the dump, so resetting here too would + // just be a redundant ~7s replay. + if (MicrotingDbContext != null) + { + await ResetSdkDbDataAsync(); + } + TimePlanningPnDbContext = GetTimePlanningPnDbContext(_mariadbTestcontainer.GetConnectionString()); }