diff --git a/IntegratoR.SampleFunction/Domain/DTOs/SmokeTest/LedgerJournalBatchSmokeTestRequest.cs b/IntegratoR.SampleFunction/Domain/DTOs/SmokeTest/LedgerJournalBatchSmokeTestRequest.cs new file mode 100644 index 0000000..db1d904 --- /dev/null +++ b/IntegratoR.SampleFunction/Domain/DTOs/SmokeTest/LedgerJournalBatchSmokeTestRequest.cs @@ -0,0 +1,30 @@ +namespace IntegratoR.SampleFunction.Domain.DTOs.SmokeTest; + +/// +/// Inputs for the LedgerJournalBatchSmokeTest HTTP trigger, which exercises the configurable, +/// chunked $batch write path (v3.0.0) against a live D365 F&O sandbox: chunked atomic +/// create, atomic-changeset rollback, continue-on-error partial accept, and batch delete. +/// +/// The D365 legal entity (DataAreaId) to create the journal in. +/// The journal name setup (e.g. "GenJrn"). +/// Debit account, must exist in the sandbox COA. +/// Credit account, must exist in the sandbox COA. +/// Amount posted on each line. +/// ISO currency code (e.g. "USD", "EUR"). +/// +/// How many lines to batch-create (default 6). Must be at least 2 so the update tests have +/// distinct lines to target. +/// +/// +/// The per-chunk operation cap for the create step (default 2), set deliberately small so the +/// create splits across several $batch changesets and the chunking path is exercised. +/// +public sealed record LedgerJournalBatchSmokeTestRequest( + string Company, + string JournalName, + string AccountDisplayValue, + string OffsetAccountDisplayValue, + decimal Amount, + string CurrencyCode, + int LineCount = 6, + int ChunkSize = 2); diff --git a/IntegratoR.SampleFunction/Domain/DTOs/SmokeTest/LedgerJournalBatchSmokeTestResponse.cs b/IntegratoR.SampleFunction/Domain/DTOs/SmokeTest/LedgerJournalBatchSmokeTestResponse.cs new file mode 100644 index 0000000..fa20100 --- /dev/null +++ b/IntegratoR.SampleFunction/Domain/DTOs/SmokeTest/LedgerJournalBatchSmokeTestResponse.cs @@ -0,0 +1,12 @@ +namespace IntegratoR.SampleFunction.Domain.DTOs.SmokeTest; + +/// +/// Result of the LedgerJournalBatchSmokeTest HTTP trigger. is true +/// only when every step succeeded; carries the per-step outcome so a caller +/// can see exactly which batch phase (chunked create, atomic rollback, continue-on-error partial, +/// batch delete) passed or failed. +/// +public sealed record LedgerJournalBatchSmokeTestResponse( + bool Success, + string? CreatedJournalBatchNumber, + IReadOnlyList Steps); diff --git a/IntegratoR.SampleFunction/Endpoints/LedgerJournalBatchSmokeTestTrigger.cs b/IntegratoR.SampleFunction/Endpoints/LedgerJournalBatchSmokeTestTrigger.cs new file mode 100644 index 0000000..d9124fd --- /dev/null +++ b/IntegratoR.SampleFunction/Endpoints/LedgerJournalBatchSmokeTestTrigger.cs @@ -0,0 +1,442 @@ +using System.Net; +using System.Text.Json; +using FluentResults; +using IntegratoR.Abstractions.Common.Batch; +using IntegratoR.Abstractions.Common.CQRS.Commands; +using IntegratoR.Abstractions.Common.CQRS.Queries; +using IntegratoR.Abstractions.Common.Results; +using IntegratoR.OData.FO.Domain.Entities.LedgerJournal; +using IntegratoR.OData.FO.Domain.Enums.LedgerJournals; +using IntegratoR.SampleFunction.Domain.DTOs.SmokeTest; +using MediatR; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace IntegratoR.SampleFunction.Endpoints; + +/// +/// End-to-end smoke test for the configurable, chunked $batch write path (v3.0.0) against a +/// live D365 F&O sandbox. Under one journal header it exercises, via MediatR batch commands: +/// (1) a chunked atomic batch-create split across several changesets; (2) an atomic-changeset +/// rollback (one good op + one bogus-key op in a single +/// changeset — the good op must NOT persist); (3) a +/// partial accept (good op persists, bogus op collected in the failure list); (4) a batch delete. +/// +/// +/// POST a and read the per-step +/// . Failures are deterministic: bogus operations +/// target a non-existent LineNumber (guaranteed 404), so the test does not depend on D365 +/// account validation. The happy-path delete steps are the authoritative cleanup; early-return +/// branches best-effort delete lines then header so the sandbox is not left with orphans. Depends on +/// the composite-key WRITE bypass in ODataClientAdapter and the hand-rolled $batch +/// transport (ADR-0004). +/// +public sealed class LedgerJournalBatchSmokeTestTrigger +{ + private readonly IMediator _mediator; + private readonly ILogger _logger; + + public LedgerJournalBatchSmokeTestTrigger(IMediator mediator, ILogger logger) + { + _mediator = mediator; + _logger = logger; + } + + [Function("LedgerJournalBatchSmokeTest_HTTPTrigger")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Function, "post", Route = "smoke/ledger-journal-batch")] HttpRequestData req, + CancellationToken cancellationToken) + { + LedgerJournalBatchSmokeTestRequest? input; + try + { + input = await req.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Batch smoke request body was not valid JSON."); + return await WriteResponse(req, HttpStatusCode.BadRequest, + Fail("ParseRequest", "SmokeTest.InvalidJson", ErrorType.Validation, "Request body is not valid JSON."), + cancellationToken).ConfigureAwait(false); + } + + if (input is null || string.IsNullOrWhiteSpace(input.Company) || string.IsNullOrWhiteSpace(input.JournalName)) + { + return await WriteResponse(req, HttpStatusCode.BadRequest, + Fail("ParseRequest", "SmokeTest.MissingFields", ErrorType.Validation, "Company and JournalName are required."), + cancellationToken).ConfigureAwait(false); + } + + if (input.LineCount < 2 || input.ChunkSize < 1 || input.ChunkSize > 200) + { + return await WriteResponse(req, HttpStatusCode.BadRequest, + Fail("ParseRequest", "SmokeTest.InvalidRange", ErrorType.Validation, "LineCount must be >= 2 and ChunkSize between 1 and 200."), + cancellationToken).ConfigureAwait(false); + } + + var steps = new List(); + _logger.LogInformation( + "LedgerJournal BATCH smoke test starting for company {Company} journal {JournalName} (lines {LineCount}, chunk {ChunkSize}).", + input.Company, input.JournalName, input.LineCount, input.ChunkSize); + + // ----------------------------------------------------------------------------------------- + // 1. Create the parent header (single command — batch lines need a header to hang off). + // ----------------------------------------------------------------------------------------- + var header = new LedgerJournalHeader + { + DataAreaId = input.Company, + JournalName = input.JournalName, + Description = $"SMOKEBATCH-{DateTime.UtcNow:yyyyMMddHHmmss}" + }; + + var createHeaderResult = await _mediator + .Send(new CreateCommand(header), cancellationToken) + .ConfigureAwait(false); + steps.Add(BuildStep("CreateHeader", createHeaderResult, onSuccess: r => $"JournalBatchNumber={r.JournalBatchNumber}")); + if (createHeaderResult.IsFailed) + { + return await WriteResponse(req, HttpStatusCode.OK, + new LedgerJournalBatchSmokeTestResponse(false, null, steps), cancellationToken).ConfigureAwait(false); + } + + string journalBatchNumber = createHeaderResult.Value.JournalBatchNumber!; + + // ----------------------------------------------------------------------------------------- + // 2. Chunked atomic batch-create: N lines, small MaxOperationsPerChunk forces several + // changesets. Proves multi-chunk splitting, global index aggregation, and per-chunk + // atomic commit on the happy path. + // ----------------------------------------------------------------------------------------- + var lines = new List(); + for (int i = 0; i < input.LineCount; i++) + { + bool debit = i % 2 == 0; + lines.Add(new LedgerJournalLine + { + DataAreaId = input.Company, + JournalBatchNumber = journalBatchNumber, + AccountDisplayValue = debit ? input.AccountDisplayValue : input.OffsetAccountDisplayValue, + AccountType = LedgerJournalACType.Ledger, + DebitAmount = debit ? input.Amount : 0m, + CreditAmount = debit ? 0m : input.Amount, + CurrencyCode = input.CurrencyCode, + TransDate = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero) + }); + } + + int expectedChunks = (int)Math.Ceiling((double)input.LineCount / input.ChunkSize); + var createBatchResult = await _mediator + .Send(new CreateBatchCommand(lines, + new BatchOptions { Mode = BatchFailureMode.Atomic, MaxOperationsPerChunk = input.ChunkSize }), cancellationToken) + .ConfigureAwait(false); + steps.Add(BuildBatchStep("BatchCreateLines.Atomic.Chunked", createBatchResult, + expect: o => o.AllSucceeded + && o.Total == input.LineCount + && o.ChunkCount == expectedChunks + && o.Items.Select(x => x.Index).SequenceEqual(Enumerable.Range(0, input.LineCount)), + detail: o => $"Total={o.Total}, Chunks={o.ChunkCount} (expected {expectedChunks}), Succeeded={o.Succeeded}")); + if (createBatchResult.IsFailed) + { + await BestEffortCleanup(steps, input.Company, journalBatchNumber, cancellationToken).ConfigureAwait(false); + return await WriteResponse(req, HttpStatusCode.OK, + new LedgerJournalBatchSmokeTestResponse(false, journalBatchNumber, steps), cancellationToken).ConfigureAwait(false); + } + + // ----------------------------------------------------------------------------------------- + // 3. Verify N lines actually landed, and capture the authoritative server-assigned + // LineNumbers for the update/delete phases. + // ----------------------------------------------------------------------------------------- + var afterCreate = await FilterLines(input.Company, journalBatchNumber, cancellationToken).ConfigureAwait(false); + steps.Add(VerifyCount("VerifyLinesCreated", afterCreate, input.LineCount)); + + List realLines = afterCreate.IsSuccess + ? [.. afterCreate.Value.OrderBy(l => l.LineNumber)] + : []; + if (realLines.Count < 2) + { + await BestEffortCleanup(steps, input.Company, journalBatchNumber, cancellationToken).ConfigureAwait(false); + return await WriteResponse(req, HttpStatusCode.OK, + new LedgerJournalBatchSmokeTestResponse(false, journalBatchNumber, steps), cancellationToken).ConfigureAwait(false); + } + + // ----------------------------------------------------------------------------------------- + // 4. Atomic-changeset ROLLBACK proof: one good update + one bogus-key op in a single atomic + // changeset. D365 must roll the whole changeset back, so the good op must NOT persist. + // ----------------------------------------------------------------------------------------- + LedgerJournalLine atomicTarget = realLines[0]; + string? atomicOriginalText = atomicTarget.TransactionText; + atomicTarget.TransactionText = "SMOKE-ATOMIC-SHOULD-ROLLBACK"; + var atomicBatch = new List + { + atomicTarget, + BogusLine(input, journalBatchNumber, 99999999m, "SMOKE-ATOMIC-BOGUS") + }; + var atomicResult = await _mediator + .Send(new UpdateBatchCommand(atomicBatch, new BatchOptions { Mode = BatchFailureMode.Atomic }), cancellationToken) + .ConfigureAwait(false); + steps.Add(ExpectBatchFailure("BatchUpdateAtomic.ExpectRollback", atomicResult)); + + var atomicReread = await GetLine(input.Company, journalBatchNumber, atomicTarget.LineNumber, cancellationToken).ConfigureAwait(false); + steps.Add(VerifyText("VerifyAtomicRolledBack", atomicReread, atomicOriginalText, "changeset rolled back: text unchanged")); + + // ----------------------------------------------------------------------------------------- + // 5. ContinueOnError PARTIAL accept: one good update + one bogus-key op. The good op must + // persist; the bogus op is collected as a failure in the outcome. + // ----------------------------------------------------------------------------------------- + LedgerJournalLine continueTarget = realLines[1]; + continueTarget.TransactionText = "SMOKE-CONTINUE-OK"; + var continueBatch = new List + { + continueTarget, + BogusLine(input, journalBatchNumber, 99999998m, "SMOKE-CONTINUE-BOGUS") + }; + var continueResult = await _mediator + .Send(new UpdateBatchCommand(continueBatch, new BatchOptions { Mode = BatchFailureMode.ContinueOnError }), cancellationToken) + .ConfigureAwait(false); + steps.Add(ExpectPartial("BatchUpdateContinueOnError.ExpectPartial", continueResult, expectedSucceeded: 1, expectedFailed: 1)); + + var continueReread = await GetLine(input.Company, journalBatchNumber, continueTarget.LineNumber, cancellationToken).ConfigureAwait(false); + steps.Add(VerifyText("VerifyContinueApplied", continueReread, "SMOKE-CONTINUE-OK", "good op applied")); + + // ----------------------------------------------------------------------------------------- + // 6. Batch-delete all lines (authoritative cleanup), verify none remain. + // ----------------------------------------------------------------------------------------- + var linesForDelete = await FilterLines(input.Company, journalBatchNumber, cancellationToken).ConfigureAwait(false); + if (linesForDelete.IsSuccess) + { + var deleteBatchResult = await _mediator + .Send(new DeleteBatchCommand([.. linesForDelete.Value], + new BatchOptions { Mode = BatchFailureMode.Atomic }), cancellationToken) + .ConfigureAwait(false); + steps.Add(BuildBatchStep("BatchDeleteLines.Atomic", deleteBatchResult, + expect: o => o.AllSucceeded, + detail: o => $"Deleted={o.Succeeded}/{o.Total}")); + } + else + { + steps.Add(BuildStep("BatchDeleteLines.FilterLines", linesForDelete, onSuccess: _ => "filtered")); + } + + var afterDelete = await FilterLines(input.Company, journalBatchNumber, cancellationToken).ConfigureAwait(false); + steps.Add(VerifyCount("VerifyLinesDeleted", afterDelete, 0)); + + // ----------------------------------------------------------------------------------------- + // 7. Delete the header, verify gone. + // ----------------------------------------------------------------------------------------- + var headerToDelete = new LedgerJournalHeader + { + DataAreaId = input.Company, + JournalBatchNumber = journalBatchNumber, + JournalName = "placeholder", // required field, not used by DELETE + Description = "placeholder" + }; + var deleteHeaderResult = await _mediator + .Send(new DeleteCommand(headerToDelete), cancellationToken) + .ConfigureAwait(false); + steps.Add(BuildStep("DeleteHeader", deleteHeaderResult, onSuccess: _ => "deleted")); + + var goneResult = await _mediator + .Send(new GetByKeyQuery([input.Company, journalBatchNumber]), cancellationToken) + .ConfigureAwait(false); + steps.Add(VerifyGone("VerifyHeaderDeleted", goneResult)); + + bool success = steps.All(s => s.Success); + _logger.LogInformation("LedgerJournal BATCH smoke test finished. Success={Success}, Steps={StepCount}", success, steps.Count); + return await WriteResponse(req, HttpStatusCode.OK, + new LedgerJournalBatchSmokeTestResponse(success, journalBatchNumber, steps), cancellationToken).ConfigureAwait(false); + } + + private static LedgerJournalLine BogusLine( + LedgerJournalBatchSmokeTestRequest input, string journalBatchNumber, decimal lineNumber, string text) => + new() + { + DataAreaId = input.Company, + JournalBatchNumber = journalBatchNumber, + LineNumber = lineNumber, // non-existent line — the PATCH/DELETE targets a key D365 doesn't have (404) + AccountDisplayValue = input.AccountDisplayValue, + AccountType = LedgerJournalACType.Ledger, + DebitAmount = 0m, + CreditAmount = 0m, + CurrencyCode = input.CurrencyCode, + TransDate = new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero), + TransactionText = text + }; + + private async Task>> FilterLines( + string company, string journalBatchNumber, CancellationToken cancellationToken) => + await _mediator + .Send(new GetByFilterQuery( + x => x.DataAreaId == company && x.JournalBatchNumber == journalBatchNumber), cancellationToken) + .ConfigureAwait(false); + + private async Task> GetLine( + string company, string journalBatchNumber, decimal lineNumber, CancellationToken cancellationToken) => + await _mediator + .Send(new GetByKeyQuery([company, journalBatchNumber, lineNumber]), cancellationToken) + .ConfigureAwait(false); + + private static BatchOutcome? GetOutcome(Result result) => + result.IsSuccess ? result.Value : (result.GetError() as BatchIntegrationError)?.Outcome; + + private SmokeTestStep BuildBatchStep( + string name, Result result, Func expect, Func detail) + { + BatchOutcome? outcome = GetOutcome(result); + if (result.IsSuccess && outcome is not null && expect(outcome)) + { + return new SmokeTestStep(name, true, Details: detail(outcome)); + } + + IntegrationError? error = result.GetError(); + _logger.LogWarning( + "Batch smoke step {Step} did not meet expectation. Code={Code}, Type={Type}, Outcome={Outcome}", + name, error?.Code ?? "(success)", error?.Type.ToString() ?? "(none)", + outcome is null ? "none" : $"S={outcome.Succeeded}/F={outcome.Failed}/Chunks={outcome.ChunkCount}"); + return new SmokeTestStep(name, false, + ErrorCode: error?.Code, + ErrorType: error?.Type.ToString(), + ErrorMessage: "Batch step did not meet expectation; see host logs for details.", + Details: outcome is not null ? detail(outcome) : null); + } + + private SmokeTestStep ExpectBatchFailure(string name, Result result) + { + BatchOutcome? outcome = GetOutcome(result); + if (result.IsFailed && result.GetError() is BatchIntegrationError) + { + return new SmokeTestStep(name, true, + Details: outcome is null ? "batch failed (no outcome)" : $"failed as expected: Succeeded={outcome.Succeeded}, Failed={outcome.Failed}"); + } + + _logger.LogWarning("Batch smoke step {Step} expected an atomic rollback failure but the batch succeeded.", name); + return new SmokeTestStep(name, false, + ErrorMessage: "Expected the atomic batch to fail (rollback), but it reported success.", + Details: outcome is null ? null : $"Succeeded={outcome.Succeeded}, Failed={outcome.Failed}"); + } + + private SmokeTestStep ExpectPartial(string name, Result result, int expectedSucceeded, int expectedFailed) + { + BatchOutcome? outcome = GetOutcome(result); + if (outcome is not null && outcome.Succeeded == expectedSucceeded && outcome.Failed == expectedFailed) + { + BatchItemResult? failure = outcome.Failures.FirstOrDefault(); + return new SmokeTestStep(name, true, + Details: $"Succeeded={outcome.Succeeded}, Failed={outcome.Failed}, FailStatus={failure?.StatusCode}"); + } + + _logger.LogWarning( + "Batch smoke step {Step} expected Succeeded={ExpSucceeded}/Failed={ExpFailed} but got {Outcome}.", + name, expectedSucceeded, expectedFailed, outcome is null ? "no outcome" : $"S={outcome.Succeeded}/F={outcome.Failed}"); + return new SmokeTestStep(name, false, + ErrorMessage: $"Expected Succeeded={expectedSucceeded}/Failed={expectedFailed}.", + Details: outcome is null ? "no outcome" : $"Succeeded={outcome.Succeeded}, Failed={outcome.Failed}"); + } + + private SmokeTestStep VerifyCount(string name, Result> result, int expected) + { + if (result.IsFailed) + { + return BuildStep(name, result, onSuccess: _ => "filtered"); + } + + int count = result.Value.Count(); + return count == expected + ? new SmokeTestStep(name, true, Details: $"Count={count}") + : new SmokeTestStep(name, false, Details: $"Expected Count={expected} but found {count}."); + } + + private SmokeTestStep VerifyText(string name, Result reread, string? expectedText, string note) + { + if (reread.IsFailed) + { + return BuildStep(name, reread, onSuccess: r => $"Text={r.TransactionText}"); + } + + string? actual = reread.Value.TransactionText; + return actual == expectedText + ? new SmokeTestStep(name, true, Details: $"{note}: Text={actual ?? "(null)"}") + : new SmokeTestStep(name, false, Details: $"{note}: expected Text='{expectedText ?? "(null)"}' but read '{actual ?? "(null)"}'."); + } + + private SmokeTestStep VerifyGone(string name, Result goneResult) + { + IntegrationError? goneError = goneResult.GetError(); + if (goneResult.IsFailed && goneError?.Type == ErrorType.NotFound) + { + return new SmokeTestStep(name, true, Details: "confirmed gone (NotFound)"); + } + + if (goneResult.IsSuccess) + { + return new SmokeTestStep(name, false, Details: "entity still exists after delete"); + } + + return BuildStep(name, goneResult); + } + + private async Task BestEffortCleanup( + List steps, string company, string journalBatchNumber, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(journalBatchNumber)) + { + return; + } + + var linesResult = await FilterLines(company, journalBatchNumber, cancellationToken).ConfigureAwait(false); + if (linesResult.IsSuccess) + { + foreach (var line in linesResult.Value) + { + var deleteLineResult = await _mediator + .Send(new DeleteCommand(line), cancellationToken) + .ConfigureAwait(false); + steps.Add(BuildStep($"Cleanup.DeleteLine[{line.LineNumber}]", deleteLineResult, onSuccess: _ => "deleted")); + } + } + else + { + steps.Add(BuildStep("Cleanup.FilterLines", linesResult, onSuccess: _ => "filtered")); + } + + var headerToDelete = new LedgerJournalHeader + { + DataAreaId = company, + JournalBatchNumber = journalBatchNumber, + JournalName = "placeholder", // required field, not used by DELETE + Description = "placeholder" + }; + var deleteHeaderResult = await _mediator + .Send(new DeleteCommand(headerToDelete), cancellationToken) + .ConfigureAwait(false); + steps.Add(BuildStep("Cleanup.DeleteHeader", deleteHeaderResult, onSuccess: _ => "deleted")); + } + + private SmokeTestStep BuildStep(string name, Result result, Func? onSuccess = null) + { + if (result.IsSuccess) + { + // result.Value can be null on a successful write whose server response carried no body + // (e.g. an OData 204 No Content), so guard before invoking the onSuccess projector. + return new SmokeTestStep(name, true, Details: result.Value is not null ? onSuccess?.Invoke(result.Value) : null); + } + + IntegrationError? error = result.GetError(); + string code = error?.Code ?? "SmokeTest.Unknown"; + string type = error?.Type.ToString() ?? ErrorType.Failure.ToString(); + string serverMessage = error?.Message ?? result.Errors.FirstOrDefault()?.Message ?? "Unknown error"; + + _logger.LogWarning("Smoke step {Step} failed. Code={Code}, Type={Type}, Detail={Detail}", name, code, type, serverMessage); + return new SmokeTestStep(name, false, ErrorCode: code, ErrorType: type, ErrorMessage: "Operation failed; see host logs for details."); + } + + private static LedgerJournalBatchSmokeTestResponse Fail(string step, string code, ErrorType type, string message) => + new(false, null, [new SmokeTestStep(step, false, code, type.ToString(), message)]); + + private static async Task WriteResponse( + HttpRequestData req, HttpStatusCode status, LedgerJournalBatchSmokeTestResponse body, CancellationToken cancellationToken) + { + var response = req.CreateResponse(status); + await response.WriteAsJsonAsync(body, cancellationToken).ConfigureAwait(false); + return response; + } +} diff --git a/wiki/Run-Smoke-Tests.md b/wiki/Run-Smoke-Tests.md index d093ab2..a9baf5f 100644 --- a/wiki/Run-Smoke-Tests.md +++ b/wiki/Run-Smoke-Tests.md @@ -1,7 +1,7 @@ # Run Smoke Tests > Last verified against v2.0.1 -`IntegratoR.SampleFunction` ships two HTTP triggers that drive the whole stack — auth, OData wiring, the LINQ-to-OData translator, Polly resilience — against a live D365 F&O sandbox. Run them to prove a fresh deployment works before you wire in real business logic. They are part of the sample host, not a separate NuGet package. +`IntegratoR.SampleFunction` ships three HTTP triggers that drive the whole stack — auth, OData wiring, the LINQ-to-OData translator, Polly resilience — against a live D365 F&O sandbox. Run them to prove a fresh deployment works before you wire in real business logic. They are part of the sample host, not a separate NuGet package. Start with the read-only dimension trigger: it needs no company context and leaves no records behind. @@ -33,14 +33,15 @@ A green run returns the delimiter and ordered segments the handler parsed out of The exact segment list depends on the target environment — the example is the shape captured against a JFI sandbox. -## The two triggers +## The three triggers | Function ID | Route | Flow | Side effects | |---|---|---|---| | `FinancialDimensionSmokeTest_HTTPTrigger` | `POST /api/smoke/financial-dimensions` | One `GetDimensionOrdersQuery` (chains two D365 reads) | None — read-only, safe to repeat | | `LedgerJournalSmokeTest_HTTPTrigger` | `POST /api/smoke/ledger-journal` | Create → GetByKey → Filter → Update → Delete on `LedgerJournalHeader` + `LedgerJournalLine` | Writes a real journal, then deletes it — self-cleaning on a green run | +| `LedgerJournalBatchSmokeTest_HTTPTrigger` | `POST /api/smoke/ledger-journal-batch` | Chunked atomic batch-create → atomic-changeset rollback → continue-on-error partial → batch delete of `LedgerJournalLine` (v3.0.0) | Writes a real journal + lines, then deletes them — self-cleaning on a green run | -Both use `AuthorizationLevel.Function`. Locally under `func start` no key is needed; once deployed to Azure they need a function/host key (`?code=` or the `x-functions-key` header). +All use `AuthorizationLevel.Function`. Locally under `func start` no key is needed; once deployed to Azure they need a function/host key (`?code=` or the `x-functions-key` header). ## Run the triggers locally @@ -123,6 +124,43 @@ Verified against live D365 (JFI) on 2026-07-01: the complete create → update > [!NOTE] > D365 answers a composite-key PATCH with `204 No Content`, so `UpdateCommand` returns your caller entity and `result.Value` may be null on a successful write. The step builder null-guards `result.Value` before projecting details — a diagnostic trigger must never throw and lose every per-step result to a 500. +## Ledger journal batch smoke test + +The batch trigger drives the configurable, chunked `$batch` write path (v3.0.0, [ADR-0004](https://github.com/Mikeoso/IntegratoR/blob/main/docs/adr/0004-configurable-chunked-odata-batch.md)) end-to-end under one journal header. It is the live-verification gate for a v3.0.0 release: it proves — against real D365 — that changesets are atomic, that `ContinueOnError` accepts the good operations and reports the bad ones, and that large inputs chunk correctly. + +```bash +curl -s -X POST http://localhost:7123/api/smoke/ledger-journal-batch \ + -H "Content-Type: application/json" \ + -d '{ + "Company": "USMF", + "JournalName": "GenJrn", + "AccountDisplayValue": "110180-", + "OffsetAccountDisplayValue": "211100-", + "Amount": 100.00, + "CurrencyCode": "USD", + "LineCount": 6, + "ChunkSize": 2 + }' +``` + +`LineCount` (default 6, min 2) is how many lines to batch-create; `ChunkSize` (default 2, 1–200) is set deliberately small so the create splits across several `$batch` changesets. Failures are injected deterministically: the rollback and partial steps include one operation targeting a non-existent `LineNumber` (guaranteed HTTP 404), so the test does not depend on D365 account validation. + +| Step | What it proves | +|---|---| +| `BatchCreateLines.Atomic.Chunked` | `CreateBatchCommand` split into ⌈`LineCount`/`ChunkSize`⌉ changesets; global index aggregation; per-chunk atomic commit | +| `VerifyLinesCreated` | Re-query count equals `LineCount` | +| `BatchUpdateAtomic.ExpectRollback` | One good update + one bogus-key op in a single `Atomic` changeset — D365 rolls the **whole** changeset back | +| `VerifyAtomicRolledBack` | Re-read confirms the good op did **not** persist (proves changeset atomicity) | +| `BatchUpdateContinueOnError.ExpectPartial` | Same mix in `ContinueOnError` — outcome reports `Succeeded=1, Failed=1` | +| `VerifyContinueApplied` | Re-read confirms the good op **did** persist | +| `BatchDeleteLines.Atomic` / `VerifyLinesDeleted` | `DeleteBatchCommand` removes every line | +| `DeleteHeader` / `VerifyHeaderDeleted` | Header deleted last (D365 rejects deleting a header with child lines); re-read confirms `NotFound` | + +A failed batch comes back as a failed `Result` whose error is a `BatchIntegrationError` carrying the full per-item outcome — the trigger reads `Succeeded`/`Failed`/`Failures` from it to decide each step. Because the chain deletes everything it creates, a green run leaves no orphan. + +> [!NOTE] +> `Atomic` atomicity is **per chunk**, not per dataset: a `LineCount` larger than `ChunkSize` spans several changesets, each committing independently. See [Known Limitations](Known-Limitations) for the D365 200-operation `$batch` ceiling this follows from. + ## Read a failed step Each entry in `Steps[]` carries the failing operation and its `IntegrationError`. On failure the trigger surfaces the `Code` and `Type`, and returns a generic `ErrorMessage` — the full server detail is logged host-side only, never echoed to the caller. @@ -159,12 +197,13 @@ The full diagnostic chain — retry warnings, circuit-breaker state, the logged ## Wire into CI -Both triggers signal outcome through the JSON `Success` field, not the HTTP status — the response is `200 OK` unless the body is malformed (`400`). A CI script must check `.Success` against the body, not the status code: +All three triggers signal outcome through the JSON `Success` field, not the HTTP status — the response is `200 OK` unless the body is malformed (`400`). A CI script must check `.Success` against the body, not the status code: 1. Deploy to a sandbox slot. 2. Run the financial-dimension test (read-only, fast). 3. Run the ledger-journal test (writes then self-cleans). -4. Block promotion if either body's `Success` is `false` — for example, `jq -e '.Success'`. +4. Run the ledger-journal-batch test (writes a chunked batch then self-cleans) — the v3.0.0 release gate. +5. Block promotion if any body's `Success` is `false` — for example, `jq -e '.Success'`. ## See Also