Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions DocAnalytics.Api/Controllers/ErrorCatalogController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using DocAnalytics.Api.Common;
using DocAnalytics.Service.ErrorCatalog;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace DocAnalytics.Api.Controllers;

/// <summary>
/// Global error catalog management.
/// GET is open to all authenticated users; POST and PUT require Admin role.
/// </summary>
[ApiController]
[Authorize(Policy = "DataAccess")]
[Route("api/v1/error-catalog")]
public sealed class ErrorCatalogController : ControllerBase
{
private readonly IErrorCatalogService _service;

public ErrorCatalogController(IErrorCatalogService service) => _service = service;

/// <summary>Returns all error catalog entries ordered by code.</summary>
// GET /api/v1/error-catalog
[HttpGet]
public async Task<IActionResult> GetAll(CancellationToken ct)
{
var items = await _service.GetAllAsync(ct);
return Ok(ApiResponse<List<ErrorCatalogDto>>.Ok(items));
}

/// <summary>Creates a new error catalog entry. Admin only.</summary>
// POST /api/v1/error-catalog
[HttpPost]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Create(
[FromBody] CreateErrorCatalogDto dto, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(dto.ErrorCode) || string.IsNullOrWhiteSpace(dto.Description))
return BadRequest(ApiResponse<ErrorCatalogDto>.Fail(
"VALIDATION", "ErrorCode and Description are required."));

var result = await _service.CreateAsync(dto, ct);

if (result is null)
return Conflict(ApiResponse<ErrorCatalogDto>.Fail(
"DUPLICATE_CODE", $"Error code '{dto.ErrorCode.Trim().ToUpperInvariant()}' already exists."));

return Ok(ApiResponse<ErrorCatalogDto>.Ok(result));
}

/// <summary>Updates description and remediation of an existing entry. Admin only.</summary>
// PUT /api/v1/error-catalog/{id}
[HttpPut("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Update(
Guid id, [FromBody] UpdateErrorCatalogDto dto, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(dto.Description))
return BadRequest(ApiResponse<ErrorCatalogDto>.Fail(
"VALIDATION", "Description is required."));

var result = await _service.UpdateAsync(id, dto, ct);

if (result is null)
return NotFound(ApiResponse<ErrorCatalogDto>.Fail(
"NOT_FOUND", "Error catalog entry not found."));

return Ok(ApiResponse<ErrorCatalogDto>.Ok(result));
}

// DELETE /api/v1/error-catalog/{id}
[HttpDelete("{id:guid}")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var deleted = await _service.DeleteAsync(id, ct);
if (!deleted)
return NotFound(ApiResponse<object>.Fail("NOT_FOUND", "Entry not found."));
return Ok(ApiResponse<object>.Ok(new { deleted = true }));
}

}
27 changes: 27 additions & 0 deletions DocAnalytics.Api/Controllers/FilesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,32 @@ public async Task<IActionResult> GetLogs(Guid id, CancellationToken ct)
return File(Encoding.UTF8.GetBytes(log.Content), "text/plain", log.FileName);
}

/// <summary>
/// Resets a Failed file to Queued and re-enqueues it for processing.
/// Admin-only — stacks with the class-level DataAccess policy.
/// </summary>
// POST /api/v1/files/{id}/retry
[HttpPost("{id:guid}/retry")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> RetryFile(Guid id, CancellationToken ct)
{
try
{
var result = await _service.RetryFileAsync(id, ct);

if (result is null)
return NotFound(
ApiResponse<RetryFileResponseDto>.Fail(
"NOT_FOUND", "File not found or access denied."));

return Ok(ApiResponse<RetryFileResponseDto>.Ok(result));
}
catch (InvalidOperationException ex)
{
return BadRequest(
ApiResponse<RetryFileResponseDto>.Fail("INVALID_STATE", ex.Message));
}
}


}
1 change: 1 addition & 0 deletions DocAnalytics.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
builder.Services.AddDashboardFeature();
builder.Services.AddInvoiceFeature();
builder.Services.AddFileDetailsFeature();
builder.Services.AddErrorCatalogFeature();
builder.Services.AddAnalyticsFeature();
builder.Services.AddErrorListFeature();
builder.Services.AddActivityLogFeature();
Expand Down
15 changes: 8 additions & 7 deletions DocAnalytics.Service.Tests/Errors/ErrorServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@
using DocAnalytics.Service.Errors;
using DocAnalytics.Service.Tests.Support;
using MockQueryable.Moq;
using ErrorCatalogEntry = DocAnalytics.Domain.Entities.ErrorCatalog;

namespace DocAnalytics.Service.Tests.Errors;

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

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

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

var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalog>());
var sut = BuildSut(files, steps, txns, Array.Empty<ErrorCatalogEntry>());

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

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

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

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

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

Assert.Equal(2, (await sut.GetErrorsForExportAsync(new ErrorListQuery())).Count);
}
Expand Down
74 changes: 61 additions & 13 deletions DocAnalytics.Service.Tests/Files/FileDetailsServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
using DocAnalytics.Domain.Common;
using DocAnalytics.Domain.Entities;
using DocAnalytics.Service.Extraction;
using DocAnalytics.Service.Files;
using DocAnalytics.Service.Tests.Support;
using MockQueryable.Moq;
using Moq;
using ErrorCatalogEntry = DocAnalytics.Domain.Entities.ErrorCatalog;



