Skip to content

Commit b3f3004

Browse files
feat(api): add GET /api/orders/stuck/summary endpoint (BD-696)
- Add summary statistics endpoint for stuck orders - Return totalStuckOrders, byThreshold (6h/48h counts) - Return byStatusCategory (Preparation, PrintBoxAlert, etc.) - Return topStatuses (top 10 statuses with counts) - Include generatedAt timestamp - Add 7 new unit tests (79 total tests passing) TDD: Tests written first following RED-GREEN-REFACTOR Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7e9829a commit b3f3004

2 files changed

Lines changed: 251 additions & 0 deletions

File tree

src/OrderMonitor.Api/Controllers/OrdersController.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,38 @@ public async Task<ActionResult<OrderStatusHistoryResponse>> GetOrderStatusHistor
116116
return StatusCode(StatusCodes.Status500InternalServerError, new { error = "An error occurred while retrieving order status history" });
117117
}
118118
}
119+
120+
/// <summary>
121+
/// Gets summary statistics for stuck orders.
122+
/// </summary>
123+
/// <param name="cancellationToken">Cancellation token</param>
124+
/// <returns>Summary statistics including counts by threshold and category</returns>
125+
/// <response code="200">Returns the stuck orders summary</response>
126+
/// <response code="500">If an internal error occurs</response>
127+
[HttpGet("stuck/summary")]
128+
[ProducesResponseType(typeof(StuckOrdersSummary), StatusCodes.Status200OK)]
129+
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
130+
public async Task<ActionResult<StuckOrdersSummary>> GetStuckOrdersSummary(
131+
CancellationToken cancellationToken = default)
132+
{
133+
try
134+
{
135+
_logger.LogInformation("Getting stuck orders summary");
136+
137+
var summary = await _stuckOrderService.GetStuckOrdersSummaryAsync(cancellationToken);
138+
139+
_logger.LogInformation(
140+
"Stuck orders summary: Total={Total}, PrepStatuses={PrepCount}, FacilityStatuses={FacilityCount}",
141+
summary.TotalStuckOrders,
142+
summary.ByThreshold.GetValueOrDefault("PrepStatuses (6h)", 0),
143+
summary.ByThreshold.GetValueOrDefault("FacilityStatuses (48h)", 0));
144+
145+
return Ok(summary);
146+
}
147+
catch (Exception ex)
148+
{
149+
_logger.LogError(ex, "Error getting stuck orders summary");
150+
return StatusCode(StatusCodes.Status500InternalServerError, new { error = "An error occurred while retrieving stuck orders summary" });
151+
}
152+
}
119153
}

tests/OrderMonitor.UnitTests/Controllers/OrdersControllerTests.cs

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,221 @@ public async Task GetOrderStatusHistory_FlagsCurrentStatusAsStuck_WhenExceedsThr
375375
}
376376

