Skip to content

Commit 03daf45

Browse files
authored
Merge pull request #131 from Akash29g/feat/file-retry-error-catalog
feat: file retry + editable error catalog
2 parents a0c821e + 15cc3d5 commit 03daf45

31 files changed

Lines changed: 827 additions & 123 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using DocAnalytics.Api.Common;
2+
using DocAnalytics.Service.ErrorCatalog;
3+
using Microsoft.AspNetCore.Authorization;
4+
using Microsoft.AspNetCore.Mvc;
5+
6+
namespace DocAnalytics.Api.Controllers;
7+
8+
/// <summary>
9+
/// Global error catalog management.
10+
/// GET is open to all authenticated users; POST and PUT require Admin role.
11+
/// </summary>
12+
[ApiController]
13+
[Authorize(Policy = "DataAccess")]
14+
[Route("api/v1/error-catalog")]
15+
public sealed class ErrorCatalogController : ControllerBase
16+
{
17+
private readonly IErrorCatalogService _service;
18+
19+
public ErrorCatalogController(IErrorCatalogService service) => _service = service;
20+
21+
/// <summary>Returns all error catalog entries ordered by code.</summary>
22+
// GET /api/v1/error-catalog
23+
[HttpGet]
24+
public async Task<IActionResult> GetAll(CancellationToken ct)
25+
{
26+
var items = await _service.GetAllAsync(ct);
27+
return Ok(ApiResponse<List<ErrorCatalogDto>>.Ok(items));
28+
}
29+
30+
/// <summary>Creates a new error catalog entry. Admin only.</summary>
31+
// POST /api/v1/error-catalog
32+
[HttpPost]
33+
[Authorize(Roles = "Admin")]
34+
public async Task<IActionResult> Create(
35+
[FromBody] CreateErrorCatalogDto dto, CancellationToken ct)
36+
{
37+
if (string.IsNullOrWhiteSpace(dto.ErrorCode) || string.IsNullOrWhiteSpace(dto.Description))
38+
return BadRequest(ApiResponse<ErrorCatalogDto>.Fail(
39+
"VALIDATION", "ErrorCode and Description are required."));
40+
41+
var result = await _service.CreateAsync(dto, ct);
42+
43+
if (result is null)
44+
return Conflict(ApiResponse<ErrorCatalogDto>.Fail(
45+
"DUPLICATE_CODE", $"Error code '{dto.ErrorCode.Trim().ToUpperInvariant()}' already exists."));
46+
47+
return Ok(ApiResponse<ErrorCatalogDto>.Ok(result));
48+
}
49+
50+
/// <summary>Updates description and remediation of an existing entry. Admin only.</summary>
51+
// PUT /api/v1/error-catalog/{id}
52+
[HttpPut("{id:guid}")]
53+
[Authorize(Roles = "Admin")]
54+
public async Task<IActionResult> Update(
55+
Guid id, [FromBody] UpdateErrorCatalogDto dto, CancellationToken ct)
56+
{
57+
if (string.IsNullOrWhiteSpace(dto.Description))
58+
return BadRequest(ApiResponse<ErrorCatalogDto>.Fail(
59+
"VALIDATION", "Description is required."));
60+
61+
var result = await _service.UpdateAsync(id, dto, ct);
62+
63+
if (result is null)
64+
return NotFound(ApiResponse<ErrorCatalogDto>.Fail(
65+
"NOT_FOUND", "Error catalog entry not found."));
66+
67+
return Ok(ApiResponse<ErrorCatalogDto>.Ok(result));
68+
}
69+
70+
// DELETE /api/v1/error-catalog/{id}
71+
[HttpDelete("{id:guid}")]
72+
[Authorize(Roles = "Admin")]
73+
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
74+
{
75+
var deleted = await _service.DeleteAsync(id, ct);
76+
if (!deleted)
77+
return NotFound(ApiResponse<object>.Fail("NOT_FOUND", "Entry not found."));
78+
return Ok(ApiResponse<object>.Ok(new { deleted = true }));
79+
}
80+
81+
}

