diff --git a/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs b/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs index e4fdd32..b8d70c8 100644 --- a/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs +++ b/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs @@ -14,7 +14,7 @@ public sealed class JobDiscoveryControllerTests public async Task Search_filters_recent_active_nav_jobs() { const string token = "eyJ.test.token"; - var feed = """{"next_url":"","items":[{"date_modified":"2026-07-30T10:00:00Z","_feed_entry":{"uuid":"1","status":"ACTIVE","title":"Backend Developer","businessName":"Acme","municipal":"OSLO"}},{"date_modified":"2026-07-30T11:00:00Z","_feed_entry":{"uuid":"2","status":"ACTIVE","title":"Nurse","businessName":"Hospital","municipal":"BERGEN"}}]}"""; + var feed = """{"next_url":"","items":[{"date_modified":"2026-07-30T10:00:00Z","_feed_entry":{"uuid":"1","status":"ACTIVE","title":"Backend Developer","businessName":"Acme","municipal":"OSLO","applicationDue":"2026-08-15T23:59:59Z"}},{"date_modified":"2026-07-30T11:00:00Z","_feed_entry":{"uuid":"2","status":"ACTIVE","title":"Nurse","businessName":"Hospital","municipal":"BERGEN"}}]}"""; var client = new HttpClient(new Handler(request => request.RequestUri!.AbsolutePath.EndsWith("publicToken") ? token : feed)); var controller = new JobDiscoveryController(new ClientFactory(client), new ConfigurationBuilder().Build(), new MemoryCache(new MemoryCacheOptions())); @@ -23,6 +23,11 @@ public sealed class JobDiscoveryControllerTests var ok = Assert.IsType(result.Result); var job = Assert.Single(Assert.IsAssignableFrom>(ok.Value)); Assert.Equal("Backend Developer", job.Title); + Assert.Equal("nav", job.Source); + Assert.Equal("NAV Arbeidsplassen", job.SourceName); + Assert.Equal("searched", job.AcquisitionType); + Assert.Equal(new DateTimeOffset(2026, 8, 15, 23, 59, 59, TimeSpan.Zero), job.Deadline); + Assert.True(job.RetrievedAt <= DateTimeOffset.UtcNow); Assert.Equal("NO", job.CountryCode); } diff --git a/JobTrackerApi/Controllers/JobDiscoveryController.cs b/JobTrackerApi/Controllers/JobDiscoveryController.cs index 1416eb7..d0370c8 100644 --- a/JobTrackerApi/Controllers/JobDiscoveryController.cs +++ b/JobTrackerApi/Controllers/JobDiscoveryController.cs @@ -28,6 +28,7 @@ public sealed class JobDiscoveryController : ControllerBase { try { + var retrievedAt = DateTimeOffset.UtcNow; var token = await GetTokenAsync(cancellationToken); var client = _clients.CreateClient(); var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -65,8 +66,12 @@ public sealed class JobDiscoveryController : ControllerBase feed.TryGetProperty("businessName", out var company) ? company.GetString() : null, feed.TryGetProperty("municipal", out var municipal) ? municipal.GetString() : null, item.TryGetProperty("date_modified", out var modified) && modified.TryGetDateTimeOffset(out var date) ? date : null, + feed.TryGetProperty("applicationDue", out var applicationDue) && applicationDue.TryGetDateTimeOffset(out var deadline) ? deadline : null, $"https://arbeidsplassen.nav.no/stillinger/stilling/{id}", "nav", + "NAV Arbeidsplassen", + "searched", + retrievedAt, "NO"); } } @@ -106,5 +111,17 @@ public sealed class JobDiscoveryController : ControllerBase private static bool Contains(string? value, string filter) => filter.Length == 0 || (value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false); - public sealed record DiscoveredJob(string Id, string Title, string? Company, string? Location, DateTimeOffset? ModifiedAt, string Url, string Source, string CountryCode); + public sealed record DiscoveredJob( + string Id, + string Title, + string? Company, + string? Location, + DateTimeOffset? ModifiedAt, + DateTimeOffset? Deadline, + string Url, + string Source, + string SourceName, + string AcquisitionType, + DateTimeOffset RetrievedAt, + string CountryCode); } diff --git a/job-tracker-ui/src/job-discovery.test.tsx b/job-tracker-ui/src/job-discovery.test.tsx index 57270ae..314ce6b 100644 --- a/job-tracker-ui/src/job-discovery.test.tsx +++ b/job-tracker-ui/src/job-discovery.test.tsx @@ -8,14 +8,17 @@ import JobDiscoveryPage from "./views/JobDiscoveryPage"; jest.mock("./api", () => ({ api: { get: jest.fn() } })); const mockedApi = api as jest.Mocked; -test("searches NAV and offers the existing reviewed save flow", async () => { - mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", countryCode: "NO" }] } as any); +test("shows honest source metadata and offers the existing reviewed save flow", async () => { + mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }] } as any); render(); fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "backend" } }); fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); expect(await screen.findByText("Backend Developer")).toBeInTheDocument(); + expect(screen.getByText("NAV Arbeidsplassen")).toBeInTheDocument(); + expect(screen.getByText(/Searched listing · Retrieved/)).toBeInTheDocument(); + expect(screen.getByText(/Application deadline:/)).toBeInTheDocument(); await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/job-discovery/search", { params: { q: "backend", location: undefined } })); expect(screen.getByRole("button", { name: "Save to tracker" })).toBeInTheDocument(); }); diff --git a/job-tracker-ui/src/quick-capture.test.tsx b/job-tracker-ui/src/quick-capture.test.tsx index c0ae584..468e05f 100644 --- a/job-tracker-ui/src/quick-capture.test.tsx +++ b/job-tracker-ui/src/quick-capture.test.tsx @@ -72,7 +72,7 @@ test('does not auto-import when no initialUrl is given', async () => { test('can generate a tailored CV after creating a job', async () => { mockedApi.post.mockImplementation((url: string) => { - if (url === '/jobimport/preview') return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', description: 'desc' } } as any); + if (url === '/jobimport/preview') return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', description: 'desc', source: 'nav', countryCode: 'NO' } } as any); if (url === '/companies') return Promise.resolve({ data: { id: 1, name: 'Acme' } } as any); if (url === '/jobapplications') return Promise.resolve({ data: { id: 42 } } as any); return Promise.resolve({ data: {} } as any); @@ -85,5 +85,6 @@ test('can generate a tailored CV after creating a job', async () => { for (let step = 0; step < 3; step += 1) fireEvent.click(screen.getByRole('button', { name: 'Skip and continue' })); fireEvent.click(screen.getByRole('button', { name: /create job/i })); - await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/generate-tailored-cv-draft')); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications', expect.objectContaining({ source: 'nav', countryCode: 'NO' }))); + expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/generate-tailored-cv-draft'); }); diff --git a/job-tracker-ui/src/views/JobDiscoveryPage.tsx b/job-tracker-ui/src/views/JobDiscoveryPage.tsx index 9071660..2b548f8 100644 --- a/job-tracker-ui/src/views/JobDiscoveryPage.tsx +++ b/job-tracker-ui/src/views/JobDiscoveryPage.tsx @@ -1,11 +1,26 @@ import { FormEvent, useState } from "react"; -import { Alert, Box, Button, Card, CardActions, CardContent, CircularProgress, Stack, TextField, Typography } from "@mui/material"; +import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, Stack, TextField, Typography } from "@mui/material"; import SearchIcon from "@mui/icons-material/Search"; import AddIcon from "@mui/icons-material/Add"; import { useNavigate } from "react-router-dom"; import { api } from "../api"; -type DiscoveredJob = { id: string; title: string; company?: string; location?: string; modifiedAt?: string; url: string; source: string; countryCode: string; }; +type DiscoveredJob = { + id: string; + title: string; + company?: string; + location?: string; + modifiedAt?: string; + deadline?: string; + url: string; + source: string; + sourceName?: string; + acquisitionType?: string; + retrievedAt?: string; + countryCode: string; +}; + +const formatDate = (value?: string) => value ? new Date(value).toLocaleDateString() : null; export default function JobDiscoveryPage() { const navigate = useNavigate(); @@ -38,9 +53,17 @@ export default function JobDiscoveryPage() { {jobs.map((job) => ( + {job.title} {[job.company, job.location].filter(Boolean).join(" · ")} - NAV · Norway{job.modifiedAt ? " · Updated " + new Date(job.modifiedAt).toLocaleDateString() : ""} + + + {job.acquisitionType === "searched" ? "Searched listing" : "Source type unavailable"} + {formatDate(job.retrievedAt) ? ` · Retrieved ${formatDate(job.retrievedAt)}` : ""} + + {formatDate(job.modifiedAt) ? Listing updated {formatDate(job.modifiedAt)} : null} + {formatDate(job.deadline) ? Application deadline: {formatDate(job.deadline)} : null} +