using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FamilyNido.Domain.HouseholdTasks;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
namespace FamilyNido.Tests.Integration;
///
/// End-to-end coverage for the public API surface under /api/v1/**:
/// generic task creation by integrations holding a valid API key.
///
public sealed class PublicApiTests : IntegrationTestBase
{
public PublicApiTests(IntegrationFixture fixture) : base(fixture) { }
private async Task<(TestSeed.FamilyHandle handle, string token)> SeedAndIssueTokenAsync(
string tokenName = "Test integration")
{
var handle = await WithDbAsync(db =>
TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services));
await TestSeed.LoginAsync(Client, "dan@example.com");
var resp = await Client.PostAsJsonAsync("/api/integrations/api-keys", new { name = tokenName });
resp.IsSuccessStatusCode.Should().BeTrue();
var created = await ReadAsync(resp);
return (handle, created!.Token);
}
/// Issue a request that exercises the integration API-key path only (no cookie).
private async Task PostTaskAsync(string token, object body)
{
using var anon = Fixture.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
HandleCookies = false,
AllowAutoRedirect = false,
});
anon.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await anon.PostAsJsonAsync("/api/v1/tasks", body);
}
[Fact]
public async Task Post_without_auth_returns_401()
{
await SeedAndIssueTokenAsync();
var resp = await Client.PostAsJsonAsync("/api/v1/tasks", new { title = "test" });
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Post_with_unknown_token_returns_401()
{
var resp = await PostTaskAsync("bxn_thisisnotreal", new { title = "test" });
resp.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Post_minimal_body_creates_a_floating_chore_today()
{
var (handle, token) = await SeedAndIssueTokenAsync();
var resp = await PostTaskAsync(token, new { title = "Comprar pan" });
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var body = await ReadAsync(resp);
body!.Created.Should().BeTrue();
body.Title.Should().Be("Comprar pan");
var saved = await WithDbAsync(db => db.HouseholdTasks.SingleAsync());
saved.FamilyId.Should().Be(handle.Family.Id);
saved.CreatedByMemberId.Should().Be(handle.Member.Id);
saved.Title.Should().Be("Comprar pan");
saved.Category.Should().Be("General");
saved.Points.Should().Be(5);
saved.IsFloating.Should().BeFalse();
// Single-shot non-floating task with no DueDate supplied → falls back to today.
saved.DueDate.Should().Be(DateOnly.FromDateTime(DateTime.UtcNow));
saved.Recurrence.Should().Be(RecurrenceMode.None);
saved.ResponsibleMemberId.Should().BeNull();
}
[Fact]
public async Task Post_with_isFloating_true_creates_a_floating_task()
{
var (_, token) = await SeedAndIssueTokenAsync();
var resp = await PostTaskAsync(token, new
{
title = "Vaciar el lavavajillas",
category = "Hogar",
points = 3,
isFloating = true,
});
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var saved = await WithDbAsync(db => db.HouseholdTasks.SingleAsync());
saved.Category.Should().Be("Hogar");
saved.Points.Should().Be(3);
saved.IsFloating.Should().BeTrue();
saved.DueDate.Should().BeNull();
}
[Fact]
public async Task Post_with_explicit_dueDate_honours_it()
{
var (_, token) = await SeedAndIssueTokenAsync();
var due = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(3);
var resp = await PostTaskAsync(token, new
{
title = "Renovar DNI",
dueDate = due.ToString("yyyy-MM-dd"),
});
resp.StatusCode.Should().Be(HttpStatusCode.Created);
var saved = await WithDbAsync(db => db.HouseholdTasks.SingleAsync());
saved.DueDate.Should().Be(due);
saved.IsFloating.Should().BeFalse();
}
[Fact]
public async Task Post_with_deduplicate_true_returns_200_on_second_call()
{
var (_, token) = await SeedAndIssueTokenAsync();
var first = await PostTaskAsync(token, new
{
title = "Vaciar el lavavajillas",
isFloating = true,
deduplicate = true,
});
first.StatusCode.Should().Be(HttpStatusCode.Created);
var firstBody = await ReadAsync(first);
var second = await PostTaskAsync(token, new
{
title = "Vaciar el lavavajillas",
isFloating = true,
deduplicate = true,
});
second.StatusCode.Should().Be(HttpStatusCode.OK);
var secondBody = await ReadAsync(second);
secondBody!.Created.Should().BeFalse();
secondBody.Reason.Should().Be("already-pending");
secondBody.TaskId.Should().Be(firstBody!.TaskId);
var count = await WithDbAsync(db => db.HouseholdTasks.CountAsync());
count.Should().Be(1);
}
[Fact]
public async Task Post_with_deduplicate_creates_new_task_after_previous_was_completed()
{
var (handle, token) = await SeedAndIssueTokenAsync();
await PostTaskAsync(token, new
{
title = "Tender la lavadora",
isFloating = true,
deduplicate = true,
});
// Mark the floating task done so its "pending" status flips.
await WithDbAsync(async db =>
{
var task = await db.HouseholdTasks.SingleAsync();
db.TaskCompletions.Add(new TaskCompletion
{
TaskId = task.Id,
CompletedByMemberId = handle.Member.Id,
OccurrenceDate = DateOnly.FromDateTime(DateTime.UtcNow),
CompletedAt = DateTimeOffset.UtcNow,
});
await db.SaveChangesAsync();
});
var second = await PostTaskAsync(token, new
{
title = "Tender la lavadora",
isFloating = true,
deduplicate = true,
});
second.StatusCode.Should().Be(HttpStatusCode.Created);
var count = await WithDbAsync(db => db.HouseholdTasks.CountAsync());
count.Should().Be(2);
}
[Fact]
public async Task Post_with_deduplicate_false_always_creates_a_new_row()
{
var (_, token) = await SeedAndIssueTokenAsync();
await PostTaskAsync(token, new { title = "Recoger paquete", isFloating = true });
await PostTaskAsync(token, new { title = "Recoger paquete", isFloating = true });
var count = await WithDbAsync(db => db.HouseholdTasks.CountAsync());
count.Should().Be(2);
}
[Fact]
public async Task Post_with_responsibleMemberId_outside_the_family_returns_400()
{
var (_, token) = await SeedAndIssueTokenAsync();
var foreignMemberId = Guid.NewGuid();
var resp = await PostTaskAsync(token, new
{
title = "Recoger paquete",
isFloating = true,
responsibleMemberId = foreignMemberId,
});
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Post_with_invalid_points_returns_400()
{
var (_, token) = await SeedAndIssueTokenAsync();
var resp = await PostTaskAsync(token, new { title = "x", points = -5 });
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Post_with_empty_title_returns_400()
{
var (_, token) = await SeedAndIssueTokenAsync();
var resp = await PostTaskAsync(token, new { title = "" });
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
private sealed record CreatedKeyDto(string Token, KeyDto Key);
private sealed record KeyDto(
Guid Id,
string Name,
string Prefix,
DateTimeOffset CreatedAt,
DateTimeOffset? LastUsedAt,
DateTimeOffset? RevokedAt);
private sealed record TaskResponse(bool Created, string? Reason, Guid TaskId, string Title);
}