mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(api): error codes, idempotency keys, cursor pagination
- Stable machine-readable error codes alongside the error message (UNAUTHENTICATED, INVALID_TOKEN, ADMIN_REQUIRED, SCOPE_INSUFFICIENT, IDEMPOTENCY_IN_PROGRESS); CLI renders "CODE: message". - Idempotency middleware on authed/admin groups: POST requests with an Idempotency-Key header replay the first response for 24h (Redis-backed, caller-scoped); concurrent duplicates get 409. X-Idempotent-Replay header marks replays. - Shared pagination helpers: ?cursor= (opaque, preferred) alongside legacy ?page= on projects and deployments lists; pagination meta includes next_cursor; invalid cursors fail soft to page 1. - CLI --idempotency-key flag for safe scripted retries. - OpenAPI updated: error envelope, cursor params, idempotency contract. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
485e41a817
commit
18f78f00a3
15 files changed
+454
-56
No files matched your search
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ctxWithQuery(q string) *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("GET", "/x?"+q, nil)
|
||||
return c
|
||||
}
|
||||
|
||||
func TestPageWindowDefaults(t *testing.T) {
|
||||
offset, limit := pageWindow(ctxWithQuery(""), 10, 100)
|
||||
if offset != 0 || limit != 10 {
|
||||
t.Fatalf("expected 0/10, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowLegacyPage(t *testing.T) {
|
||||
offset, limit := pageWindow(ctxWithQuery("page=3&limit=20"), 10, 100)
|
||||
if offset != 40 || limit != 20 {
|
||||
t.Fatalf("expected 40/20, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowCursor(t *testing.T) {
|
||||
// cursor for offset 50 produced by paginationMeta
|
||||
meta := paginationMeta(0, 50, 200)
|
||||
cursor := meta["next_cursor"].(string)
|
||||
offset, limit := pageWindow(ctxWithQuery("cursor="+cursor+"&limit=50"), 10, 100)
|
||||
if offset != 50 || limit != 50 {
|
||||
t.Fatalf("expected 50/50, got %d/%d", offset, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageWindowInvalidCursorFailsSoft(t *testing.T) {
|
||||
offset, _ := pageWindow(ctxWithQuery("cursor=garbage"), 10, 100)
|
||||
if offset != 0 {
|
||||
t.Fatalf("expected offset 0 on invalid cursor, got %d", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginationMetaNextCursorOnlyWhenMore(t *testing.T) {
|
||||
if _, ok := paginationMeta(0, 10, 10)["next_cursor"]; ok {
|
||||
t.Fatal("no next page should omit next_cursor")
|
||||
}
|
||||
if _, ok := paginationMeta(0, 10, 25)["next_cursor"]; !ok {
|
||||
t.Fatal("expected next_cursor when more pages exist")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user