diff --git a/PatchNotes.Sync/SyncService.cs b/PatchNotes.Sync/SyncService.cs index 24b92ed8..75de5e7a 100644 --- a/PatchNotes.Sync/SyncService.cs +++ b/PatchNotes.Sync/SyncService.cs @@ -132,6 +132,10 @@ public async Task SyncPackageAsync( .Select(r => r.Tag) .ToHashSetAsync(cancellationToken); + // Batch-collect releases locally before adding to the change tracker. + // This prevents orphaned entities if processing fails mid-loop. + var newReleases = new List(); + await foreach (var ghRelease in _github.GetAllReleasesAsync( package.GithubOwner, package.GithubRepo, @@ -223,7 +227,7 @@ public async Task SyncPackageAsync( IsPrerelease = parsed.IsPrerelease }; - _db.Releases.Add(release); + newReleases.Add(release); existingTags.Add(ghRelease.TagName); releasesAdded++; @@ -231,6 +235,9 @@ public async Task SyncPackageAsync( releasesNeedingSummary.Add(release); } + // Add all releases to the change tracker only after the loop + // completes successfully — no orphans if processing threw mid-loop + _db.Releases.AddRange(newReleases); package.LastFetchedAt = fetchedAt; await _db.SaveChangesAsync(cancellationToken); diff --git a/PatchNotes.Tests/GitHubClientTests.cs b/PatchNotes.Tests/GitHubClientTests.cs index 6ec01c2b..5a39ee58 100644 --- a/PatchNotes.Tests/GitHubClientTests.cs +++ b/PatchNotes.Tests/GitHubClientTests.cs @@ -436,10 +436,10 @@ protected override Task SendAsync( return Task.FromResult(response); } - // Return empty array by default for any unmatched request - return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = JsonContent.Create(Array.Empty()) - }); + // Fail loudly for unmatched requests so tests don't silently pass + // with empty data. Every expected HTTP call must be explicitly set up. + throw new InvalidOperationException( + $"No mock response configured for {request.Method} {pathAndQuery}. " + + $"Call SetupResponse/SetupErrorResponse for this path in your test arrangement."); } } diff --git a/patchnotes-web/src/api/hooks.ts b/patchnotes-web/src/api/hooks.ts index aec2603b..7838d1ed 100644 --- a/patchnotes-web/src/api/hooks.ts +++ b/patchnotes-web/src/api/hooks.ts @@ -38,14 +38,14 @@ import { GetWatchlistResponse } from './generated/watchlist/watchlist.zod' // ── Helpers ───────────────────────────────────────────────── -function safeParse( +function validateResponse( schema: T, data: unknown -): z.output | null { +): z.output { const result = schema.safeParse(data) if (!result.success) { console.error('[Zod validation error]', z.prettifyError(result.error)) - return null + throw result.error } return result.data } @@ -55,7 +55,7 @@ function safeParse( export function usePackages() { return useGetPackages(undefined, { query: { - select: (res) => safeParse(GetPackagesResponse, res.data)?.items ?? null, + select: (res) => validateResponse(GetPackagesResponse, res.data).items, }, }) } @@ -63,7 +63,7 @@ export function usePackages() { export function usePackage(id: string) { return useGetPackage(id, { query: { - select: (res) => safeParse(GetPackageResponse, res.data), + select: (res) => validateResponse(GetPackageResponse, res.data), }, }) } @@ -87,7 +87,7 @@ export function useReleases(options?: ReleasesOptions) { return useGetReleases(params, { query: { - select: (res) => safeParse(GetReleasesResponse, res.data), + select: (res) => validateResponse(GetReleasesResponse, res.data), }, }) } @@ -95,7 +95,7 @@ export function useReleases(options?: ReleasesOptions) { export function useRelease(id: string) { return useGetRelease(id, { query: { - select: (res) => safeParse(GetReleaseResponse, res.data), + select: (res) => validateResponse(GetReleaseResponse, res.data), }, }) } @@ -103,7 +103,7 @@ export function useRelease(id: string) { export function usePackageReleases(packageId: string) { return useGetPackageReleases(packageId, { query: { - select: (res) => safeParse(GetPackageReleasesResponse, res.data), + select: (res) => validateResponse(GetPackageReleasesResponse, res.data), }, }) } @@ -114,7 +114,7 @@ export function usePackagesByOwner(owner: string) { return useGetPackagesByOwner(owner, undefined, { query: { select: (res) => - safeParse(GetPackagesByOwnerResponse, res.data)?.items ?? null, + validateResponse(GetPackagesByOwnerResponse, res.data).items, }, }) } @@ -122,7 +122,8 @@ export function usePackagesByOwner(owner: string) { export function usePackageByOwnerRepo(owner: string, repo: string) { return useGetPackageByOwnerRepo(owner, repo, { query: { - select: (res) => safeParse(GetPackageByOwnerRepoResponse, res.data), + select: (res) => + validateResponse(GetPackageByOwnerRepoResponse, res.data), }, }) } @@ -212,7 +213,7 @@ export function useWatchlist() { return useGetWatchlist({ query: { enabled: !!user, - select: (res) => safeParse(GetWatchlistResponse, res.data), + select: (res) => validateResponse(GetWatchlistResponse, res.data), }, }) }