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
57 changes: 47 additions & 10 deletions PatchNotes.Api/Routes/PackageRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ public static WebApplication MapPackageRoutes(this WebApplication app)

var group = app.MapGroup("/api/packages").WithTags("Packages");

// GET /api/packages - List all tracked packages
group.MapGet("/", async (PatchNotesDbContext db) =>
// GET /api/packages - List all tracked packages (paginated)
group.MapGet("/", async (int? limit, int? offset, PatchNotesDbContext db) =>
{
var take = Math.Clamp(limit ?? 20, 1, 100);
var skip = Math.Max(offset ?? 0, 0);

var total = await db.Packages.CountAsync();

var packages = await db.Packages
.OrderBy(p => p.Name)
.Skip(skip)
.Take(take)
.Select(p => new PackageDto
{
Id = p.Id,
Expand All @@ -30,9 +38,16 @@ public static WebApplication MapPackageRoutes(this WebApplication app)
CreatedAt = p.CreatedAt
})
.ToListAsync();
return TypedResults.Ok(packages);

return TypedResults.Ok(new PaginatedResponse<PackageDto>
{
Items = packages,
Total = total,
Limit = take,
Offset = skip
});
})
.Produces<List<PackageDto>>(StatusCodes.Status200OK)
.Produces<PaginatedResponse<PackageDto>>(StatusCodes.Status200OK)
.WithName("GetPackages");

// GET /api/packages/{id} - Get single package details (by nanoid)
Expand Down Expand Up @@ -104,11 +119,19 @@ public static WebApplication MapPackageRoutes(this WebApplication app)
.Produces(StatusCodes.Status404NotFound)
.WithName("GetPackageReleases");

// GET /api/packages/{owner} - List packages by GitHub owner
group.MapGet("/{owner}", async (string owner, PatchNotesDbContext db) =>
// GET /api/packages/{owner} - List packages by GitHub owner (paginated)
group.MapGet("/{owner}", async (string owner, int? limit, int? offset, PatchNotesDbContext db) =>
{
var packages = await db.Packages
.Where(p => p.GithubOwner == owner)
var take = Math.Clamp(limit ?? 20, 1, 100);
var skip = Math.Max(offset ?? 0, 0);

var query = db.Packages.Where(p => p.GithubOwner == owner);
var total = await query.CountAsync();

var packages = await query
.OrderBy(p => p.Name)
.Skip(skip)
.Take(take)
.Select(p => new OwnerPackageDto
{
Id = p.Id,
Expand All @@ -125,9 +148,15 @@ public static WebApplication MapPackageRoutes(this WebApplication app)
})
.ToListAsync();

return Results.Ok(packages);
return Results.Ok(new PaginatedResponse<OwnerPackageDto>
{
Items = packages,
Total = total,
Limit = take,
Offset = skip
});
})
.Produces<List<OwnerPackageDto>>(StatusCodes.Status200OK)
.Produces<PaginatedResponse<OwnerPackageDto>>(StatusCodes.Status200OK)
.WithName("GetPackagesByOwner");

// GET /api/packages/{owner}/{repo} - Package detail with all version groups and releases
Expand Down Expand Up @@ -572,3 +601,11 @@ public class BulkAddPackageResultItem
public string? GithubOwner { get; set; }
public string? GithubRepo { get; set; }
}

public class PaginatedResponse<T>
{
public required List<T> Items { get; set; }
public int Total { get; set; }
public int Limit { get; set; }
public int Offset { get; set; }
}
102 changes: 88 additions & 14 deletions PatchNotes.Tests/PackagesApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ public async Task GetPackages_ReturnsEmptyList_WhenNoPackages()
var response = await _client.GetAsync("/api/packages");

response.StatusCode.Should().Be(HttpStatusCode.OK);
var packages = await response.Content.ReadFromJsonAsync<JsonElement>();
packages.GetArrayLength().Should().Be(0);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("items").GetArrayLength().Should().Be(0);
result.GetProperty("total").GetInt32().Should().Be(0);
result.GetProperty("limit").GetInt32().Should().Be(20);
result.GetProperty("offset").GetInt32().Should().Be(0);
}

[Fact]
Expand All @@ -61,8 +64,9 @@ public async Task GetPackages_ReturnsAllPackages()

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var packages = await response.Content.ReadFromJsonAsync<JsonElement>();
packages.GetArrayLength().Should().Be(2);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("items").GetArrayLength().Should().Be(2);
result.GetProperty("total").GetInt32().Should().Be(2);
}

[Fact]
Expand All @@ -88,8 +92,8 @@ public async Task GetPackages_ReturnsCorrectFields()

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var packages = await response.Content.ReadFromJsonAsync<JsonElement>();
var pkg = packages[0];
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
var pkg = result.GetProperty("items")[0];
pkg.GetProperty("npmName").GetString().Should().Be("lodash");
pkg.GetProperty("githubOwner").GetString().Should().Be("lodash");
pkg.GetProperty("githubRepo").GetString().Should().Be("lodash");
Expand All @@ -98,6 +102,49 @@ public async Task GetPackages_ReturnsCorrectFields()
pkg.TryGetProperty("lastFetchedAt", out _).Should().BeTrue();
}

