mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-06-04 04:23:00 +00:00
initiall commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
// +build integration
|
||||
|
||||
package handlers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/tdvorak/primora/apps/backend/internal/handlers"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/observability"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/services"
|
||||
"github.com/tdvorak/primora/apps/backend/internal/storage"
|
||||
)
|
||||
|
||||
// Integration tests require a running PostgreSQL instance
|
||||
// Run with: go test -tags=integration ./...
|
||||
|
||||
func TestHealthEndpoints(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
t.Run("liveness check", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/health/liveness", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ok", response["status"])
|
||||
})
|
||||
|
||||
t.Run("readiness check", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/health/readiness", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, response, "status")
|
||||
assert.Contains(t, response, "checks")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAuthenticationFlow(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
t.Run("requires authentication", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/me", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
|
||||
t.Run("rejects invalid token", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBootstrapFlow(t *testing.T) {
|
||||
router := setupTestRouter(t)
|
||||
|
||||
// This test requires a valid JWT token
|
||||
// In a real integration test, you would:
|
||||
// 1. Create a test user in the auth service
|
||||
// 2. Get a valid JWT token
|
||||
// 3. Use that token to test the bootstrap endpoint
|
||||
|
||||
t.Skip("Requires auth service integration")
|
||||
}
|
||||
|
||||
func setupTestRouter(t *testing.T) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// This would need to connect to a test database
|
||||
// For now, we'll create a minimal setup
|
||||
|
||||
router := gin.New()
|
||||
|
||||
// Create minimal dependencies
|
||||
store, err := storage.NewLocalStore(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
// In a real integration test, you would:
|
||||
// - Connect to a test PostgreSQL database
|
||||
// - Run migrations
|
||||
// - Create a CoreRepository
|
||||
// - Create a PlatformService
|
||||
|
||||
// For this example, we'll just set up the health endpoints
|
||||
metrics := observability.NewMetrics()
|
||||
|
||||
handler := &handlers.HTTPHandler{
|
||||
Platform: nil, // Would be initialized with real services
|
||||
Validate: validator.New(),
|
||||
Metrics: metrics,
|
||||
Readiness: func(c *gin.Context) map[string]any {
|
||||
return map[string]any{
|
||||
"status": "ok",
|
||||
"checks": map[string]any{
|
||||
"database": "ok",
|
||||
"storage": "ok",
|
||||
"dragonfly": "disabled",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
handler.Register(router)
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newTestContext(rawQuery string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
path := "/"
|
||||
if rawQuery != "" {
|
||||
path += "?" + rawQuery
|
||||
}
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
return ctx, recorder
|
||||
}
|
||||
|
||||
func TestParsePaginationQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("uses default when absent", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, _ := newTestContext("")
|
||||
value, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
|
||||
if !ok {
|
||||
t.Fatalf("expected parse to succeed")
|
||||
}
|
||||
if value != 50 {
|
||||
t.Fatalf("unexpected value: %d", value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parses valid query", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, _ := newTestContext("limit=25")
|
||||
value, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
|
||||
if !ok {
|
||||
t.Fatalf("expected parse to succeed")
|
||||
}
|
||||
if value != 25 {
|
||||
t.Fatalf("unexpected value: %d", value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid number", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("limit=abc")
|
||||
_, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
|
||||
if ok {
|
||||
t.Fatalf("expected parse to fail")
|
||||
}
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects out-of-range value", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("limit=500")
|
||||
_, ok := parsePaginationQuery(ctx, "limit", 50, 1, 200)
|
||||
if ok {
|
||||
t.Fatalf("expected parse to fail")
|
||||
}
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleErrorStatusMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
handler := &HTTPHandler{}
|
||||
|
||||
t.Run("maps insufficient role to forbidden", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, errors.New("project role admin is insufficient"))
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps uniqueness to conflict", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, errors.New("duplicate key value violates unique constraint"))
|
||||
if recorder.Code != http.StatusConflict {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("maps invalid input to bad request", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, recorder := newTestContext("")
|
||||
handler.handleError(ctx, errors.New("invalid invitation project scope"))
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unexpected status: %d", recorder.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
||||
)
|
||||
|
||||
type organizationMembershipResponse struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
MembershipRole string `json:"membership_role"`
|
||||
}
|
||||
|
||||
type projectResponse struct {
|
||||
ID string `json:"id"`
|
||||
OrganizationID string `json:"organization_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
MembershipRole *string `json:"membership_role"`
|
||||
}
|
||||
|
||||
type apiKeyResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
}
|
||||
|
||||
type bucketResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
type bucketObjectResponse struct {
|
||||
ID string `json:"id"`
|
||||
BucketID string `json:"bucket_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ChecksumSHA256 string `json:"checksum_sha256"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type auditLogResponse struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
RequestID string `json:"request_id"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
|
||||
type collectionResponse struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Schema map[string]any `json:"schema"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type documentResponse struct {
|
||||
ID string `json:"id"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
Data map[string]any `json:"data"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func toOrganizationMembershipResponse(row db.ListOrganizationsForUserRow) organizationMembershipResponse {
|
||||
return organizationMembershipResponse{
|
||||
ID: row.ID.String(),
|
||||
Slug: row.Slug,
|
||||
Name: row.Name,
|
||||
MembershipRole: row.MembershipRole,
|
||||
}
|
||||
}
|
||||
|
||||
func toOrganizationMembershipResponseFromCore(row db.CoreOrganization, role string) organizationMembershipResponse {
|
||||
return organizationMembershipResponse{
|
||||
ID: row.ID.String(),
|
||||
Slug: row.Slug,
|
||||
Name: row.Name,
|
||||
MembershipRole: role,
|
||||
}
|
||||
}
|
||||
|
||||
func toProjectListResponse(rows []db.ListProjectsForOrganizationRow) []projectResponse {
|
||||
result := make([]projectResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, toProjectFromRow(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toProjectFromRow(row db.ListProjectsForOrganizationRow) projectResponse {
|
||||
var membershipRole *string
|
||||
if row.MembershipRole.Valid {
|
||||
role := string(row.MembershipRole.CoreProjectRole)
|
||||
membershipRole = &role
|
||||
}
|
||||
return projectResponse{
|
||||
ID: row.ID.String(),
|
||||
OrganizationID: row.OrganizationID.String(),
|
||||
Slug: row.Slug,
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
MembershipRole: membershipRole,
|
||||
}
|
||||
}
|
||||
|
||||
func toProjectFromCore(row db.CoreProject) projectResponse {
|
||||
return projectResponse{
|
||||
ID: row.ID.String(),
|
||||
OrganizationID: row.OrganizationID.String(),
|
||||
Slug: row.Slug,
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
MembershipRole: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func toAPIKeyListResponse(rows []db.CoreApiKey) []apiKeyResponse {
|
||||
result := make([]apiKeyResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, toAPIKeyResponse(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toAPIKeyResponse(row db.CoreApiKey) apiKeyResponse {
|
||||
return apiKeyResponse{
|
||||
ID: row.ID.String(),
|
||||
ProjectID: row.ProjectID.String(),
|
||||
Name: row.Name,
|
||||
Prefix: row.Prefix,
|
||||
LastUsedAt: timestamptzPtr(row.LastUsedAt),
|
||||
RevokedAt: timestamptzPtr(row.RevokedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func toBucketListResponse(rows []db.CoreBucket) []bucketResponse {
|
||||
result := make([]bucketResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, toBucketResponse(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toBucketResponse(row db.CoreBucket) bucketResponse {
|
||||
return bucketResponse{
|
||||
ID: row.ID.String(),
|
||||
ProjectID: row.ProjectID.String(),
|
||||
Slug: row.Slug,
|
||||
Name: row.Name,
|
||||
Visibility: row.Visibility,
|
||||
}
|
||||
}
|
||||
|
||||
func toObjectListResponse(rows []db.CoreBucketObject) []bucketObjectResponse {
|
||||
result := make([]bucketObjectResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, toObjectResponse(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toObjectResponse(row db.CoreBucketObject) bucketObjectResponse {
|
||||
createdAt := time.Time{}
|
||||
if row.CreatedAt.Valid {
|
||||
createdAt = row.CreatedAt.Time
|
||||
}
|
||||
return bucketObjectResponse{
|
||||
ID: row.ID.String(),
|
||||
BucketID: row.BucketID.String(),
|
||||
ObjectKey: row.ObjectKey,
|
||||
ContentType: row.ContentType,
|
||||
SizeBytes: row.SizeBytes,
|
||||
ChecksumSHA256: row.ChecksumSha256,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func toAuditLogListResponse(rows []db.CoreAuditLog) []auditLogResponse {
|
||||
result := make([]auditLogResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, toAuditLogResponse(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toAuditLogResponse(row db.CoreAuditLog) auditLogResponse {
|
||||
metadata := map[string]any{}
|
||||
_ = json.Unmarshal(row.Metadata, &metadata)
|
||||
createdAt := time.Time{}
|
||||
if row.CreatedAt.Valid {
|
||||
createdAt = row.CreatedAt.Time
|
||||
}
|
||||
return auditLogResponse{
|
||||
ID: row.ID.String(),
|
||||
CreatedAt: createdAt,
|
||||
Action: row.Action,
|
||||
ResourceType: row.ResourceType,
|
||||
ResourceID: row.ResourceID,
|
||||
RequestID: row.RequestID,
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func toCollectionListResponse(rows []db.CoreCollection) []collectionResponse {
|
||||
result := make([]collectionResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, toCollectionResponse(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toCollectionResponse(row db.CoreCollection) collectionResponse {
|
||||
schema := map[string]any{}
|
||||
_ = json.Unmarshal(row.Schema, &schema)
|
||||
return collectionResponse{
|
||||
ID: row.ID.String(),
|
||||
ProjectID: row.ProjectID.String(),
|
||||
Slug: row.Slug,
|
||||
Name: row.Name,
|
||||
Description: row.Description,
|
||||
Schema: schema,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func toDocumentListResponse(rows []db.CoreDocument) []documentResponse {
|
||||
result := make([]documentResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, toDocumentResponse(row))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toDocumentResponse(row db.CoreDocument) documentResponse {
|
||||
data := map[string]any{}
|
||||
_ = json.Unmarshal(row.Data, &data)
|
||||
return documentResponse{
|
||||
ID: row.ID.String(),
|
||||
CollectionID: row.CollectionID.String(),
|
||||
Data: data,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
UpdatedAt: row.UpdatedAt.Time,
|
||||
}
|
||||
}
|
||||
|
||||
func timestamptzPtr(value pgtype.Timestamptz) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
t := value.Time
|
||||
return &t
|
||||
}
|
||||
Reference in New Issue
Block a user