Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion PatchNotes.Sync/SyncService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ public async Task<PackageSyncResult> 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<Release>();

await foreach (var ghRelease in _github.GetAllReleasesAsync(
package.GithubOwner,
package.GithubRepo,
Expand Down Expand Up @@ -223,14 +227,17 @@ public async Task<PackageSyncResult> SyncPackageAsync(
IsPrerelease = parsed.IsPrerelease
};

_db.Releases.Add(release);
newReleases.Add(release);
existingTags.Add(ghRelease.TagName);
releasesAdded++;

// New releases always need summaries
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);

Expand Down
10 changes: 5 additions & 5 deletions PatchNotes.Tests/GitHubClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -436,10 +436,10 @@ protected override Task<HttpResponseMessage> 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<object>())
});
// 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.");
}
}
23 changes: 12 additions & 11 deletions patchnotes-web/src/api/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,14 @@ import { GetWatchlistResponse } from './generated/watchlist/watchlist.zod'

// ── Helpers ─────────────────────────────────────────────────

function safeParse<T extends z.ZodType>(
function validateResponse<T extends z.ZodType>(
schema: T,
data: unknown
): z.output<T> | null {
): z.output<T> {
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
}
Expand All @@ -55,15 +55,15 @@ function safeParse<T extends z.ZodType>(
export function usePackages() {
return useGetPackages(undefined, {
query: {
select: (res) => safeParse(GetPackagesResponse, res.data)?.items ?? null,
select: (res) => validateResponse(GetPackagesResponse, res.data).items,
},
})
}

export function usePackage(id: string) {
return useGetPackage(id, {
query: {
select: (res) => safeParse(GetPackageResponse, res.data),
select: (res) => validateResponse(GetPackageResponse, res.data),
},
})
}
Expand All @@ -87,23 +87,23 @@ export function useReleases(options?: ReleasesOptions) {

return useGetReleases(params, {
query: {
select: (res) => safeParse(GetReleasesResponse, res.data),
select: (res) => validateResponse(GetReleasesResponse, res.data),
},
})
}

export function useRelease(id: string) {
return useGetRelease(id, {
query: {
select: (res) => safeParse(GetReleaseResponse, res.data),
select: (res) => validateResponse(GetReleaseResponse, res.data),
},
})
}

export function usePackageReleases(packageId: string) {
return useGetPackageReleases(packageId, {
query: {
select: (res) => safeParse(GetPackageReleasesResponse, res.data),
select: (res) => validateResponse(GetPackageReleasesResponse, res.data),
},
})
}
Expand All @@ -114,15 +114,16 @@ export function usePackagesByOwner(owner: string) {
return useGetPackagesByOwner(owner, undefined, {
query: {
select: (res) =>
safeParse(GetPackagesByOwnerResponse, res.data)?.items ?? null,
validateResponse(GetPackagesByOwnerResponse, res.data).items,
},
})
}

export function usePackageByOwnerRepo(owner: string, repo: string) {
return useGetPackageByOwnerRepo(owner, repo, {
query: {
select: (res) => safeParse(GetPackageByOwnerRepoResponse, res.data),
select: (res) =>
validateResponse(GetPackageByOwnerRepoResponse, res.data),
},
})
}
Expand Down Expand Up @@ -212,7 +213,7 @@ export function useWatchlist() {
return useGetWatchlist({
query: {
enabled: !!user,
select: (res) => safeParse(GetWatchlistResponse, res.data),
select: (res) => validateResponse(GetWatchlistResponse, res.data),
},
})
}
Expand Down