[Fact]
public async Task GetPackages_RespectsLimitAndOffset()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PatchNotesDbContext>();
db.Packages.AddRange(
new Package { Name = "alpha", Url = "https://github.com/o/alpha", NpmName = "alpha", GithubOwner = "o", GithubRepo = "alpha" },
new Package { Name = "bravo", Url = "https://github.com/o/bravo", NpmName = "bravo", GithubOwner = "o", GithubRepo = "bravo" },
new Package { Name = "charlie", Url = "https://github.com/o/charlie", NpmName = "charlie", GithubOwner = "o", GithubRepo = "charlie" }
);
await db.SaveChangesAsync();

// Act
var response = await _client.GetAsync("/api/packages?limit=2&offset=1");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("items").GetArrayLength().Should().Be(2);
result.GetProperty("total").GetInt32().Should().Be(3);
result.GetProperty("limit").GetInt32().Should().Be(2);
result.GetProperty("offset").GetInt32().Should().Be(1);
}

[Fact]
public async Task GetPackages_ClampsLimitToMax100()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PatchNotesDbContext>();
db.Packages.Add(new Package { Name = "pkg", Url = "https://github.com/o/r", NpmName = "pkg", GithubOwner = "o", GithubRepo = "r" });
await db.SaveChangesAsync();

// Act
var response = await _client.GetAsync("/api/packages?limit=500");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("limit").GetInt32().Should().Be(100);
}

#endregion

#region POST /api/packages
Expand Down Expand Up @@ -232,8 +279,8 @@ public async Task DeletePackage_DeletesPackage_WhenExists()

// Verify deletion
var getResponse = await _client.GetAsync("/api/packages");
var packages = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
packages.GetArrayLength().Should().Be(0);
var result = await getResponse.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("items").GetArrayLength().Should().Be(0);
}

#endregion
Expand All @@ -246,8 +293,9 @@ public async Task GetPackagesByOwner_ReturnsEmptyList_WhenNoPackagesForOwner()
var response = await _client.GetAsync("/api/packages/nonexistent-owner");

response.StatusCode.Should().Be(HttpStatusCode.OK);
var packages = await response.Content.ReadFromJsonAsync<JsonElement>();
packages.GetArrayLength().Should().Be(0);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("items").GetArrayLength().Should().Be(0);
result.GetProperty("total").GetInt32().Should().Be(0);
}

[Fact]
Expand All @@ -268,8 +316,9 @@ public async Task GetPackagesByOwner_ReturnsMatchingPackages()

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var packages = await response.Content.ReadFromJsonAsync<JsonElement>();
packages.GetArrayLength().Should().Be(2);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("items").GetArrayLength().Should().Be(2);
result.GetProperty("total").GetInt32().Should().Be(2);
}

[Fact]
Expand All @@ -288,8 +337,8 @@ public async Task GetPackagesByOwner_ReturnsCorrectFields()

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var packages = await response.Content.ReadFromJsonAsync<JsonElement>();
var p = packages[0];
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
var p = result.GetProperty("items")[0];
p.GetProperty("id").GetString().Should().NotBeNullOrEmpty();
p.GetProperty("name").GetString().Should().Be("FastAPI");
p.GetProperty("githubOwner").GetString().Should().Be("tiangolo");
Expand All @@ -298,6 +347,31 @@ public async Task GetPackagesByOwner_ReturnsCorrectFields()
p.TryGetProperty("lastUpdated", out _).Should().BeTrue();
}

[Fact]
public async Task GetPackagesByOwner_RespectsLimitAndOffset()
{
// Arrange
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PatchNotesDbContext>();
db.Packages.AddRange(
new Package { Name = "alpha", Url = "https://github.com/testowner/alpha", NpmName = "alpha", GithubOwner = "testowner", GithubRepo = "alpha" },
new Package { Name = "bravo", Url = "https://github.com/testowner/bravo", NpmName = "bravo", GithubOwner = "testowner", GithubRepo = "bravo" },
new Package { Name = "charlie", Url = "https://github.com/testowner/charlie", NpmName = "charlie", GithubOwner = "testowner", GithubRepo = "charlie" }
);
await db.SaveChangesAsync();

// Act
var response = await _client.GetAsync("/api/packages/testowner?limit=1&offset=1");

// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
result.GetProperty("items").GetArrayLength().Should().Be(1);
result.GetProperty("total").GetInt32().Should().Be(3);
result.GetProperty("limit").GetInt32().Should().Be(1);
result.GetProperty("offset").GetInt32().Should().Be(1);
}

[Fact]
public async Task PostPackage_ReturnsForbidden_WhenNonAdmin()
{
Expand Down
Loading