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
53 changes: 53 additions & 0 deletions PatchNotes.Api/Routes/PackageRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,59 @@ public static WebApplication MapPackageRoutes(this WebApplication app)
.Produces(StatusCodes.Status404NotFound)
.WithName("DisablePackageSync");

// POST /api/admin/packages/{id}/reset-summaries - Mark all releases as stale and delete summaries (admin only)
adminPackages.MapPost("/{id:length(21)}/reset-summaries", async (string id, PatchNotesDbContext db) =>
{
var package = await db.Packages.FindAsync(id);
if (package == null)
{
return Results.NotFound(new ApiError("Package not found"));
}

await db.Releases
.Where(r => r.PackageId == id)
.ExecuteUpdateAsync(s => s.SetProperty(r => r.SummaryStale, true));

await db.ReleaseSummaries
.Where(s => s.PackageId == id)
.ExecuteDeleteAsync();

return Results.NoContent();
})
.AddEndpointFilterFactory(requireAuth)
.AddEndpointFilterFactory(requireAdmin)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.WithName("ResetPackageSummaries");

// POST /api/admin/packages/{id}/reset-releases - Delete all releases/summaries and reset LastFetchedAt (admin only)
adminPackages.MapPost("/{id:length(21)}/reset-releases", async (string id, PatchNotesDbContext db) =>
{
var package = await db.Packages.FindAsync(id);
if (package == null)
{
return Results.NotFound(new ApiError("Package not found"));
}

await db.Releases
.Where(r => r.PackageId == id)
.ExecuteDeleteAsync();

await db.ReleaseSummaries
.Where(s => s.PackageId == id)
.ExecuteDeleteAsync();

package.LastFetchedAt = null;
await db.SaveChangesAsync();

return Results.NoContent();
})
.AddEndpointFilterFactory(requireAuth)
.AddEndpointFilterFactory(requireAdmin)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.WithName("ResetPackageReleases");

// GET /api/admin/github/search?q={query} - Search GitHub repositories (admin only)
var adminGitHub = app.MapGroup("/api/admin/github").WithTags("AdminGitHub");

Expand Down
153 changes: 153 additions & 0 deletions PatchNotes.Tests/PackagesApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -888,4 +888,157 @@ public async Task DisablePackageSync_SetsIsSyncDisabled_WhenPackageExists()
}

#endregion

#region POST /api/admin/packages/{id}/reset-summaries