namespace DocAnalytics.Service.Tests.Files;

public class FileDetailsServiceTests
{
// ── helpers ────────────────────────────────────────────────────────────

private static Mock<DocAnalytics.Data.AppDbContext> Ctx(
FileRecord[] files, FileStepHistory[] steps, ErrorCatalog[] catalog)
FileRecord[] files, FileStepHistory[] steps, ErrorCatalogEntry[] catalog)
{
var ctx = MockDb.Create();
ctx.Setup(c => c.Files).Returns(files.ToList().BuildMockDbSet().Object);
Expand All @@ -18,54 +25,95 @@ public class FileDetailsServiceTests
return ctx;
}

// Wrap construction so existing tests don't need to know about the new deps
// (IExtractionQueue + ICurrentUser are only used by RetryFileAsync, not these tests)
private static FileDetailsService Svc(Mock<DocAnalytics.Data.AppDbContext> ctx) =>
new(ctx.Object,
new Mock<IExtractionQueue>().Object,
new Mock<ICurrentUser>().Object);

// ── tests ───────────────────────────────────────────────────────────────

[Fact]
public async Task GetFileDetailsAsync_returns_null_when_file_missing()
{
var sut = new FileDetailsService(Ctx(Array.Empty<FileRecord>(), Array.Empty<FileStepHistory>(), Array.Empty<ErrorCatalog>()).Object);
var sut = Svc(Ctx(Array.Empty<FileRecord>(),
Array.Empty<FileStepHistory>(),
Array.Empty<ErrorCatalogEntry>()));
Assert.Null(await sut.GetFileDetailsAsync(Guid.NewGuid()));
}

[Fact]
public async Task GetFileDetailsAsync_maps_history_with_remediation()
{
var fileId = Guid.NewGuid();
var files = new[] { new FileRecord { Id = fileId, FileName = "a.pdf", Status = "Failed", CurrentStep = "Validate" } };
var files = new[]
{
new FileRecord
{
Id = fileId, FileName = "a.pdf",
Status = "Failed", CurrentStep = "Validate",
},
};
var steps = new[]
{
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Upload", Status = "Success", StartedAt = DateTime.UtcNow.AddMinutes(-2) },
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad", StartedAt = DateTime.UtcNow.AddMinutes(-1) },
new FileStepHistory
{
Id = Guid.NewGuid(), FileId = fileId, StepName = "Upload",
Status = "Success", StartedAt = DateTime.UtcNow.AddMinutes(-2),
},
new FileStepHistory
{
Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate",
Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad",
StartedAt = DateTime.UtcNow.AddMinutes(-1),
},
};
var catalog = new[] { new ErrorCatalog { ErrorCode = "E1", RemediationMsg = "Fix it" } };
var catalog = new[] { new ErrorCatalogEntry { ErrorCode = "E1", RemediationMsg = "Fix it" } };

var dto = await new FileDetailsService(Ctx(files, steps, catalog).Object).GetFileDetailsAsync(fileId);
var dto = await Svc(Ctx(files, steps, catalog)).GetFileDetailsAsync(fileId);

Assert.NotNull(dto);
Assert.Equal("a.pdf", dto!.FileInfo.Name);
Assert.Equal(2, dto.History.Count);
var failed = dto.History.Single(h => h.Step == "Validate");
Assert.Equal("Fix it", failed.Error!.SuggestedFix);
Assert.Null(dto.History.Single(h => h.Step == "Upload").Error); // success → no error block
Assert.Null(dto.History.Single(h => h.Step == "Upload").Error);
}

[Fact]
public async Task GetFileLogsAsync_returns_null_when_file_missing()
{
var sut = new FileDetailsService(Ctx(Array.Empty<FileRecord>(), Array.Empty<FileStepHistory>(), Array.Empty<ErrorCatalog>()).Object);
var sut = Svc(Ctx(Array.Empty<FileRecord>(),
Array.Empty<FileStepHistory>(),
Array.Empty<ErrorCatalogEntry>()));
Assert.Null(await sut.GetFileLogsAsync(Guid.NewGuid()));
}

[Fact]
public async Task GetFileLogsAsync_builds_downloadable_log()
{
var fileId = Guid.NewGuid();
var files = new[] { new FileRecord { Id = fileId, FileName = "a.pdf", Status = "Failed", CurrentStep = "Validate" } };
var files = new[]
{
new FileRecord
{
Id = fileId, FileName = "a.pdf",
Status = "Failed", CurrentStep = "Validate",
},
};
var steps = new[]
{
new FileStepHistory { Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate", Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad", StartedAt = DateTime.UtcNow },
new FileStepHistory
{
Id = Guid.NewGuid(), FileId = fileId, StepName = "Validate",
Status = "Failed", ErrorCode = "E1", ErrorMessage = "bad",
StartedAt = DateTime.UtcNow,
},
};
var catalog = new[] { new ErrorCatalog { ErrorCode = "E1", RemediationMsg = "Fix it" } };
var catalog = new[] { new ErrorCatalogEntry { ErrorCode = "E1", RemediationMsg = "Fix it" } };

var log = await new FileDetailsService(Ctx(files, steps, catalog).Object).GetFileLogsAsync(fileId);
var log = await Svc(Ctx(files, steps, catalog)).GetFileLogsAsync(fileId);

Assert.NotNull(log);
Assert.Equal($"file_{fileId}_log.txt", log!.FileName);
Expand Down
Loading
Loading