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