DocAnalytics.Api/Controllers/FilesController.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,32 @@ public async Task<IActionResult> GetLogs(Guid id, CancellationToken ct)
5353
return File(Encoding.UTF8.GetBytes(log.Content), "text/plain", log.FileName);
5454
}
5555

56+
/// <summary>
57+
/// Resets a Failed file to Queued and re-enqueues it for processing.
58+
/// Admin-only — stacks with the class-level DataAccess policy.
59+
/// </summary>
60+
// POST /api/v1/files/{id}/retry
61+
[HttpPost("{id:guid}/retry")]
62+
[Authorize(Roles = "Admin")]
63+
public async Task<IActionResult> RetryFile(Guid id, CancellationToken ct)
64+
{
65+
try
66+
{
67+
var result = await _service.RetryFileAsync(id, ct);
68+
69+
if (result is null)
70+
return NotFound(
71+
ApiResponse<RetryFileResponseDto>.Fail(
72+
"NOT_FOUND", "File not found or access denied."));
73+
74+
return Ok(ApiResponse<RetryFileResponseDto>.Ok(result));
75+
}
76+
catch (InvalidOperationException ex)
77+
{
78+
return BadRequest(
79+
ApiResponse<RetryFileResponseDto>.Fail("INVALID_STATE", ex.Message));
80+
}
81+
}
82+
5683

5784
}

DocAnalytics.Api/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
builder.Services.AddDashboardFeature();
6565
builder.Services.AddInvoiceFeature();
6666
builder.Services.AddFileDetailsFeature();
67+
builder.Services.AddErrorCatalogFeature();
6768
builder.Services.AddAnalyticsFeature();
6869
builder.Services.AddErrorListFeature();
6970
builder.Services.AddActivityLogFeature();

DocAnalytics.Service.Tests/Errors/ErrorServiceTests.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
using DocAnalytics.Service.Errors;
33
using DocAnalytics.Service.Tests.Support;
44
using MockQueryable.Moq;
5+
using ErrorCatalogEntry = DocAnalytics.Domain.Entities.ErrorCatalog;
56

67
namespace DocAnalytics.Service.Tests.Errors;
78

89
public class ErrorServiceTests
910
{
1011
private static ErrorService BuildSut(
1112
IEnumerable<FileRecord> files, IEnumerable<FileStepHistory> steps,
12-
IEnumerable<Transaction> txns, IEnumerable<ErrorCatalog> catalog)
13+
IEnumerable<Transaction> txns, IEnumerable<ErrorCatalogEntry> catalog)
1314
{
1415
var ctx = MockDb.Create();
1516
ctx.Setup(c => c.Files).Returns(files.ToList().BuildMockDbSet().Object);
@@ -31,7 +32,7 @@ public async Task GetErrorsAsync_returns_only_failed_steps_with_remediation()
3132
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Upload", Status = "Success" },
3233
};
3334
var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } };
34-
var catalog = new[] { new ErrorCatalog { ErrorCode = "ERR1", RemediationMsg = "Retry the upload" } };
35+
var catalog = new[] { new ErrorCatalogEntry { ErrorCode = "ERR1", RemediationMsg = "Retry the upload" } };
3536

3637
var sut = BuildSut(files, steps, txns, catalog);
3738

@@ -53,7 +54,7 @@ public async Task GetErrorsAsync_filters_by_step()
5354
};
5455
var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } };
5556

56-
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalog>());
57+
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalogEntry>());
5758

5859
var result = await sut.GetErrorsAsync(new ErrorListQuery { Step = "Transform" });
5960

@@ -68,7 +69,7 @@ public async Task GetErrorsAsync_filters_by_source()
6869
var files = new[] { new FileRecord { Id = fileId, FileName = "a.pdf", TransactionId = txnId } };
6970
var steps = new[] { new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", Status = "Failed", ErrorCode = "E1" } };
7071
var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } };
71-
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalog>());
72+
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalogEntry>());
7273

