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
14 changes: 12 additions & 2 deletions DEUDA-TECNICA.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ solo lee la primera página de `orders.json` (`limit=250`).
- **Cuándo abordarla:** antes de apuntar a una tienda con más de 250 pedidos relevantes. La
paginación de la Admin REST se hace por la cabecera `Link: <…>; rel="next"`; hay que
iterar siguiendo ese cursor y respetar el rate-limit (40 calls/app/store en buckets de
leaky-bucket). Misma iteración que el TODO de `Retry-After` en 429.
leaky-bucket), respetando `Retry-After` cuando devuelva 429.

### 6. Refresco proactivo del token de Shopify

Expand Down Expand Up @@ -109,6 +109,16 @@ No hay middleware de manejo de errores en [`Program.cs`](backend/Program.cs).
- **Cuándo abordarla:** al desplegar, añadir manejo de errores consistente y asegurar que
`ASPNETCORE_ENVIRONMENT` nunca es `Development` en entornos accesibles.

### 9. Paginación del adaptador SAP

[`SapODataSalesRepository`](backend/Infrastructure/Outbound/Sap/SapODataSalesRepository.cs) pide
una sola página del OData (`$top=200`), sin seguir `__next`.

- **Por qué se pospone:** la sandbox del Business Accelerator Hub devuelve un dataset acotado;
200 ítems bastan para validar el flujo end-to-end del MVP.
- **Cuándo abordarla:** al apuntar a un S/4HANA real con más volumen. OData v2 pagina por el
enlace `d.__next`; hay que iterar siguiéndolo (análogo a la paginación de Shopify, #5).

---

_Última revisión: 2026-05-24._
_Última revisión: 2026-06-02._
12 changes: 11 additions & 1 deletion backend/Infrastructure/Inbound/Http/ErrorHttpResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ public static IActionResult ToActionResult(Error error)
{
Status = status,
Title = error.Type.ToString(),
Detail = error.Message,
Detail = ClientDetail(error),
};
return new ObjectResult(problem) { StatusCode = status };
}

// Server-side failures carry upstream/infra exception text (SAP/Shopify/SQLite messages); keep
// it out of the client response (the controller logs the real detail). Client-facing errors
// (NotFound/Validation/Unauthorized) carry intentional, safe domain messages.
private static string ClientDetail(Error error) => error.Type switch
{
ErrorType.Unavailable => "The data source is currently unavailable.",
ErrorType.Unexpected => "An unexpected error occurred.",
_ => error.Message,
};
}
24 changes: 17 additions & 7 deletions backend/Infrastructure/Inbound/Http/SalesController.cs
Original file line number Diff line number Diff line change
@@ -1,39 +1,49 @@
using Microsoft.AspNetCore.Mvc;
using ConnectAnalyzer.Application;
using ConnectAnalyzer.Domain;

namespace ConnectAnalyzer.Infrastructure.Inbound.Http;

[ApiController]
[Route("api/sales")]
public sealed class SalesController(SalesAnalytics analytics, IngestSales ingest) : ControllerBase
public sealed class SalesController(
SalesAnalytics analytics,
IngestSales ingest,
ILogger<SalesController> logger) : ControllerBase
{
[HttpPost("refresh")]
public async Task<IActionResult> Refresh(CancellationToken ct)
{
var result = await ingest.ExecuteAsync(ct);
return result.Match<IActionResult>(
ingested => Ok(new { ingested }),
ErrorHttpResults.ToActionResult);
return result.Match<IActionResult>(ingested => Ok(new { ingested }), Fail);
}

[HttpGet]
public async Task<IActionResult> GetAll(CancellationToken ct)
{
var result = await analytics.GetAllAsync(ct);
return result.Match<IActionResult>(Ok, ErrorHttpResults.ToActionResult);
return result.Match<IActionResult>(Ok, Fail);
}

[HttpGet("by-product")]
public async Task<IActionResult> ByProduct(CancellationToken ct)
{
var result = await analytics.TotalsByProductAsync(ct);
return result.Match<IActionResult>(Ok, ErrorHttpResults.ToActionResult);
return result.Match<IActionResult>(Ok, Fail);
}

[HttpGet("by-customer")]
public async Task<IActionResult> ByCustomer(CancellationToken ct)
{
var result = await analytics.TotalsByCustomerAsync(ct);
return result.Match<IActionResult>(Ok, ErrorHttpResults.ToActionResult);
return result.Match<IActionResult>(Ok, Fail);
}

// Logs the real error detail server-side (it's stripped from the client response by
// ErrorHttpResults for server-side failures), then maps to the HTTP status.
private IActionResult Fail(Error error)
{
logger.LogWarning("Sales request failed: {Type} {Message}", error.Type, error.Message);
return ErrorHttpResults.ToActionResult(error);
}
}
20 changes: 19 additions & 1 deletion backend/Infrastructure/Outbound/Sap/SapODataSalesRepository.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Globalization;
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using ConnectAnalyzer.Application.Ports;
Expand All @@ -18,9 +19,21 @@ public sealed class SapODataSalesRepository(HttpClient http) : ISalesRepository

