using System.Net; using System.Net.Http.Json; using FamilyNido.Domain.HouseholdTasks; using FluentAssertions; using Microsoft.EntityFrameworkCore; namespace FamilyNido.Tests.Integration; /// /// CRUD + completion-state coverage for /api/household-tasks. Hits create, /// list, today/week views, complete/undo, archive/restore, deadline ranges /// and floating tasks. /// public sealed class HouseholdTaskTests : IntegrationTestBase { public HouseholdTaskTests(IntegrationFixture fixture) : base(fixture) { } private async Task SeedAdminAsync() { var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync(db, Fixture.Factory.Services)); await TestSeed.LoginAsync(Client, "dan@example.com"); return handle; } private static object NewTaskBody( string title, string recurrence = "None", DateOnly? startDate = null, DateOnly? dueDate = null, bool isFloating = false, int points = 5) { return new { title, description = (string?)null, category = (string?)"General", recurrence, weeklyDays = (string?)null, monthlyDay = (int?)null, timeOfDay = (string?)null, startDate = (startDate ?? DateOnly.FromDateTime(DateTime.UtcNow)).ToString("yyyy-MM-dd"), dueDate = dueDate?.ToString("yyyy-MM-dd"), responsibleMemberId = (Guid?)null, relatedMemberIds = Array.Empty(), isFloating, points, }; } [Fact] public async Task Create_persists_a_one_off_task() { await SeedAdminAsync(); var response = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Comprar pan")); response.StatusCode.Should().Be(HttpStatusCode.Created); var saved = await WithDbAsync(db => db.HouseholdTasks.Where(t => t.Title == "Comprar pan").FirstAsync()); saved.Recurrence.Should().Be(RecurrenceMode.None); saved.Points.Should().Be(5); saved.IsArchived.Should().BeFalse(); } [Fact] public async Task Create_rejects_points_out_of_range() { await SeedAdminAsync(); var response = await Client.PostAsJsonAsync( "/api/household-tasks/", NewTaskBody("Pintar", points: 99)); response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] public async Task List_returns_only_non_archived_by_default_but_includes_with_flag() { await SeedAdminAsync(); // Create one normal + one to-be-archived. await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Tarea viva")); var archivedResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Tarea archivada")); var archivedId = (await ReadAsync(archivedResp))!.Id; await Client.PostAsync($"/api/household-tasks/{archivedId}/archive", content: null); var defaultResp = await Client.GetAsync("/api/household-tasks/"); var defaultList = await ReadAsync>(defaultResp); defaultList!.Select(t => t.Title).Should().BeEquivalentTo(["Tarea viva"]); var allResp = await Client.GetAsync("/api/household-tasks/?includeArchived=true"); var allList = await ReadAsync>(allResp); allList!.Select(t => t.Title).Should().BeEquivalentTo(["Tarea viva", "Tarea archivada"]); } [Fact] public async Task Complete_and_undo_round_trips_persistently() { await SeedAdminAsync(); var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Sacar la basura")); var task = (await ReadAsync(createdResp))!; var done = await Client.PostAsync($"/api/household-tasks/{task.Id}/occurrences/{today}/complete", content: null); done.StatusCode.Should().Be(HttpStatusCode.OK); var afterDone = await WithDbAsync(db => db.TaskCompletions.AnyAsync(c => c.TaskId == task.Id)); afterDone.Should().BeTrue(); var undone = await Client.PostAsync($"/api/household-tasks/{task.Id}/occurrences/{today}/undo", content: null); undone.StatusCode.Should().Be(HttpStatusCode.OK); var afterUndone = await WithDbAsync(db => db.TaskCompletions.AnyAsync(c => c.TaskId == task.Id)); afterUndone.Should().BeFalse(); } [Fact] public async Task Today_view_surfaces_tasks_due_today_and_floating() { await SeedAdminAsync(); var today = DateOnly.FromDateTime(DateTime.UtcNow); // One due today, one floating, one strictly in the future. The future // task uses startDate == dueDate so it doesn't trigger deadline-range // semantics (which would surface it every day in the window). await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Hoy", dueDate: today)); await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Flotante", isFloating: true)); var futureDate = today.AddDays(1); await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody( "Mañana", startDate: futureDate, dueDate: futureDate)); var resp = await Client.GetAsync("/api/household-tasks/today"); var day = await ReadAsync(resp); var titles = day!.Tasks.Select(t => t.Task.Title).ToList(); titles.Should().Contain(["Hoy", "Flotante"]); titles.Should().NotContain("Mañana"); } [Fact] public async Task Deadline_range_appears_every_day_until_completed() { await SeedAdminAsync(); var today = DateOnly.FromDateTime(DateTime.UtcNow); // StartDate < DueDate triggers the deadline-range semantics. await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody( "Lavar el coche", startDate: today, dueDate: today.AddDays(3))); // Today: shows up. var dayResp = await Client.GetAsync("/api/household-tasks/today"); var day = await ReadAsync(dayResp); day!.Tasks.Should().Contain(t => t.Task.Title == "Lavar el coche"); // Complete the occurrence: from then on it must not appear in the future. var task = day.Tasks.First(t => t.Task.Title == "Lavar el coche").Task; await Client.PostAsync( $"/api/household-tasks/{task.Id}/occurrences/{today:yyyy-MM-dd}/complete", content: null); // Same query again — gone everywhere because deadline-range tasks are // "done forever" once any completion exists, exactly like floating. var afterResp = await Client.GetAsync("/api/household-tasks/today"); var after = await ReadAsync(afterResp); after!.Tasks.Should().NotContain(t => t.Task.Title == "Lavar el coche"); } [Fact] public async Task Archive_then_restore_round_trips() { await SeedAdminAsync(); var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Cositas")); var task = (await ReadAsync(createdResp))!; var archive = await Client.PostAsync($"/api/household-tasks/{task.Id}/archive", content: null); archive.StatusCode.Should().Be(HttpStatusCode.OK); (await WithDbAsync(db => db.HouseholdTasks.Where(t => t.Id == task.Id).Select(t => t.IsArchived).FirstAsync())) .Should().BeTrue(); var restore = await Client.PostAsync($"/api/household-tasks/{task.Id}/restore", content: null); restore.StatusCode.Should().Be(HttpStatusCode.OK); (await WithDbAsync(db => db.HouseholdTasks.Where(t => t.Id == task.Id).Select(t => t.IsArchived).FirstAsync())) .Should().BeFalse(); } [Fact] public async Task Admin_can_reattribute_a_completed_occurrence() { var admin = await SeedAdminAsync(); var other = await WithDbAsync(db => TestSeed.SeedMemberAsync( db, admin.Family.Id, "María", FamilyNido.Domain.Families.MemberType.Adult)); var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Tirar la basura")); var task = (await ReadAsync(createdResp))!; // Admin completes the task themselves first. var done = await Client.PostAsync($"/api/household-tasks/{task.Id}/occurrences/{today}/complete", content: null); done.StatusCode.Should().Be(HttpStatusCode.OK); // Then re-attributes the completion to María. var put = await Client.PutAsJsonAsync( $"/api/household-tasks/{task.Id}/occurrences/{today}/completion", new { completedByMemberId = other.Id, note = (string?)null }); put.StatusCode.Should().Be(HttpStatusCode.OK); var stored = await WithDbAsync(db => db.TaskCompletions.AsNoTracking().FirstAsync(c => c.TaskId == task.Id)); stored.CompletedByMemberId.Should().Be(other.Id); } [Fact] public async Task Admin_can_create_a_completion_attributed_to_another_member() { var admin = await SeedAdminAsync(); var other = await WithDbAsync(db => TestSeed.SeedMemberAsync( db, admin.Family.Id, "María", FamilyNido.Domain.Families.MemberType.Adult)); var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Recoger paquete")); var task = (await ReadAsync(createdResp))!; // No prior completion — PUT should upsert one attributed to María. var put = await Client.PutAsJsonAsync( $"/api/household-tasks/{task.Id}/occurrences/{today}/completion", new { completedByMemberId = other.Id, note = (string?)null }); put.StatusCode.Should().Be(HttpStatusCode.OK); var stored = await WithDbAsync(db => db.TaskCompletions.AsNoTracking().FirstAsync(c => c.TaskId == task.Id)); stored.CompletedByMemberId.Should().Be(other.Id); } [Fact] public async Task Non_admin_cannot_reattribute_a_completion() { // Seed a family with an adult (non-admin) caller. var handle = await WithDbAsync(db => TestSeed.SeedFamilyAsync( db, Fixture.Factory.Services, email: "adult@example.com", displayName: "Adulto", role: FamilyNido.Domain.Families.FamilyRole.Adult)); await TestSeed.LoginAsync(Client, "adult@example.com"); var other = await WithDbAsync(db => TestSeed.SeedMemberAsync( db, handle.Family.Id, "María", FamilyNido.Domain.Families.MemberType.Adult)); var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Limpiar baño")); var task = (await ReadAsync(createdResp))!; var put = await Client.PutAsJsonAsync( $"/api/household-tasks/{task.Id}/occurrences/{today}/completion", new { completedByMemberId = other.Id, note = (string?)null }); put.StatusCode.Should().Be(HttpStatusCode.Forbidden); } [Fact] public async Task Admin_attribution_to_unknown_member_returns_400() { await SeedAdminAsync(); var today = DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"); var createdResp = await Client.PostAsJsonAsync("/api/household-tasks/", NewTaskBody("Pasar aspirador")); var task = (await ReadAsync(createdResp))!; var put = await Client.PutAsJsonAsync( $"/api/household-tasks/{task.Id}/occurrences/{today}/completion", new { completedByMemberId = Guid.NewGuid(), note = (string?)null }); put.StatusCode.Should().Be(HttpStatusCode.BadRequest); } [Fact] public async Task Completions_history_returns_entries_sorted_descending() { var admin = await SeedAdminAsync(); var other = await WithDbAsync(db => TestSeed.SeedMemberAsync( db, admin.Family.Id, "María", FamilyNido.Domain.Families.MemberType.Adult)); // Daily task that started 5 days ago so the historical dates we // attribute below are within the schedule (HasOccurrenceOn rejects // anything before StartDate). var startDate = DateOnly.FromDateTime(DateTime.UtcNow).AddDays(-5); var createdResp = await Client.PostAsJsonAsync( "/api/household-tasks/", NewTaskBody("Sacar al perro", recurrence: "Daily", startDate: startDate)); var task = (await ReadAsync(createdResp))!; // Three days, three different completers — checks ordering and // attribution round-trip. var d0 = DateOnly.FromDateTime(DateTime.UtcNow); var d1 = d0.AddDays(-1); var d2 = d0.AddDays(-2); foreach (var (date, memberId) in new[] { (d2, admin.Member.Id), (d1, other.Id), (d0, admin.Member.Id), }) { var put = await Client.PutAsJsonAsync( $"/api/household-tasks/{task.Id}/occurrences/{date:yyyy-MM-dd}/completion", new { completedByMemberId = memberId, note = (string?)null }); put.StatusCode.Should().Be(HttpStatusCode.OK); } var historyResp = await Client.GetAsync($"/api/household-tasks/{task.Id}/completions"); historyResp.StatusCode.Should().Be(HttpStatusCode.OK); var history = (await ReadAsync>(historyResp))!; history.Should().HaveCount(3); history[0].OccurrenceDate.Should().Be(d0.ToString("yyyy-MM-dd")); history[1].OccurrenceDate.Should().Be(d1.ToString("yyyy-MM-dd")); history[2].OccurrenceDate.Should().Be(d2.ToString("yyyy-MM-dd")); history[0].CompletedByMemberId.Should().Be(admin.Member.Id); history[1].CompletedByMemberId.Should().Be(other.Id); } private sealed record CompletionEntry(string OccurrenceDate, Guid? CompletedByMemberId, string CompletedAt, string? Note); /// Compact projection for deserialising task DTOs returned by the API. private sealed record TaskRow(Guid Id, string Title, int Points); /// "Today" view shape returned by /api/household-tasks/today. private sealed record DayTasks(string Date, List Tasks); private sealed record TaskOnDate(TaskRow Task, TaskOccurrence Occurrence); private sealed record TaskOccurrence(string TaskId, string OccurrenceDate, bool IsCompleted); }