377377
#endregion
378+
379+
#region GetStuckOrdersSummary Tests
380+
381+
[Fact]
382+
public async Task GetStuckOrdersSummary_ReturnsOkResult_WithSummary()
383+
{
384+
// Arrange
385+
var expectedSummary = new StuckOrdersSummary
386+
{
387+
TotalStuckOrders = 150,
388+
ByThreshold = new Dictionary<string, int>
389+
{
390+
["PrepStatuses (6h)"] = 100,
391+
["FacilityStatuses (48h)"] = 50
392+
},
393+
ByStatusCategory = new Dictionary<string, int>
394+
{
395+
["Preparation"] = 80,
396+
["PrintBoxAlert"] = 20,
397+
["Facility"] = 40,
398+
["Shipping"] = 10
399+
},
400+
TopStatuses = new List<StatusCount>
401+
{
402+
new() { StatusId = 3060, Status = "PreparationDone", Count = 45 },
403+
new() { StatusId = 4800, Status = "ErrorInFacility", Count = 30 },
404+
new() { StatusId = 3720, Status = "PrintBoxAlert_RenderStatusFailure", Count = 15 }
405+
},
406+
GeneratedAt = DateTime.UtcNow
407+
};
408+
409+
_stuckOrderServiceMock
410+
.Setup(s => s.GetStuckOrdersSummaryAsync(It.IsAny<CancellationToken>()))
411+
.ReturnsAsync(expectedSummary);
412+
413+
// Act
414+
var result = await _controller.GetStuckOrdersSummary();
415+
416+
// Assert
417+
var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
418+
var summary = okResult.Value.Should().BeOfType<StuckOrdersSummary>().Subject;
419+
summary.TotalStuckOrders.Should().Be(150);
420+
}
421+
422+
[Fact]
423+
public async Task GetStuckOrdersSummary_ReturnsByThresholdCounts()
424+
{
425+
// Arrange
426+
var expectedSummary = new StuckOrdersSummary
427+
{
428+
TotalStuckOrders = 100,
429+
ByThreshold = new Dictionary<string, int>
430+
{
431+
["PrepStatuses (6h)"] = 60,
432+
["FacilityStatuses (48h)"] = 40
433+
},
434+
ByStatusCategory = new Dictionary<string, int>(),
435+
TopStatuses = [],
436+
GeneratedAt = DateTime.UtcNow
437+
};
438+
439+
_stuckOrderServiceMock
440+
.Setup(s => s.GetStuckOrdersSummaryAsync(It.IsAny<CancellationToken>()))
441+
.ReturnsAsync(expectedSummary);
442+
443+
// Act
444+
var result = await _controller.GetStuckOrdersSummary();
445+
446+
// Assert
447+
var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
448+
var summary = okResult.Value.Should().BeOfType<StuckOrdersSummary>().Subject;
449+
summary.ByThreshold.Should().ContainKey("PrepStatuses (6h)");
450+
summary.ByThreshold.Should().ContainKey("FacilityStatuses (48h)");
451+
summary.ByThreshold["PrepStatuses (6h)"].Should().Be(60);
452+
summary.ByThreshold["FacilityStatuses (48h)"].Should().Be(40);
453+
}
454+
455+
[Fact]
456+
public async Task GetStuckOrdersSummary_ReturnsByStatusCategoryCounts()
457+
{
458+
// Arrange
459+
var expectedSummary = new StuckOrdersSummary
460+
{
461+
TotalStuckOrders = 100,
462+
ByThreshold = new Dictionary<string, int>(),
463+
ByStatusCategory = new Dictionary<string, int>
464+
{
465+
["Preparation"] = 50,
466+
["PrintBoxAlert"] = 20,
467+
["Facility"] = 30
468+
},
469+
TopStatuses = [],
470+
GeneratedAt = DateTime.UtcNow
471+
};
472+
473+
_stuckOrderServiceMock
474+
.Setup(s => s.GetStuckOrdersSummaryAsync(It.IsAny<CancellationToken>()))
475+
.ReturnsAsync(expectedSummary);
476+
477+
// Act
478+
var result = await _controller.GetStuckOrdersSummary();
479+
480+
// Assert
481+
var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
482+
var summary = okResult.Value.Should().BeOfType<StuckOrdersSummary>().Subject;
483+
summary.ByStatusCategory.Should().HaveCount(3);
484+
summary.ByStatusCategory["Preparation"].Should().Be(50);
485+
}
486+
487+
[Fact]
488+
public async Task GetStuckOrdersSummary_ReturnsTopStatuses()
489+
{
490+
// Arrange
491+
var expectedSummary = new StuckOrdersSummary
492+
{
493+
TotalStuckOrders = 100,
494+
ByThreshold = new Dictionary<string, int>(),
495+
ByStatusCategory = new Dictionary<string, int>(),
496+
TopStatuses = new List<StatusCount>
497+
{
498+
new() { StatusId = 3060, Status = "PreparationDone", Count = 45 },
499+
new() { StatusId = 4800, Status = "ErrorInFacility", Count = 30 },
500+
new() { StatusId = 3720, Status = "PrintBoxAlert_RenderStatusFailure", Count = 15 }
501+
},
502+
GeneratedAt = DateTime.UtcNow
503+
};
504+
505+
_stuckOrderServiceMock
506+
.Setup(s => s.GetStuckOrdersSummaryAsync(It.IsAny<CancellationToken>()))
507+
.ReturnsAsync(expectedSummary);
508+
509+
// Act
510+
var result = await _controller.GetStuckOrdersSummary();
511+
512+
// Assert
513+
var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
514+
var summary = okResult.Value.Should().BeOfType<StuckOrdersSummary>().Subject;
515+
summary.TopStatuses.Should().HaveCount(3);
516+
summary.TopStatuses.First().Status.Should().Be("PreparationDone");
517+
summary.TopStatuses.First().Count.Should().Be(45);
518+
}
519+
520+
[Fact]
521+
public async Task GetStuckOrdersSummary_WhenNoStuckOrders_ReturnsZeroCounts()
522+
{
523+
// Arrange
524+
var expectedSummary = new StuckOrdersSummary
525+
{
526+
TotalStuckOrders = 0,
527+
ByThreshold = new Dictionary<string, int>
528+
{
529+
["PrepStatuses (6h)"] = 0,
530+
["FacilityStatuses (48h)"] = 0
531+
},
532+
ByStatusCategory = new Dictionary<string, int>(),
533+
TopStatuses = [],
534+
GeneratedAt = DateTime.UtcNow
535+
};
536+
537+
_stuckOrderServiceMock
538+
.Setup(s => s.GetStuckOrdersSummaryAsync(It.IsAny<CancellationToken>()))
539+
.ReturnsAsync(expectedSummary);
540+
541+
// Act
542+
var result = await _controller.GetStuckOrdersSummary();
543+
544+
// Assert
545+
var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
546+
var summary = okResult.Value.Should().BeOfType<StuckOrdersSummary>().Subject;
547+
summary.TotalStuckOrders.Should().Be(0);
548+
summary.TopStatuses.Should().BeEmpty();
549+
}
550+
551+
[Fact]
552+
public async Task GetStuckOrdersSummary_WhenServiceThrows_Returns500()
553+
{
554+
// Arrange
555+
_stuckOrderServiceMock
556+
.Setup(s => s.GetStuckOrdersSummaryAsync(It.IsAny<CancellationToken>()))
557+
.ThrowsAsync(new Exception("Database error"));
558+
559+
// Act
560+
var result = await _controller.GetStuckOrdersSummary();
561+
562+
// Assert
563+
result.Result.Should().BeOfType<ObjectResult>()
564+
.Which.StatusCode.Should().Be(500);
565+
}
566+
567+
[Fact]
568+
public async Task GetStuckOrdersSummary_IncludesGeneratedAtTimestamp()
569+
{
570+
// Arrange
571+
var generatedAt = DateTime.UtcNow;
572+
var expectedSummary = new StuckOrdersSummary
573+
{
574+
TotalStuckOrders = 10,
575+
ByThreshold = new Dictionary<string, int>(),
576+
ByStatusCategory = new Dictionary<string, int>(),
577+
TopStatuses = [],
578+
GeneratedAt = generatedAt
579+
};
580+
581+
_stuckOrderServiceMock
582+
.Setup(s => s.GetStuckOrdersSummaryAsync(It.IsAny<CancellationToken>()))
583+
.ReturnsAsync(expectedSummary);
584+
585+
// Act
586+
var result = await _controller.GetStuckOrdersSummary();
587+
588+
// Assert
589+
var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
590+
var summary = okResult.Value.Should().BeOfType<StuckOrdersSummary>().Subject;
591+
summary.GeneratedAt.Should().BeCloseTo(generatedAt, TimeSpan.FromSeconds(1));
592+
}
593+
594+
#endregion
378595
}

0 commit comments

Comments
 (0)