[Fact]
public async Task ResetSummaries_GivenUnauthenticatedRequest_ReturnsForbidden()
{
// CSRF middleware rejects requests without Origin header before auth runs
var response = await _client.PostAsync("/api/admin/packages/nonexistent-id-1234xx/reset-summaries", null);

response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

[Fact]
public async Task ResetSummaries_GivenNonAdminRequest_ReturnsForbidden()
{
var response = await _nonAdminClient.PostAsync("/api/admin/packages/nonexistent-id-1234xx/reset-summaries", null);

response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

[Fact]
public async Task ResetSummaries_ReturnsNotFound_WhenPackageDoesNotExist()
{
var response = await _authClient.PostAsync("/api/admin/packages/nonexistent-id-1234xx/reset-summaries", null);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

[Fact]
public async Task ResetSummaries_MarksReleasesStaleAndDeletesSummaries()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PatchNotesDbContext>();
var pkg = new Package
{
Name = "summary-reset-pkg",
Url = "https://github.com/o/sr",
GithubOwner = "o",
GithubRepo = "sr",
};
db.Packages.Add(pkg);
db.Releases.AddRange(
new Release { PackageId = pkg.Id, Tag = "v1.0.0", PublishedAt = DateTimeOffset.UtcNow.AddDays(-10), FetchedAt = DateTimeOffset.UtcNow, MajorVersion = 1, SummaryStale = false },
new Release { PackageId = pkg.Id, Tag = "v1.0.1", PublishedAt = DateTimeOffset.UtcNow.AddDays(-5), FetchedAt = DateTimeOffset.UtcNow, MajorVersion = 1, SummaryStale = false },
new Release { PackageId = pkg.Id, Tag = "v2.0.0", PublishedAt = DateTimeOffset.UtcNow.AddDays(-1), FetchedAt = DateTimeOffset.UtcNow, MajorVersion = 2, SummaryStale = false }
);
db.ReleaseSummaries.AddRange(
new ReleaseSummary { PackageId = pkg.Id, MajorVersion = 1, IsPrerelease = false, Summary = "v1 summary", GeneratedAt = DateTimeOffset.UtcNow },
new ReleaseSummary { PackageId = pkg.Id, MajorVersion = 2, IsPrerelease = false, Summary = "v2 summary", GeneratedAt = DateTimeOffset.UtcNow }
);
await db.SaveChangesAsync();
var id = pkg.Id;

// Act
var response = await _authClient.PostAsync($"/api/admin/packages/{id}/reset-summaries", null);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);

using var verifyScope = _fixture.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<PatchNotesDbContext>();

// All releases should now be stale
var releases = verifyDb.Releases.Where(r => r.PackageId == id).ToList();
releases.Should().HaveCount(3);
releases.Should().AllSatisfy(r => r.SummaryStale.Should().BeTrue());

// All summaries should be deleted
var summaries = verifyDb.ReleaseSummaries.Where(s => s.PackageId == id).ToList();
summaries.Should().BeEmpty();
}

#endregion

#region POST /api/admin/packages/{id}/reset-releases

[Fact]
public async Task ResetReleases_GivenUnauthenticatedRequest_ReturnsForbidden()
{
// CSRF middleware rejects requests without Origin header before auth runs
var response = await _client.PostAsync("/api/admin/packages/nonexistent-id-1234xx/reset-releases", null);

response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

[Fact]
public async Task ResetReleases_GivenNonAdminRequest_ReturnsForbidden()
{
var response = await _nonAdminClient.PostAsync("/api/admin/packages/nonexistent-id-1234xx/reset-releases", null);

response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

[Fact]
public async Task ResetReleases_ReturnsNotFound_WhenPackageDoesNotExist()
{
var response = await _authClient.PostAsync("/api/admin/packages/nonexistent-id-1234xx/reset-releases", null);

response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}

[Fact]
public async Task ResetReleases_DeletesReleasesAndSummariesAndClearsLastFetchedAt()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PatchNotesDbContext>();
var pkg = new Package
{
Name = "release-reset-pkg",
Url = "https://github.com/o/rr",
GithubOwner = "o",
GithubRepo = "rr",
LastFetchedAt = DateTimeOffset.UtcNow,
};
db.Packages.Add(pkg);
db.Releases.AddRange(
new Release { PackageId = pkg.Id, Tag = "v1.0.0", PublishedAt = DateTimeOffset.UtcNow.AddDays(-10), FetchedAt = DateTimeOffset.UtcNow, MajorVersion = 1 },
new Release { PackageId = pkg.Id, Tag = "v2.0.0", PublishedAt = DateTimeOffset.UtcNow.AddDays(-1), FetchedAt = DateTimeOffset.UtcNow, MajorVersion = 2 }
);
db.ReleaseSummaries.Add(
new ReleaseSummary { PackageId = pkg.Id, MajorVersion = 1, IsPrerelease = false, Summary = "v1 summary", GeneratedAt = DateTimeOffset.UtcNow }
);
await db.SaveChangesAsync();
var id = pkg.Id;

// Act
var response = await _authClient.PostAsync($"/api/admin/packages/{id}/reset-releases", null);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.NoContent);

using var verifyScope = _fixture.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<PatchNotesDbContext>();

// All releases should be deleted
var releases = verifyDb.Releases.Where(r => r.PackageId == id).ToList();
releases.Should().BeEmpty();

// All summaries should be deleted
var summaries = verifyDb.ReleaseSummaries.Where(s => s.PackageId == id).ToList();
summaries.Should().BeEmpty();

// LastFetchedAt should be cleared
var updated = await verifyDb.Packages.FindAsync(id);
updated!.LastFetchedAt.Should().BeNull();

// Package itself should still exist
updated.Name.Should().Be("release-reset-pkg");
}

#endregion
}
60 changes: 58 additions & 2 deletions patchnotes-web/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
},
"servers": [
{
"url": "http://localhost:5299/"
"url": "http://localhost:2101/"
}
],
"paths": {
Expand Down Expand Up @@ -466,6 +466,62 @@
}
}
},
"/api/admin/packages/{id}/reset-summaries": {
"post": {
"tags": [
"AdminPackages"
],
"operationId": "ResetPackageSummaries",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"maxLength": 21,
"minLength": 21,
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/admin/packages/{id}/reset-releases": {
"post": {
"tags": [
"AdminPackages"
],
"operationId": "ResetPackageReleases",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"maxLength": 21,
"minLength": 21,
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found"
}
}
}
},
"/api/admin/github/search": {
"get": {
"tags": [
Expand Down Expand Up @@ -2360,4 +2416,4 @@
"name": "EmailTemplates"
}
]
}
}
Loading