public async Task<Result<IReadOnlyList<Sale>>> SearchAsync(CancellationToken ct = default)
{
ct.ThrowIfCancellationRequested();
try
{
var json = await http.GetStringAsync(SalesItemsResource, ct);
using var response = await http.GetAsync(SalesItemsResource, ct);

// A bad/missing API key is an auth problem (401), not the source being down (502).
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
return Result<IReadOnlyList<Sale>>.Failure(
Error.Unauthorized("SAP rejected the API key (check Sap:ApiKey)."));

if (!response.IsSuccessStatusCode)
return Result<IReadOnlyList<Sale>>.Failure(
Error.Unavailable($"The SAP data source returned {(int)response.StatusCode}."));

var json = await response.Content.ReadAsStringAsync(ct);
var payload = JsonSerializer.Deserialize<ODataResponse>(json, JsonOptions);

IReadOnlyList<Sale> sales = (payload?.D?.Results ?? [])
Expand All @@ -40,6 +53,11 @@ public async Task<Result<IReadOnlyList<Sale>>> SearchAsync(CancellationToken ct
return Result<IReadOnlyList<Sale>>.Failure(
Error.Unexpected($"Malformed OData payload from the SAP data source: {ex.Message}"));
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
return Result<IReadOnlyList<Sale>>.Failure(
Error.Unavailable("SAP request timed out."));
}
}

private static Sale? TryParseSale(SalesOrderItemDto item)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

namespace ConnectAnalyzer.Infrastructure.Outbound.Shopify;

// TODO: paginate via the Link header's `rel="next"` cursor. The MVP fetches a single page of
public sealed class ShopifyOrdersRepository(
HttpClient http,
ShopifyTokenProvider tokens,
Expand Down Expand Up @@ -39,7 +38,6 @@ private async Task<Result<IReadOnlyList<Sale>>> FetchOrdersAsync(string token, C
return Result<IReadOnlyList<Sale>>.Failure(Error.Unauthorized(
"Shopify rejected the Admin API token (it may have been revoked or lack the required scopes)."));

// TODO: respect Retry-After header on 429 instead of failing immediately.
if ((int)response.StatusCode == 429)
return Result<IReadOnlyList<Sale>>.Failure(Error.Unavailable(
"Shopify rate limit hit."));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

namespace ConnectAnalyzer.Infrastructure.Outbound.Shopify;

// TODO: react to a future 401 from the data endpoint by invalidating the cached token and
public sealed class ShopifyTokenProvider(HttpClient http, string clientId, string clientSecret)
{
private readonly SemaphoreSlim _gate = new(1, 1);
Expand Down
15 changes: 6 additions & 9 deletions backend/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,22 +111,19 @@
app.MapControllers();

// Seed the local store from the configured source on startup so the dashboard isn't empty on
// first load. Runs in the background with retries: on free-tier hosting the upstream source can
// take 30-60 s to wake up from a cold start, longer than the HttpClient default, so the first
// attempt frequently lands while the source is still returning 502/timeouts. Retries with backoff
// let the store self-heal without blocking app.Run(). Skipped under "Testing" (integration tests
// wire their own store). POST /api/sales/refresh remains available as a manual retry.
// first load. Runs in the background with a short backoff to ride out a transient startup race
// (e.g. the mock waking a second after the backend on Cloud Run), without blocking app.Run().
// Skipped under "Testing" (integration tests wire their own store). POST /api/sales/refresh
// remains available as a manual retry.
if (!app.Environment.IsEnvironment("Testing"))
{
_ = Task.Run(async () =>
{
TimeSpan[] backoffs =
[
TimeSpan.Zero,
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(15),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(60),
TimeSpan.FromSeconds(3),
TimeSpan.FromSeconds(10),
];

var logger = app.Services.GetRequiredService<ILogger<Program>>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public class ErrorMappingEndpointsTests
[Theory]
[InlineData(ErrorType.NotFound, HttpStatusCode.NotFound)]
[InlineData(ErrorType.Validation, HttpStatusCode.BadRequest)]
[InlineData(ErrorType.Unauthorized, HttpStatusCode.Unauthorized)]
[InlineData(ErrorType.Unavailable, HttpStatusCode.BadGateway)]
[InlineData(ErrorType.Unexpected, HttpStatusCode.InternalServerError)]
public async Task Failure_IsTranslatedToItsHttpStatus(ErrorType type, HttpStatusCode expected)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,16 @@ public void ToActionResult_CarriesStatusAndMessageInProblemDetails()
Assert.Equal(StatusCodes.Status404NotFound, problem.Status);
Assert.Equal("customer C999 not found", problem.Detail);
}

[Fact]
public void ToActionResult_DoesNotLeakInternalDetailForServerErrors()
{
var result = ErrorHttpResults.ToActionResult(
Error.Unavailable("Could not reach the SAP data source: raw 401 text"));

var objectResult = Assert.IsType<ObjectResult>(result);
var problem = Assert.IsType<ProblemDetails>(objectResult.Value);
Assert.Equal(StatusCodes.Status502BadGateway, problem.Status);
Assert.DoesNotContain("raw 401 text", problem.Detail!);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,18 @@ public async Task ReturnsUnavailableFailureWhenSourceReturnsHttpError(HttpStatus
Assert.Equal(ErrorType.Unavailable, FailureError(result).Type);
}

[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task ReturnsUnauthorizedWhenSapRejectsTheApiKey(HttpStatusCode status)
{
var sut = CreateSut("ignored", status);

var result = await sut.SearchAsync();

Assert.Equal(ErrorType.Unauthorized, FailureError(result).Type);
}

[Fact]
public async Task ReturnsUnavailableFailureWhenSourceIsUnreachable()
{
Expand Down
Loading