7374
Assert.Equal(1, (await sut.GetErrorsAsync(new ErrorListQuery { Source = "SAP" })).TotalCount);
7475
Assert.Equal(0, (await sut.GetErrorsAsync(new ErrorListQuery { Source = "CSV" })).TotalCount);
@@ -85,7 +86,7 @@ public async Task GetErrorsAsync_filters_by_date_range()
8586
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Load", Status = "Failed", ErrorCode = "E2", CompletedAt = new DateTime(2026,6,1,0,0,0,DateTimeKind.Utc) },
8687
};
8788
var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } };
88-
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalog>());
89+
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalogEntry>());
8990

9091
var result = await sut.GetErrorsAsync(new ErrorListQuery { From = new DateTime(2026, 5, 1), To = new DateTime(2026, 7, 1) });
9192
Assert.Equal(1, result.TotalCount);
@@ -102,7 +103,7 @@ public async Task GetErrorsAsync_sorts_by_error_code_descending()
102103
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Load", Status = "Failed", ErrorCode = "E9" },
103104
};
104105
var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } };
105-
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalog>());
106+
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalogEntry>());
106107

107108
var result = await sut.GetErrorsAsync(new ErrorListQuery { SortBy = "error_code", SortDir = "desc" });
108109
Assert.Equal("E9", result.Items[0].ErrorCode);
@@ -119,7 +120,7 @@ public async Task GetErrorsForExportAsync_returns_all_matching_rows()
119120
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Load", Status = "Failed", ErrorCode = "E2" },
120121
};
121122
var txns = new[] { new Transaction { Id = txnId, SourceSystem = "SAP" } };
122-
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalog>());
123+
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalogEntry>());
123124

124125
Assert.Equal(2, (await sut.GetErrorsForExportAsync(new ErrorListQuery())).Count);
125126
}

DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs

Lines changed: 61 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1+
using DocAnalytics.Domain.Common;
12
using DocAnalytics.Domain.Entities;
3+
using DocAnalytics.Service.Extraction;
24
using DocAnalytics.Service.Files;
35
using DocAnalytics.Service.Tests.Support;
46
using MockQueryable.Moq;
57
using Moq;
8+
using ErrorCatalogEntry = DocAnalytics.Domain.Entities.ErrorCatalog;
9+
10+
611

712
namespace DocAnalytics.Service.Tests.Files;
813

914
public class FileDetailsServiceTests
1015
{
16+
// ── helpers ────────────────────────────────────────────────────────────
17+
1118
private static Mock<DocAnalytics.Data.AppDbContext> Ctx(
12-
FileRecord[] files, FileStepHistory[] steps, ErrorCatalog[] catalog)
19+
FileRecord[] files, FileStepHistory[] steps, ErrorCatalogEntry[] catalog)
1320
{
1421
var ctx = MockDb.Create();
1522
ctx.Setup(c => c.Files).Returns(files.ToList().BuildMockDbSet().Object);
@@ -18,54 +25,95 @@ public class FileDetailsServiceTests
1825
return ctx;
1926
}
2027

28+
// Wrap construction so existing tests don't need to know about the new deps
29+
// (IExtractionQueue + ICurrentUser are only used by RetryFileAsync, not these tests)
30+
private static FileDetailsService Svc(Mock<DocAnalytics.Data.AppDbContext> ctx) =>
31+
new(ctx.Object,
32+
new Mock<IExtractionQueue>().Object,
33+
new Mock<ICurrentUser>().Object);
34+
35+
// ── tests ───────────────────────────────────────────────────────────────
36+
2137
[Fact]
2238
public async Task GetFileDetailsAsync_returns_null_when_file_missing()
2339
{
24-
var sut = new FileDetailsService(Ctx(Array.Empty<FileRecord>(), Array.Empty<FileStepHistory>(), Array.Empty<ErrorCatalog>()).Object);
40+
var sut = Svc(Ctx(Array.Empty<FileRecord>(),
41+
Array.Empty<FileStepHistory>(),
42+
Array.Empty<ErrorCatalogEntry>()));
2543
Assert.Null(await sut.GetFileDetailsAsync(Guid.NewGuid()));
2644
}
2745

