using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Data.Sqlite; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class ApplicationCalendarControllerTests { [Fact] public async Task Agenda_date_window_translates_on_sqlite_and_keeps_legacy_wall_clock_date() { await using var connection = new SqliteConnection("Data Source=:memory:"); await connection.OpenAsync(); var current = new Mock(); current.SetupGet(service => service.UserId).Returns("owner-1"); await using var db = new JobTrackerContext(new DbContextOptionsBuilder().UseSqlite(connection).Options, current.Object); await db.Database.EnsureCreatedAsync(); var job = await SeedJobAsync(db, "owner-1"); job.Status = "Saved"; job.FollowUpAt = null; job.Deadline = new DateTime(2026, 9, 30, 23, 59, 59); await db.SaveChangesAsync(); db.ChangeTracker.Clear(); var controller = CreateController(db, "owner-1", new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object); var result = await controller.Agenda(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), default); var row = Assert.Single(Assert.IsAssignableFrom>(Assert.IsType(result.Result).Value)); Assert.Equal("2026-09-30", row.Date); Assert.Equal(new DateTime(2026, 9, 30, 23, 59, 59), row.RecordedAt); } [Fact] public async Task Agenda_includes_saved_deadlines_and_preserves_recorded_dates_without_provider_access() { await using var db = CreateDb("owner-1"); var job = await SeedJobAsync(db, "owner-1"); job.Status = "Saved"; job.FollowUpAt = new DateTime(2026, 9, 2, 0, 0, 0, DateTimeKind.Unspecified); job.Deadline = new DateTime(2026, 9, 15, 23, 30, 0, DateTimeKind.Unspecified); await db.SaveChangesAsync(); var controller = CreateController(db, "owner-1", new Mock(MockBehavior.Strict).Object, new Mock(MockBehavior.Strict).Object); var response = await controller.Agenda(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), default); var rows = Assert.IsAssignableFrom>(Assert.IsType(response.Result).Value).ToList(); Assert.Equal(2, rows.Count); Assert.Equal("follow-up", rows[0].Kind); Assert.Equal("2026-09-02", rows[0].Date); Assert.Equal("deadline", rows[1].Kind); Assert.Equal("2026-09-15", rows[1].Date); Assert.Equal(job.Deadline, rows[1].RecordedAt); Assert.Equal(DateTimeKind.Unspecified, rows[1].RecordedAt.Kind); Assert.All(rows, row => { Assert.Equal(job.Id, row.JobApplicationId); Assert.Equal("Acme", row.CompanyName); }); } [Fact] public async Task Agenda_excludes_other_owners_deleted_and_out_of_range_dates() { await using var db = CreateDb("owner-1"); await SeedJobAsync(db, "owner-2"); var deleted = await SeedJobAsync(db, "owner-1"); deleted.IsDeleted = true; var outside = await SeedJobAsync(db, "owner-1"); outside.FollowUpAt = new DateTime(2026, 8, 31); outside.Deadline = new DateTime(2026, 10, 1); var edge = await SeedJobAsync(db, "owner-1"); edge.FollowUpAt = null; edge.Deadline = new DateTime(2026, 9, 30, 23, 59, 59); await db.SaveChangesAsync(); var controller = CreateController(db, "owner-1", Mock.Of(), Mock.Of()); var response = await controller.Agenda(new DateOnly(2026, 9, 1), new DateOnly(2026, 9, 30), default); var rows = Assert.IsAssignableFrom>(Assert.IsType(response.Result).Value); Assert.Equal(edge.Id, Assert.Single(rows).JobApplicationId); } [Theory] [InlineData("2026-09-30", "2026-09-01")] [InlineData("2026-01-01", "2026-12-31")] [InlineData("9999-12-01", "9999-12-31")] public async Task Agenda_rejects_unbounded_or_invalid_ranges(string from, string to) { await using var db = CreateDb("owner-1"); var controller = CreateController(db, "owner-1", Mock.Of(), Mock.Of()); var response = await controller.Agenda(DateOnly.Parse(from), DateOnly.Parse(to), default); Assert.IsType(response.Result); } [Fact] public async Task Status_distinguishes_connection_from_calendar_consent() { await using var db = CreateDb("owner-1"); var google = new Mock(); google.Setup(service => service.GetConnectionAsync("owner-1", It.IsAny())) .ReturnsAsync(new GmailConnection { OwnerUserId = "owner-1", Scope = GmailOAuthService.CalendarScope }); var microsoft = new Mock(); microsoft.Setup(service => service.GetConnectionAsync("owner-1", It.IsAny())) .ReturnsAsync(new MicrosoftGraphConnection { OwnerUserId = "owner-1", Scope = "Mail.Read" }); var controller = CreateController(db, "owner-1", google.Object, microsoft.Object); var value = Assert.IsType(Assert.IsType((await controller.Status(default)).Result).Value); Assert.True(value.Google.Connected); Assert.True(value.Google.Writable); Assert.True(value.Microsoft.Connected); Assert.False(value.Microsoft.Writable); } [Fact] public async Task Creates_follow_up_in_selected_connected_calendar_with_stable_job_context() { await using var db = CreateDb("owner-1"); var job = await SeedJobAsync(db, "owner-1"); ExternalCalendarEventRequest? sent = null; var google = new Mock(); google.Setup(service => service.CreateCalendarEventAsync("owner-1", It.IsAny(), It.IsAny())) .Callback((_, request, _) => sent = request) .ReturnsAsync(new ExternalCalendarEventResult("event-1", "https://calendar.google.test/event-1")); var controller = CreateController(db, "owner-1", google.Object, Mock.Of()); var response = await controller.CreateEvent(job.Id, new("google", "follow-up"), default); var value = Assert.IsType(Assert.IsType(response.Result).Value); Assert.Equal("google", value.Provider); Assert.Equal("event-1", value.Id); Assert.NotNull(sent); Assert.Equal("Follow up: Backend Engineer at Acme", sent.Summary); Assert.Equal(job.FollowUpAt, sent.StartsAtUtc.UtcDateTime); Assert.False(sent.AllDay); Assert.Equal(64, sent.StableId.Length); } [Fact] public async Task Rejects_another_owners_job_without_calling_provider() { await using var db = CreateDb("owner-1"); var job = await SeedJobAsync(db, "owner-2"); var google = new Mock(MockBehavior.Strict); var controller = CreateController(db, "owner-1", google.Object, Mock.Of()); var response = await controller.CreateEvent(job.Id, new("google", "follow-up"), default); Assert.IsType(response.Result); } [Fact] public async Task Downloads_portable_all_day_deadline_event_without_provider_credentials() { await using var db = CreateDb("owner-1"); var job = await SeedJobAsync(db, "owner-1"); var controller = CreateController(db, "owner-1", Mock.Of(), Mock.Of()); var response = Assert.IsType(await controller.DownloadEvent(job.Id, "deadline", default)); var content = System.Text.Encoding.UTF8.GetString(response.FileContents); Assert.Equal("text/calendar; charset=utf-8", response.ContentType); Assert.Contains("DTSTART;VALUE=DATE:20260915", content); Assert.Contains("DTEND;VALUE=DATE:20260916", content); Assert.Contains("SUMMARY:Application deadline: Backend Engineer at Acme", content); } private static JobTrackerContext CreateDb(string owner) { var current = new Mock(); current.SetupGet(service => service.UserId).Returns(owner); return new JobTrackerContext(new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object); } private static async Task SeedJobAsync(JobTrackerContext db, string owner) { var company = new Company { OwnerUserId = owner, Name = "Acme" }; var job = new JobApplication { OwnerUserId = owner, Company = company, JobTitle = "Backend Engineer", Status = "Applied", FollowUpAt = new DateTime(2026, 9, 2, 10, 30, 0, DateTimeKind.Utc), Deadline = new DateTime(2026, 9, 15, 0, 0, 0, DateTimeKind.Utc), Location = "Oslo" }; db.JobApplications.Add(job); await db.SaveChangesAsync(); return job; } private static ApplicationCalendarController CreateController(JobTrackerContext db, string owner, IGmailOAuthService google, IMicrosoftGraphOAuthService microsoft) { var current = new Mock(); current.SetupGet(service => service.UserId).Returns(owner); return new ApplicationCalendarController(db, current.Object, google, microsoft); } }