2846
[Fact]
2947
public async Task GetFileDetailsAsync_maps_history_with_remediation()
3048
{
3149
var fileId = Guid.NewGuid();
32-
var files = new[] { new FileRecord { Id = fileId, FileName = "a.pdf", Status = "Failed", CurrentStep = "Validate" } };
50+
var files = new[]
51+
{
52+
new FileRecord
53+
{
54+
Id = fileId, FileName = "a.pdf",
55+
Status = "Failed", CurrentStep = "Validate",
56+
},
57+
};
3358
var steps = new[]
3459
{
35-
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Upload", Status = "Success", StartedAt = DateTime.UtcNow.AddMinutes(-2) },
36-
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad", StartedAt = DateTime.UtcNow.AddMinutes(-1) },
60+
new FileStepHistory
61+
{
62+
Id = Guid.NewGuid(), FileId = fileId, StepName = "Upload",
63+
Status = "Success", StartedAt = DateTime.UtcNow.AddMinutes(-2),
64+
},
65+
new FileStepHistory
66+
{
67+
Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate",
68+
Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad",
69+
StartedAt = DateTime.UtcNow.AddMinutes(-1),
70+
},
3771
};
38-
var catalog = new[] { new ErrorCatalog { ErrorCode = "E1", RemediationMsg = "Fix it" } };
72+
var catalog = new[] { new ErrorCatalogEntry { ErrorCode = "E1", RemediationMsg = "Fix it" } };
3973

40-
var dto = await new FileDetailsService(Ctx(files, steps, catalog).Object).GetFileDetailsAsync(fileId);
74+
var dto = await Svc(Ctx(files, steps, catalog)).GetFileDetailsAsync(fileId);
4175

4276
Assert.NotNull(dto);
4377
Assert.Equal("a.pdf", dto!.FileInfo.Name);
4478
Assert.Equal(2, dto.History.Count);
4579
var failed = dto.History.Single(h => h.Step == "Validate");
4680
Assert.Equal("Fix it", failed.Error!.SuggestedFix);
47-
Assert.Null(dto.History.Single(h => h.Step == "Upload").Error); // success → no error block
81+
Assert.Null(dto.History.Single(h => h.Step == "Upload").Error);
4882
}
4983

5084
[Fact]
5185
public async Task GetFileLogsAsync_returns_null_when_file_missing()
5286
{
53-
var sut = new FileDetailsService(Ctx(Array.Empty<FileRecord>(), Array.Empty<FileStepHistory>(), Array.Empty<ErrorCatalog>()).Object);
87+
var sut = Svc(Ctx(Array.Empty<FileRecord>(),
88+
Array.Empty<FileStepHistory>(),
89+
Array.Empty<ErrorCatalogEntry>()));
5490
Assert.Null(await sut.GetFileLogsAsync(Guid.NewGuid()));
5591
}
5692

5793
[Fact]
5894
public async Task GetFileLogsAsync_builds_downloadable_log()
5995
{
6096
var fileId = Guid.NewGuid();
61-
var files = new[] { new FileRecord { Id = fileId, FileName = "a.pdf", Status = "Failed", CurrentStep = "Validate" } };
97+
var files = new[]
98+
{
99+
new FileRecord
100+
{
101+
Id = fileId, FileName = "a.pdf",
102+
Status = "Failed", CurrentStep = "Validate",
103+
},
104+
};
62105
var steps = new[]
63106
{
64-
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad", StartedAt = DateTime.UtcNow },
107+
new FileStepHistory
108+
{
109+
Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate",
110+
Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad",
111+
StartedAt = DateTime.UtcNow,
112+
},
65113
};
66-
var catalog = new[] { new ErrorCatalog { ErrorCode = "E1", RemediationMsg = "Fix it" } };
114+
var catalog = new[] { new ErrorCatalogEntry { ErrorCode = "E1", RemediationMsg = "Fix it" } };
67115

68-
var log = await new FileDetailsService(Ctx(files, steps, catalog).Object).GetFileLogsAsync(fileId);
116+
var log = await Svc(Ctx(files, steps, catalog)).GetFileLogsAsync(fileId);
69117

70118
Assert.NotNull(log);
71119
Assert.Equal($"file_{fileId}_log.txt", log!.FileName);

0 commit comments

Comments
 (0)