diff --git a/.github/workflows/test.yml b/.github/workflows/maint.yml similarity index 76% rename from .github/workflows/test.yml rename to .github/workflows/maint.yml index 1245423..1c5d9fd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/maint.yml @@ -1,4 +1,4 @@ -name: Tests +name: Build , Vet Test, and Lint on: push: @@ -9,7 +9,7 @@ on: jobs: test: - name: Run Tests + name: Run Vet Tests runs-on: ubuntu-latest strategy: @@ -38,22 +38,6 @@ jobs: - name: Run go vet run: go vet ./... - - name: Run tests - run: go test -v -race -coverprofile=coverage.out -covermode=atomic ./... - - - name: Display test coverage - run: go tool cover -func=coverage.out - - # - name: Upload coverage to Codecov - # uses: codecov/codecov-action@v4 - # with: - # file: ./coverage.out - # flags: unittests - # name: codecov-umbrella - # env: - # CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # continue-on-error: true - lint: name: Lint Code runs-on: ubuntu-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..696b9ac --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,91 @@ +name: Tests + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21" + + - name: Run unit tests + run: go test ./pkg/resolvespec ./pkg/restheadspec -v -cover + + - name: Generate coverage report + run: | + go test ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage.out + go tool cover -html=coverage.out -o coverage.html + + - name: Upload coverage + uses: actions/upload-artifact@v3 + with: + name: coverage-report + path: coverage.html + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: "1.21" + + - name: Create test databases + env: + PGPASSWORD: postgres + run: | + psql -h localhost -U postgres -c "CREATE DATABASE resolvespec_test;" + psql -h localhost -U postgres -c "CREATE DATABASE restheadspec_test;" + + - name: Run resolvespec integration tests + env: + TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable" + run: go test -tags=integration ./pkg/resolvespec -v + + - name: Run restheadspec integration tests + env: + TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=restheadspec_test port=5432 sslmode=disable" + run: go test -tags=integration ./pkg/restheadspec -v + + - name: Generate integration coverage + env: + TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable" + run: | + go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage-integration.out + go tool cover -html=coverage-integration.out -o coverage-integration.html + + - name: Upload integration coverage + uses: actions/upload-artifact@v3 + with: + name: integration-coverage-report + path: coverage-integration.html diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d5b80c8 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,56 @@ +{ + "go.testFlags": [ + "-v" + ], + "go.testTimeout": "300s", + "go.coverOnSave": false, + "go.coverOnSingleTest": true, + "go.coverageDecorator": { + "type": "gutter" + }, + "go.testEnvVars": { + "TEST_DATABASE_URL": "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable" + }, + "go.toolsEnvVars": { + "CGO_ENABLED": "0" + }, + "go.buildTags": "", + "go.testTags": "", + "files.exclude": { + "**/.git": true, + "**/.DS_Store": true, + "**/coverage.out": true, + "**/coverage.html": true, + "**/coverage-integration.out": true, + "**/coverage-integration.html": true + }, + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/*/**": true, + "**/.hg/store/**": true, + "**/vendor/**": true + }, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[go]": { + "editor.defaultFormatter": "golang.go", + "editor.formatOnSave": true, + "editor.insertSpaces": false, + "editor.tabSize": 4 + }, + "gopls": { + "ui.completion.usePlaceholders": true, + "ui.semanticTokens": true, + "ui.codelenses": { + "generate": true, + "regenerate_cgo": true, + "test": true, + "tidy": true, + "upgrade_dependency": true, + "vendor": true + } + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 2caa5a1..d6b77dd 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -9,7 +9,7 @@ "env": { "CGO_ENABLED": "0" }, - "cwd": "${workspaceFolder}/bin", + "cwd": "${workspaceFolder}/bin" }, "args": [ "../..." @@ -17,11 +17,179 @@ "problemMatcher": [ "$go" ], - "group": "build", + "group": "build" + }, + { + "type": "shell", + "label": "test: unit tests (all)", + "command": "go test ./pkg/resolvespec ./pkg/restheadspec -v -cover", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [ + "$go" + ], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "shared", + "focus": true + } + }, + { + "type": "shell", + "label": "test: unit tests (resolvespec)", + "command": "go test ./pkg/resolvespec -v -cover", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [ + "$go" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "type": "shell", + "label": "test: unit tests (restheadspec)", + "command": "go test ./pkg/restheadspec -v -cover", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [ + "$go" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "type": "shell", + "label": "test: integration tests (automated)", + "command": "./scripts/run-integration-tests.sh", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [ + "$go" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + } + }, + { + "type": "shell", + "label": "test: integration tests (resolvespec only)", + "command": "./scripts/run-integration-tests.sh resolvespec", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [ + "$go" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "type": "shell", + "label": "test: integration tests (restheadspec only)", + "command": "./scripts/run-integration-tests.sh restheadspec", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [ + "$go" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "type": "shell", + "label": "test: coverage report", + "command": "make coverage", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "type": "shell", + "label": "test: integration coverage report", + "command": "make coverage-integration", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "type": "shell", + "label": "docker: start postgres", + "command": "make docker-up", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "type": "shell", + "label": "docker: stop postgres", + "command": "make docker-down", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "shared" + } + }, + { + "type": "shell", + "label": "docker: clean postgres data", + "command": "make clean", + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "shared" + } }, { "type": "go", - "label": "go: test workspace", + "label": "go: test workspace (with race)", "command": "test", "options": { "cwd": "${workspaceFolder}" @@ -36,13 +204,10 @@ "problemMatcher": [ "$go" ], - "group": { - "kind": "test", - "isDefault": true - }, + "group": "test", "presentation": { "reveal": "always", - "panel": "new" + "panel": "shared" } }, { @@ -69,23 +234,45 @@ }, { "type": "shell", - "label": "go: full test suite", + "label": "test: all tests (unit + integration)", + "command": "make test", + "options": { + "cwd": "${workspaceFolder}" + }, + "dependsOn": [ + "docker: start postgres" + ], + "problemMatcher": [ + "$go" + ], + "group": "test", + "presentation": { + "reveal": "always", + "panel": "dedicated", + "focus": true + } + }, + { + "type": "shell", + "label": "test: full suite with checks", "dependsOrder": "sequence", "dependsOn": [ "go: vet workspace", - "go: test workspace" + "test: unit tests (all)", + "test: integration tests (automated)" ], "problemMatcher": [], - "group": { - "kind": "test", - "isDefault": false + "group": "test", + "presentation": { + "reveal": "always", + "panel": "dedicated" } }, { "type": "shell", "label": "Make Release", "problemMatcher": [], - "command": "sh ${workspaceFolder}/make_release.sh", + "command": "sh ${workspaceFolder}/make_release.sh" } ] -} \ No newline at end of file +} diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md deleted file mode 100644 index 46a7b00..0000000 --- a/MIGRATION_GUIDE.md +++ /dev/null @@ -1,173 +0,0 @@ -# Migration Guide: Database and Router Abstraction - -This guide explains how to migrate from the direct GORM/Router dependencies to the new abstracted interfaces. - -## Overview of Changes - -### What was changed: -1. **Database Operations**: GORM-specific code is now abstracted behind `Database` interface -2. **Router Integration**: HTTP router dependencies are abstracted behind `Router` interface -3. **Model Registry**: Models are now managed through a `ModelRegistry` interface -4. **Backward Compatibility**: Existing code continues to work with `NewAPIHandler()` - -### Benefits: -- **Database Flexibility**: Switch between GORM, Bun, or other ORMs without code changes -- **Router Flexibility**: Use Gorilla Mux, Gin, Echo, or other routers -- **Better Testing**: Easy to mock database and router interactions -- **Cleaner Separation**: Business logic separated from ORM/router specifics - -## Migration Path - -### Option 1: No Changes Required (Backward Compatible) -Your existing code continues to work without any changes: - -```go -// This still works exactly as before -handler := resolvespec.NewAPIHandler(db) -``` - -### Option 2: Gradual Migration to New API - -#### Step 1: Use New Handler Constructor -```go -// Old way -handler := resolvespec.NewAPIHandler(gormDB) - -// New way -handler := resolvespec.NewHandlerWithGORM(gormDB) -``` - -#### Step 2: Use Interface-based Approach -```go -// Create database adapter -dbAdapter := resolvespec.NewGormAdapter(gormDB) - -// Create model registry -registry := resolvespec.NewModelRegistry() - -// Register your models -registry.RegisterModel("public.users", &User{}) -registry.RegisterModel("public.orders", &Order{}) - -// Create handler -handler := resolvespec.NewHandler(dbAdapter, registry) -``` - -## Switching Database Backends - -### From GORM to Bun -```go -// Add bun dependency first: -// go get github.com/uptrace/bun - -// Old GORM setup -gormDB, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{}) -gormAdapter := resolvespec.NewGormAdapter(gormDB) - -// New Bun setup -sqlDB, _ := sql.Open("sqlite3", "test.db") -bunDB := bun.NewDB(sqlDB, sqlitedialect.New()) -bunAdapter := resolvespec.NewBunAdapter(bunDB) - -// Handler creation is identical -handler := resolvespec.NewHandler(bunAdapter, registry) -``` - -## Router Flexibility - -### Current Gorilla Mux (Default) -```go -router := mux.NewRouter() -resolvespec.SetupRoutes(router, handler) -``` - -### BunRouter (Built-in Support) -```go -// Simple setup -router := bunrouter.New() -resolvespec.SetupBunRouterWithResolveSpec(router, handler) - -// Or using adapter -routerAdapter := resolvespec.NewStandardBunRouterAdapter() -// Use routerAdapter.GetBunRouter() for the underlying router -``` - -### Using Router Adapters (Advanced) -```go -// For when you want router abstraction -routerAdapter := resolvespec.NewStandardRouter() -routerAdapter.RegisterRoute("/{schema}/{entity}", handlerFunc) -``` - -## Model Registration - -### Old Way (Still Works) -```go -// Models registered through existing models package -handler.RegisterModel("public", "users", &User{}) -``` - -### New Way (Recommended) -```go -registry := resolvespec.NewModelRegistry() -registry.RegisterModel("public.users", &User{}) -registry.RegisterModel("public.orders", &Order{}) - -handler := resolvespec.NewHandler(dbAdapter, registry) -``` - -## Interface Definitions - -### Database Interface -```go -type Database interface { - NewSelect() SelectQuery - NewInsert() InsertQuery - NewUpdate() UpdateQuery - NewDelete() DeleteQuery - // ... transaction methods -} -``` - -### Available Adapters -- `GormAdapter` - For GORM (ready to use) -- `BunAdapter` - For Bun (add dependency: `go get github.com/uptrace/bun`) -- Easy to create custom adapters for other ORMs - -## Testing Benefits - -### Before (Tightly Coupled) -```go -// Hard to test - requires real GORM setup -func TestHandler(t *testing.T) { - db := setupRealGormDB() - handler := resolvespec.NewAPIHandler(db) - // ... test logic -} -``` - -### After (Mockable) -```go -// Easy to test - mock the interfaces -func TestHandler(t *testing.T) { - mockDB := &MockDatabase{} - mockRegistry := &MockModelRegistry{} - handler := resolvespec.NewHandler(mockDB, mockRegistry) - // ... test logic with mocks -} -``` - -## Breaking Changes -- **None for existing code** - Full backward compatibility maintained -- New interfaces are additive, not replacing existing APIs - -## Recommended Migration Timeline -1. **Phase 1**: Use existing code (no changes needed) -2. **Phase 2**: Gradually adopt new constructors (`NewHandlerWithGORM`) -3. **Phase 3**: Move to interface-based approach when needed -4. **Phase 4**: Switch database backends if desired - -## Getting Help -- Check example functions in `resolvespec.go` -- Review interface definitions in `database.go` -- Examine adapter implementations for patterns \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bba4a99 --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +.PHONY: test test-unit test-integration docker-up docker-down clean + +# Run all unit tests +test-unit: + @echo "Running unit tests..." + @go test ./pkg/resolvespec ./pkg/restheadspec -v -cover + +# Run all integration tests (requires PostgreSQL) +test-integration: + @echo "Running integration tests..." + @go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v + +# Run all tests (unit + integration) +test: test-unit test-integration + +# Start PostgreSQL for integration tests +docker-up: + @echo "Starting PostgreSQL container..." + @docker-compose up -d postgres-test + @echo "Waiting for PostgreSQL to be ready..." + @sleep 5 + @echo "PostgreSQL is ready!" + +# Stop PostgreSQL container +docker-down: + @echo "Stopping PostgreSQL container..." + @docker-compose down + +# Clean up Docker volumes and test data +clean: + @echo "Cleaning up..." + @docker-compose down -v + @echo "Cleanup complete!" + +# Run integration tests with Docker (full workflow) +test-integration-docker: docker-up + @echo "Running integration tests with Docker..." + @go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v + @$(MAKE) docker-down + +# Check test coverage +coverage: + @echo "Generating coverage report..." + @go test ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage.out + @go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +# Run integration tests coverage +coverage-integration: + @echo "Generating integration test coverage report..." + @go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -coverprofile=coverage-integration.out + @go tool cover -html=coverage-integration.out -o coverage-integration.html + @echo "Integration coverage report generated: coverage-integration.html" + +help: + @echo "Available targets:" + @echo " test-unit - Run unit tests" + @echo " test-integration - Run integration tests (requires PostgreSQL)" + @echo " test - Run all tests" + @echo " docker-up - Start PostgreSQL container" + @echo " docker-down - Stop PostgreSQL container" + @echo " test-integration-docker - Run integration tests with Docker (automated)" + @echo " clean - Clean up Docker volumes" + @echo " coverage - Generate unit test coverage report" + @echo " coverage-integration - Generate integration test coverage report" + @echo " help - Show this help message" diff --git a/README.md b/README.md index dd9115a..174322a 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,83 @@ # ๐Ÿ“œ ResolveSpec ๐Ÿ“œ -![Tests](https://github.com/bitechdev/ResolveSpec/workflows/Tests/badge.svg) +![1.00](https://github.com/bitechdev/ResolveSpec/workflows/Tests/badge.svg) ResolveSpec is a flexible and powerful REST API specification and implementation that provides GraphQL-like capabilities while maintaining REST simplicity. It offers **two complementary approaches**: 1. **ResolveSpec** - Body-based API with JSON request options 2. **RestHeadSpec** - Header-based API where query options are passed via HTTP headers +3. **FuncSpec** - Header-based API to map and call API's to sql functions. Both share the same core architecture and provide dynamic data querying, relationship preloading, and complex filtering. -**๐Ÿ†• New in v2.0**: Database-agnostic architecture with support for GORM, Bun, and other ORMs. Router-flexible design works with Gorilla Mux, Gin, Echo, and more. +Documentation Generated by LLMs -**๐Ÿ†• New in v2.1**: RestHeadSpec (HeaderSpec) - Header-based REST API with lifecycle hooks, cursor pagination, and advanced filtering. - -**๐Ÿ†• New in v3.0**: Explicit route registration - Routes are now created per registered model for better flexibility and control. OPTIONS method support with full CORS headers for cross-origin requests. - -![slogan](./generated_slogan.webp) +![1.00](./generated_slogan.webp) ## Table of Contents -- [Features](#features) -- [Installation](#installation) -- [Quick Start](#quick-start) - - [ResolveSpec (Body-Based API)](#resolvespec-body-based-api) - - [RestHeadSpec (Header-Based API)](#restheadspec-header-based-api) - - [Existing Code (Backward Compatible)](#option-1-existing-code-backward-compatible) - - [New Database-Agnostic API](#option-2-new-database-agnostic-api) - - [Router Integration](#router-integration) -- [Migration from v1.x](#migration-from-v1x) -- [Architecture](#architecture) -- [API Structure](#api-structure) -- [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1) - - [Lifecycle Hooks](#lifecycle-hooks) - - [Cursor Pagination](#cursor-pagination) - - [Response Formats](#response-formats) - - [Single Record as Object](#single-record-as-object-default-behavior) -- [Example Usage](#example-usage) - - [Recursive CRUD Operations](#recursive-crud-operations-) -- [Testing](#testing) -- [What's New](#whats-new) +* [Features](#features) +* [Installation](#installation) +* [Quick Start](#quick-start) + * [ResolveSpec (Body-Based API)](#resolvespec-body-based-api) + * [RestHeadSpec (Header-Based API)](#restheadspec-header-based-api) + * [Existing Code (Backward Compatible)](#option-1-existing-code-backward-compatible) + * [New Database-Agnostic API](#option-2-new-database-agnostic-api) + * [Router Integration](#router-integration) +* [Migration from v1.x](#migration-from-v1x) +* [Architecture](#architecture) +* [API Structure](#api-structure) +* [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1) + * [Lifecycle Hooks](#lifecycle-hooks) + * [Cursor Pagination](#cursor-pagination) + * [Response Formats](#response-formats) + * [Single Record as Object](#single-record-as-object-default-behavior) +* [Example Usage](#example-usage) + * [Recursive CRUD Operations](#recursive-crud-operations-) +* [Testing](#testing) +* [What's New](#whats-new) ## Features ### Core Features -- **Dynamic Data Querying**: Select specific columns and relationships to return -- **Relationship Preloading**: Load related entities with custom column selection and filters -- **Complex Filtering**: Apply multiple filters with various operators -- **Sorting**: Multi-column sort support -- **Pagination**: Built-in limit/offset and cursor-based pagination -- **Computed Columns**: Define virtual columns for complex calculations -- **Custom Operators**: Add custom SQL conditions when needed -- **๐Ÿ†• Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field + +* **Dynamic Data Querying**: Select specific columns and relationships to return +* **Relationship Preloading**: Load related entities with custom column selection and filters +* **Complex Filtering**: Apply multiple filters with various operators +* **Sorting**: Multi-column sort support +* **Pagination**: Built-in limit/offset and cursor-based pagination +* **Computed Columns**: Define virtual columns for complex calculations +* **Custom Operators**: Add custom SQL conditions when needed +* **๐Ÿ†• Recursive CRUD Handler**: Automatically handle nested object graphs with foreign key resolution and per-record operation control via `_request` field ### Architecture (v2.0+) -- **๐Ÿ†• Database Agnostic**: Works with GORM, Bun, or any database layer through adapters -- **๐Ÿ†• Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers -- **๐Ÿ†• Backward Compatible**: Existing code works without changes -- **๐Ÿ†• Better Testing**: Mockable interfaces for easy unit testing + +* **๐Ÿ†• Database Agnostic**: Works with GORM, Bun, or any database layer through adapters +* **๐Ÿ†• Router Flexible**: Integrates with Gorilla Mux, Gin, Echo, or custom routers +* **๐Ÿ†• Backward Compatible**: Existing code works without changes +* **๐Ÿ†• Better Testing**: Mockable interfaces for easy unit testing ### RestHeadSpec (v2.1+) -- **๐Ÿ†• Header-Based API**: All query options passed via HTTP headers instead of request body -- **๐Ÿ†• Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations -- **๐Ÿ†• Cursor Pagination**: Efficient cursor-based pagination with complex sort support -- **๐Ÿ†• Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats -- **๐Ÿ†• Single Record as Object**: Automatically normalize single-element arrays to objects (enabled by default) -- **๐Ÿ†• Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL -- **๐Ÿ†• Base64 Encoding**: Support for base64-encoded header values + +* **๐Ÿ†• Header-Based API**: All query options passed via HTTP headers instead of request body +* **๐Ÿ†• Lifecycle Hooks**: Before/after hooks for create, read, update, and delete operations +* **๐Ÿ†• Cursor Pagination**: Efficient cursor-based pagination with complex sort support +* **๐Ÿ†• Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible formats +* **๐Ÿ†• Single Record as Object**: Automatically normalize single-element arrays to objects (enabled by default) +* **๐Ÿ†• Advanced Filtering**: Field filters, search operators, AND/OR logic, and custom SQL +* **๐Ÿ†• Base64 Encoding**: Support for base64-encoded header values ### Routing & CORS (v3.0+) -- **๐Ÿ†• Explicit Route Registration**: Routes created per registered model instead of dynamic lookups -- **๐Ÿ†• OPTIONS Method Support**: Full OPTIONS method support returning model metadata -- **๐Ÿ†• CORS Headers**: Comprehensive CORS support with all HeadSpec headers allowed -- **๐Ÿ†• Better Route Control**: Customize routes per model with more flexibility + +* **๐Ÿ†• Explicit Route Registration**: Routes created per registered model instead of dynamic lookups +* **๐Ÿ†• OPTIONS Method Support**: Full OPTIONS method support returning model metadata +* **๐Ÿ†• CORS Headers**: Comprehensive CORS support with all HeadSpec headers allowed +* **๐Ÿ†• Better Route Control**: Customize routes per model with more flexibility ## API Structure ### URL Patterns + ``` /[schema]/[table_or_entity]/[id] /[schema]/[table_or_entity] @@ -85,7 +87,7 @@ Both share the same core architecture and provide dynamic data querying, relatio ### Request Format -```json +```JSON { "operation": "read|create|update|delete", "data": { @@ -110,7 +112,7 @@ RestHeadSpec provides an alternative REST API approach where all query options a ### Quick Example -```http +```HTTP GET /public/users HTTP/1.1 Host: api.example.com X-Select-Fields: id,name,email,department_id @@ -124,7 +126,7 @@ X-DetailApi: true ### Setup with GORM -```go +```Go import "github.com/bitechdev/ResolveSpec/pkg/restheadspec" import "github.com/gorilla/mux" @@ -147,7 +149,7 @@ http.ListenAndServe(":8080", router) ### Setup with Bun ORM -```go +```Go import "github.com/bitechdev/ResolveSpec/pkg/restheadspec" import "github.com/uptrace/bun" @@ -164,19 +166,19 @@ restheadspec.SetupMuxRoutes(router, handler) ### Common Headers -| Header | Description | Example | -|--------|-------------|---------| -| `X-Select-Fields` | Columns to include | `id,name,email` | -| `X-Not-Select-Fields` | Columns to exclude | `password,internal_notes` | -| `X-FieldFilter-{col}` | Exact match filter | `X-FieldFilter-Status: active` | -| `X-SearchFilter-{col}` | Fuzzy search (ILIKE) | `X-SearchFilter-Name: john` | -| `X-SearchOp-{op}-{col}` | Filter with operator | `X-SearchOp-Gte-Age: 18` | -| `X-Preload` | Preload relations | `posts:id,title` | -| `X-Sort` | Sort columns | `-created_at,+name` | -| `X-Limit` | Limit results | `50` | -| `X-Offset` | Offset for pagination | `100` | -| `X-Clean-JSON` | Remove null/empty fields | `true` | -| `X-Single-Record-As-Object` | Return single records as objects (default: `true`) | `false` | +| Header | Description | Example | +| --------------------------- | -------------------------------------------------- | ------------------------------ | +| `X-Select-Fields` | Columns to include | `id,name,email` | +| `X-Not-Select-Fields` | Columns to exclude | `password,internal_notes` | +| `X-FieldFilter-{col}` | Exact match filter | `X-FieldFilter-Status: active` | +| `X-SearchFilter-{col}` | Fuzzy search (ILIKE) | `X-SearchFilter-Name: john` | +| `X-SearchOp-{op}-{col}` | Filter with operator | `X-SearchOp-Gte-Age: 18` | +| `X-Preload` | Preload relations | `posts:id,title` | +| `X-Sort` | Sort columns | `-created_at,+name` | +| `X-Limit` | Limit results | `50` | +| `X-Offset` | Offset for pagination | `100` | +| `X-Clean-JSON` | Remove null/empty fields | `true` | +| `X-Single-Record-As-Object` | Return single records as objects (default: `true`) | `false` | **Available Operators**: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `startswith`, `endswith`, `between`, `betweeninclusive`, `in`, `empty`, `notempty` @@ -187,11 +189,14 @@ For complete header documentation, see [pkg/restheadspec/HEADERS.md](pkg/resthea ResolveSpec and RestHeadSpec include comprehensive CORS support for cross-origin requests: **OPTIONS Method**: -```http + +```HTTP OPTIONS /public/users HTTP/1.1 ``` + Returns metadata with appropriate CORS headers: -```http + +```HTTP Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization, X-Select-Fields, X-FieldFilter-*, ... @@ -200,14 +205,16 @@ Access-Control-Allow-Credentials: true ``` **Key Features**: -- OPTIONS returns model metadata (same as GET metadata endpoint) -- All HTTP methods include CORS headers automatically -- OPTIONS requests don't require authentication (CORS preflight) -- Supports all HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) -- 24-hour max age to reduce preflight requests + +* OPTIONS returns model metadata (same as GET metadata endpoint) +* All HTTP methods include CORS headers automatically +* OPTIONS requests don't require authentication (CORS preflight) +* Supports all HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) +* 24-hour max age to reduce preflight requests **Configuration**: -```go + +```Go import "github.com/bitechdev/ResolveSpec/pkg/common" // Get default CORS config @@ -222,7 +229,7 @@ corsConfig.AllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} RestHeadSpec supports lifecycle hooks for all CRUD operations: -```go +```Go import "github.com/bitechdev/ResolveSpec/pkg/restheadspec" // Create handler @@ -267,27 +274,29 @@ handler.Hooks.Register(restheadspec.BeforeCreate, func(ctx *restheadspec.HookCon ``` **Available Hook Types**: -- `BeforeRead`, `AfterRead` -- `BeforeCreate`, `AfterCreate` -- `BeforeUpdate`, `AfterUpdate` -- `BeforeDelete`, `AfterDelete` + +* `BeforeRead`, `AfterRead` +* `BeforeCreate`, `AfterCreate` +* `BeforeUpdate`, `AfterUpdate` +* `BeforeDelete`, `AfterDelete` **HookContext** provides: -- `Context`: Request context -- `Handler`: Access to handler, database, and registry -- `Schema`, `Entity`, `TableName`: Request info -- `Model`: The registered model type -- `Options`: Parsed request options (filters, sorting, etc.) -- `ID`: Record ID (for single-record operations) -- `Data`: Request data (for create/update) -- `Result`: Operation result (for after hooks) -- `Writer`: Response writer (allows hooks to modify response) + +* `Context`: Request context +* `Handler`: Access to handler, database, and registry +* `Schema`, `Entity`, `TableName`: Request info +* `Model`: The registered model type +* `Options`: Parsed request options (filters, sorting, etc.) +* `ID`: Record ID (for single-record operations) +* `Data`: Request data (for create/update) +* `Result`: Operation result (for after hooks) +* `Writer`: Response writer (allows hooks to modify response) ### Cursor Pagination RestHeadSpec supports efficient cursor-based pagination for large datasets: -```http +```HTTP GET /public/posts HTTP/1.1 X-Sort: -created_at,+id X-Limit: 50 @@ -295,20 +304,22 @@ X-Cursor-Forward: ``` **How it works**: + 1. First request returns results + cursor token in response 2. Subsequent requests use `X-Cursor-Forward` or `X-Cursor-Backward` 3. Cursor maintains consistent ordering even with data changes 4. Supports complex multi-column sorting **Benefits over offset pagination**: -- Consistent results when data changes -- Better performance for large offsets -- Prevents "skipped" or duplicate records -- Works with complex sort expressions + +* Consistent results when data changes +* Better performance for large offsets +* Prevents "skipped" or duplicate records +* Works with complex sort expressions **Example with hooks**: -```go +```Go // Enable cursor pagination in a hook handler.Hooks.Register(restheadspec.BeforeRead, func(ctx *restheadspec.HookContext) error { // For large tables, enforce cursor pagination @@ -324,7 +335,8 @@ handler.Hooks.Register(restheadspec.BeforeRead, func(ctx *restheadspec.HookConte RestHeadSpec supports multiple response formats: **1. Simple Format** (`X-SimpleApi: true`): -```json + +```JSON [ { "id": 1, "name": "John" }, { "id": 2, "name": "Jane" } @@ -332,7 +344,8 @@ RestHeadSpec supports multiple response formats: ``` **2. Detail Format** (`X-DetailApi: true`, default): -```json + +```JSON { "success": true, "data": [...], @@ -346,7 +359,8 @@ RestHeadSpec supports multiple response formats: ``` **3. Syncfusion Format** (`X-Syncfusion: true`): -```json + +```JSON { "result": [...], "count": 100 @@ -358,10 +372,12 @@ RestHeadSpec supports multiple response formats: By default, RestHeadSpec automatically converts single-element arrays into objects for cleaner API responses. This provides a better developer experience when fetching individual records. **Default behavior (enabled)**: -```http + +```HTTP GET /public/users/123 ``` -```json + +```JSON { "success": true, "data": { "id": 123, "name": "John", "email": "john@example.com" } @@ -369,7 +385,8 @@ GET /public/users/123 ``` Instead of: -```json + +```JSON { "success": true, "data": [{ "id": 123, "name": "John", "email": "john@example.com" }] @@ -377,11 +394,13 @@ Instead of: ``` **To disable** (force arrays for consistency): -```http + +```HTTP GET /public/users/123 X-Single-Record-As-Object: false ``` -```json + +```JSON { "success": true, "data": [{ "id": 123, "name": "John", "email": "john@example.com" }] @@ -389,23 +408,26 @@ X-Single-Record-As-Object: false ``` **How it works**: -- When a query returns exactly **one record**, it's returned as an object -- When a query returns **multiple records**, they're returned as an array -- Set `X-Single-Record-As-Object: false` to always receive arrays -- Works with all response formats (simple, detail, syncfusion) -- Applies to both read operations and create/update returning clauses + +* When a query returns exactly **one record**, it's returned as an object +* When a query returns **multiple records**, they're returned as an array +* Set `X-Single-Record-As-Object: false` to always receive arrays +* Works with all response formats (simple, detail, syncfusion) +* Applies to both read operations and create/update returning clauses **Benefits**: -- Cleaner API responses for single-record queries -- No need to unwrap single-element arrays on the client side -- Better TypeScript/type inference support -- Consistent with common REST API patterns -- Backward compatible via header opt-out + +* Cleaner API responses for single-record queries +* No need to unwrap single-element arrays on the client side +* Better TypeScript/type inference support +* Consistent with common REST API patterns +* Backward compatible via header opt-out ## Example Usage ### Reading Data with Related Entities -```json + +```JSON POST /core/users { "operation": "read", @@ -449,7 +471,7 @@ ResolveSpec now supports automatic handling of nested object graphs with intelli #### Creating Nested Objects -```json +```JSON POST /core/users { "operation": "create", @@ -482,7 +504,7 @@ POST /core/users Control individual operations for each nested record using the special `_request` field: -```json +```JSON POST /core/users/123 { "operation": "update", @@ -508,11 +530,12 @@ POST /core/users/123 } ``` -**Supported `_request` values**: -- `insert` - Create a new related record -- `update` - Update an existing related record -- `delete` - Delete a related record -- `upsert` - Create if doesn't exist, update if exists +**Supported** **`_request`** **values**: + +* `insert` - Create a new related record +* `update` - Update an existing related record +* `delete` - Delete a related record +* `upsert` - Create if doesn't exist, update if exists #### How It Works @@ -524,14 +547,14 @@ POST /core/users/123 #### Benefits -- Reduce API round trips for complex object graphs -- Maintain referential integrity automatically -- Simplify client-side code -- Atomic operations with automatic rollback on errors +* Reduce API round trips for complex object graphs +* Maintain referential integrity automatically +* Simplify client-side code +* Atomic operations with automatic rollback on errors ## Installation -```bash +```Shell go get github.com/bitechdev/ResolveSpec ``` @@ -541,7 +564,7 @@ go get github.com/bitechdev/ResolveSpec ResolveSpec uses JSON request bodies to specify query options: -```go +```Go import "github.com/bitechdev/ResolveSpec/pkg/resolvespec" // Create handler @@ -568,7 +591,7 @@ resolvespec.SetupRoutes(router, handler) RestHeadSpec uses HTTP headers for query options instead of request body: -```go +```Go import "github.com/bitechdev/ResolveSpec/pkg/restheadspec" // Create handler with GORM @@ -597,7 +620,7 @@ See [RestHeadSpec: Header-Based API](#restheadspec-header-based-api-1) for compl Your existing code continues to work without any changes: -```go +```Go import "github.com/bitechdev/ResolveSpec/pkg/resolvespec" // This still works exactly as before @@ -615,7 +638,7 @@ ResolveSpec v2.0 introduces a new database and router abstraction layer while ma To update your imports: -```bash +```Shell # Update go.mod go mod edit -replace github.com/Warky-Devs/ResolveSpec=github.com/bitechdev/ResolveSpec@latest go mod tidy @@ -627,7 +650,7 @@ go mod tidy Alternatively, use find and replace in your project: -```bash +```Shell find . -type f -name "*.go" -exec sed -i 's|github.com/Warky-Devs/ResolveSpec|github.com/bitechdev/ResolveSpec|g' {} + go mod tidy ``` @@ -642,7 +665,7 @@ go mod tidy ### Detailed Migration Guide -For detailed migration instructions, examples, and best practices, see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md). +For detailed migration instructions, examples, and best practices, see [MIGRATION\_GUIDE.md](MIGRATION_GUIDE.md). ## Architecture @@ -684,22 +707,23 @@ Your Application Code ### Supported Database Layers -- **GORM** (default, fully supported) -- **Bun** (ready to use, included in dependencies) -- **Custom ORMs** (implement the `Database` interface) +* **GORM** (default, fully supported) +* **Bun** (ready to use, included in dependencies) +* **Custom ORMs** (implement the `Database` interface) ### Supported Routers -- **Gorilla Mux** (built-in support with `SetupRoutes()`) -- **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`) -- **Gin** (manual integration, see examples above) -- **Echo** (manual integration, see examples above) -- **Custom Routers** (implement request/response adapters) +* **Gorilla Mux** (built-in support with `SetupRoutes()`) +* **BunRouter** (built-in support with `SetupBunRouterWithResolveSpec()`) +* **Gin** (manual integration, see examples above) +* **Echo** (manual integration, see examples above) +* **Custom Routers** (implement request/response adapters) ### Option 2: New Database-Agnostic API #### With GORM (Recommended Migration Path) -```go + +```Go import "github.com/bitechdev/ResolveSpec/pkg/resolvespec" // Create database adapter @@ -715,7 +739,8 @@ handler := resolvespec.NewHandler(dbAdapter, registry) ``` #### With Bun ORM -```go + +```Go import "github.com/bitechdev/ResolveSpec/pkg/resolvespec" import "github.com/uptrace/bun" @@ -730,7 +755,8 @@ handler := resolvespec.NewHandler(dbAdapter, registry) ### Router Integration #### Gorilla Mux (Built-in Support) -```go + +```Go import "github.com/gorilla/mux" // Register models first @@ -746,7 +772,8 @@ resolvespec.SetupMuxRoutes(router, handler, nil) ``` #### Gin (Custom Integration) -```go + +```Go import "github.com/gin-gonic/gin" func setupGin(handler *resolvespec.Handler) *gin.Engine { @@ -769,7 +796,8 @@ func setupGin(handler *resolvespec.Handler) *gin.Engine { ``` #### Echo (Custom Integration) -```go + +```Go import "github.com/labstack/echo/v4" func setupEcho(handler *resolvespec.Handler) *echo.Echo { @@ -792,7 +820,8 @@ func setupEcho(handler *resolvespec.Handler) *echo.Echo { ``` #### BunRouter (Built-in Support) -```go + +```Go import "github.com/uptrace/bunrouter" // Simple setup with built-in function @@ -837,7 +866,8 @@ func setupFullUptrace(bunDB *bun.DB) *bunrouter.Router { ## Configuration ### Model Registration -```go + +```Go type User struct { ID uint `json:"id" gorm:"primaryKey"` Name string `json:"name"` @@ -851,20 +881,24 @@ handler.RegisterModel("core", "users", &User{}) ## Features in Detail ### Filtering + Supported operators: -- eq: Equal -- neq: Not Equal -- gt: Greater Than -- gte: Greater Than or Equal -- lt: Less Than -- lte: Less Than or Equal -- like: LIKE pattern matching -- ilike: Case-insensitive LIKE -- in: IN clause + +* eq: Equal +* neq: Not Equal +* gt: Greater Than +* gte: Greater Than or Equal +* lt: Less Than +* lte: Less Than or Equal +* like: LIKE pattern matching +* ilike: Case-insensitive LIKE +* in: IN clause ### Sorting + Support for multiple sort criteria with direction: -```json + +```JSON "sort": [ { "column": "created_at", @@ -878,8 +912,10 @@ Support for multiple sort criteria with direction: ``` ### Computed Columns + Define virtual columns using SQL expressions: -```json + +```JSON "computedColumns": [ { "name": "full_name", @@ -892,7 +928,7 @@ Define virtual columns using SQL expressions: ### With New Architecture (Mockable) -```go +```Go import "github.com/stretchr/testify/mock" // Create mock database @@ -927,14 +963,14 @@ ResolveSpec uses GitHub Actions for automated testing and quality checks. The CI The project includes automated workflows that: -- **Test**: Run all tests with race detection and code coverage -- **Lint**: Check code quality with golangci-lint -- **Build**: Verify the project builds successfully -- **Multi-version**: Test against multiple Go versions (1.23.x, 1.24.x) +* **Test**: Run all tests with race detection and code coverage +* **Lint**: Check code quality with golangci-lint +* **Build**: Verify the project builds successfully +* **Multi-version**: Test against multiple Go versions (1.23.x, 1.24.x) ### Running Tests Locally -```bash +```Shell # Run all tests go test -v ./... @@ -952,13 +988,13 @@ golangci-lint run The project includes comprehensive test coverage: -- **Unit Tests**: Individual component testing -- **Integration Tests**: End-to-end API testing -- **CRUD Tests**: Standalone tests for both ResolveSpec and RestHeadSpec APIs +* **Unit Tests**: Individual component testing +* **Integration Tests**: End-to-end API testing +* **CRUD Tests**: Standalone tests for both ResolveSpec and RestHeadSpec APIs To run only the CRUD standalone tests: -```bash +```Shell go test -v ./tests -run TestCRUDStandalone ``` @@ -970,18 +1006,18 @@ Check the [Actions tab](../../actions) on GitHub to see the status of recent CI Add this badge to display CI status in your fork: -```markdown +```Markdown ![Tests](https://github.com/bitechdev/ResolveSpec/workflows/Tests/badge.svg) ``` ## Security Considerations -- Implement proper authentication and authorization -- Validate all input parameters -- Use prepared statements (handled by GORM/Bun/your ORM) -- Implement rate limiting -- Control access at schema/entity level -- **New**: Database abstraction layer provides additional security through interface boundaries +* Implement proper authentication and authorization +* Validate all input parameters +* Use prepared statements (handled by GORM/Bun/your ORM) +* Implement rate limiting +* Control access at schema/entity level +* **New**: Database abstraction layer provides additional security through interface boundaries ## Contributing @@ -993,94 +1029,106 @@ Add this badge to display CI status in your fork: ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## What's New ### v3.0 (Latest - December 2025) **Explicit Route Registration (๐Ÿ†•)**: -- **Breaking Change**: Routes are now created explicitly for each registered model -- **Better Control**: Customize routes per model with more flexibility -- **Registration Order**: Models must be registered BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes -- **Benefits**: More flexible routing, easier to add custom routes per model, better performance + +* **Breaking Change**: Routes are now created explicitly for each registered model +* **Better Control**: Customize routes per model with more flexibility +* **Registration Order**: Models must be registered BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes +* **Benefits**: More flexible routing, easier to add custom routes per model, better performance **OPTIONS Method & CORS Support (๐Ÿ†•)**: -- **OPTIONS Endpoint**: Full OPTIONS method support for CORS preflight requests -- **Metadata Response**: OPTIONS returns model metadata (same as GET /metadata) -- **CORS Headers**: Comprehensive CORS headers on all responses -- **Header Support**: All HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) allowed -- **No Auth on OPTIONS**: CORS preflight requests don't require authentication -- **Configurable**: Customize CORS settings via `common.CORSConfig` + +* **OPTIONS Endpoint**: Full OPTIONS method support for CORS preflight requests +* **Metadata Response**: OPTIONS returns model metadata (same as GET /metadata) +* **CORS Headers**: Comprehensive CORS headers on all responses +* **Header Support**: All HeadSpec custom headers (`X-Select-Fields`, `X-FieldFilter-*`, etc.) allowed +* **No Auth on OPTIONS**: CORS preflight requests don't require authentication +* **Configurable**: Customize CORS settings via `common.CORSConfig` **Migration Notes**: -- Update your code to register models BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes -- Routes like `/public/users` are now created per registered model instead of using dynamic `/{schema}/{entity}` pattern -- This is a **breaking change** but provides better control and flexibility + +* Update your code to register models BEFORE calling SetupMuxRoutes/SetupBunRouterRoutes +* Routes like `/public/users` are now created per registered model instead of using dynamic `/{schema}/{entity}` pattern +* This is a **breaking change** but provides better control and flexibility ### v2.1 **Recursive CRUD Handler (๐Ÿ†• Nov 11, 2025)**: -- **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships -- **Foreign Key Resolution**: Automatic propagation of parent IDs to child records -- **Per-Record Operations**: Control create/update/delete operations per record via `_request` field -- **Transaction Safety**: All nested operations execute atomically within database transactions -- **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships -- **Deep Nesting Support**: Handle relationships at any depth level -- **Mixed Operations**: Combine insert, update, and delete operations in a single request + +* **Nested Object Graphs**: Automatically handle complex object hierarchies with parent-child relationships +* **Foreign Key Resolution**: Automatic propagation of parent IDs to child records +* **Per-Record Operations**: Control create/update/delete operations per record via `_request` field +* **Transaction Safety**: All nested operations execute atomically within database transactions +* **Relationship Detection**: Automatic detection of belongsTo, hasMany, hasOne, and many2many relationships +* **Deep Nesting Support**: Handle relationships at any depth level +* **Mixed Operations**: Combine insert, update, and delete operations in a single request **Primary Key Improvements (Nov 11, 2025)**: -- **GetPrimaryKeyName**: Enhanced primary key detection for better preload and ID field handling -- **Better GORM/Bun Support**: Improved compatibility with both ORMs for primary key operations -- **Computed Column Support**: Fixed computed columns functionality across handlers + +* **GetPrimaryKeyName**: Enhanced primary key detection for better preload and ID field handling +* **Better GORM/Bun Support**: Improved compatibility with both ORMs for primary key operations +* **Computed Column Support**: Fixed computed columns functionality across handlers **Database Adapter Enhancements (Nov 11, 2025)**: -- **Bun ORM Relations**: Using Scan model method for better has-many and many-to-many relationship handling -- **Model Method Support**: Enhanced query building with proper model registration -- **Improved Type Safety**: Better handling of relationship queries with type-aware scanning + +* **Bun ORM Relations**: Using Scan model method for better has-many and many-to-many relationship handling +* **Model Method Support**: Enhanced query building with proper model registration +* **Improved Type Safety**: Better handling of relationship queries with type-aware scanning **RestHeadSpec - Header-Based REST API**: -- **Header-Based Querying**: All query options via HTTP headers instead of request body -- **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations -- **Cursor Pagination**: Efficient cursor-based pagination with complex sorting -- **Advanced Filtering**: Field filters, search operators, AND/OR logic -- **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses -- **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header) -- **Base64 Support**: Base64-encoded header values for complex queries -- **Type-Aware Filtering**: Automatic type detection and conversion for filters + +* **Header-Based Querying**: All query options via HTTP headers instead of request body +* **Lifecycle Hooks**: Before/after hooks for create, read, update, delete operations +* **Cursor Pagination**: Efficient cursor-based pagination with complex sorting +* **Advanced Filtering**: Field filters, search operators, AND/OR logic +* **Multiple Response Formats**: Simple, detailed, and Syncfusion-compatible responses +* **Single Record as Object**: Automatically return single-element arrays as objects (default, toggleable via header) +* **Base64 Support**: Base64-encoded header values for complex queries +* **Type-Aware Filtering**: Automatic type detection and conversion for filters **Core Improvements**: -- Better model registry with schema.table format support -- Enhanced validation and error handling -- Improved reflection safety -- Fixed COUNT query issues with table aliasing -- Better pointer handling throughout the codebase -- **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec + +* Better model registry with schema.table format support +* Enhanced validation and error handling +* Improved reflection safety +* Fixed COUNT query issues with table aliasing +* Better pointer handling throughout the codebase +* **Comprehensive Test Coverage**: Added standalone CRUD tests for both ResolveSpec and RestHeadSpec ### v2.0 **Breaking Changes**: -- **None!** Full backward compatibility maintained + +* **None!** Full backward compatibility maintained **New Features**: -- **Database Abstraction**: Support for GORM, Bun, and custom ORMs -- **Router Flexibility**: Works with any HTTP router through adapters -- **BunRouter Integration**: Built-in support for uptrace/bunrouter -- **Better Architecture**: Clean separation of concerns with interfaces -- **Enhanced Testing**: Mockable interfaces for comprehensive testing -- **Migration Guide**: Step-by-step migration instructions + +* **Database Abstraction**: Support for GORM, Bun, and custom ORMs +* **Router Flexibility**: Works with any HTTP router through adapters +* **BunRouter Integration**: Built-in support for uptrace/bunrouter +* **Better Architecture**: Clean separation of concerns with interfaces +* **Enhanced Testing**: Mockable interfaces for comprehensive testing +* **Migration Guide**: Step-by-step migration instructions **Performance Improvements**: -- More efficient query building through interface design -- Reduced coupling between components -- Better memory management with interface boundaries + +* More efficient query building through interface design +* Reduced coupling between components +* Better memory management with interface boundaries ## Acknowledgments -- Inspired by REST, OData, and GraphQL's flexibility -- **Header-based approach**: Inspired by REST best practices and clean API design -- **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/) -- **Router Support**: Gorilla Mux (built-in), BunRouter, Gin, Echo, and others through adapters -- Slogan generated using DALL-E -- AI used for documentation checking and correction -- Community feedback and contributions that made v2.0 and v2.1 possible \ No newline at end of file +* Inspired by REST, OData, and GraphQL's flexibility +* **Header-based approach**: Inspired by REST best practices and clean API design +* **Database Support**: [GORM](https://gorm.io) and [Bun](https://bun.uptrace.dev/) +* **Router Support**: Gorilla Mux (built-in), BunRouter, Gin, Echo, and others through adapters +* Slogan generated using DALL-E +* AI used for documentation checking and correction +* Community feedback and contributions that made v2.0 and v2.1 possible + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..47e7983 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + postgres-test: + image: postgres:15-alpine + container_name: resolvespec-postgres-test + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "5434:5432" + volumes: + - postgres-test-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - resolvespec-test + +volumes: + postgres-test-data: + driver: local + +networks: + resolvespec-test: + driver: bridge diff --git a/go.mod b/go.mod index 00e3c5e..b0bfd99 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( go.opentelemetry.io/otel/trace v1.38.0 go.uber.org/zap v1.27.0 golang.org/x/time v0.14.0 + gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.25.12 ) @@ -39,6 +40,10 @@ require ( github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -61,8 +66,10 @@ require ( go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/crypto v0.41.0 // indirect golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect golang.org/x/net v0.43.0 // indirect + golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect diff --git a/go.sum b/go.sum index 2ba7386..bff5082 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,7 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= @@ -37,6 +38,14 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -75,6 +84,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -126,6 +138,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc= golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= @@ -156,8 +170,11 @@ google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= diff --git a/pkg/resolvespec/context_test.go b/pkg/resolvespec/context_test.go new file mode 100644 index 0000000..be70d0a --- /dev/null +++ b/pkg/resolvespec/context_test.go @@ -0,0 +1,138 @@ +package resolvespec + +import ( + "context" + "testing" +) + +func TestContextOperations(t *testing.T) { + ctx := context.Background() + + // Test Schema + t.Run("WithSchema and GetSchema", func(t *testing.T) { + ctx = WithSchema(ctx, "public") + schema := GetSchema(ctx) + if schema != "public" { + t.Errorf("Expected schema 'public', got '%s'", schema) + } + }) + + // Test Entity + t.Run("WithEntity and GetEntity", func(t *testing.T) { + ctx = WithEntity(ctx, "users") + entity := GetEntity(ctx) + if entity != "users" { + t.Errorf("Expected entity 'users', got '%s'", entity) + } + }) + + // Test TableName + t.Run("WithTableName and GetTableName", func(t *testing.T) { + ctx = WithTableName(ctx, "public.users") + tableName := GetTableName(ctx) + if tableName != "public.users" { + t.Errorf("Expected tableName 'public.users', got '%s'", tableName) + } + }) + + // Test Model + t.Run("WithModel and GetModel", func(t *testing.T) { + type TestModel struct { + ID int + Name string + } + model := &TestModel{ID: 1, Name: "test"} + ctx = WithModel(ctx, model) + retrieved := GetModel(ctx) + if retrieved == nil { + t.Error("Expected model to be retrieved, got nil") + } + if retrievedModel, ok := retrieved.(*TestModel); ok { + if retrievedModel.ID != 1 || retrievedModel.Name != "test" { + t.Errorf("Expected model with ID=1 and Name='test', got ID=%d, Name='%s'", retrievedModel.ID, retrievedModel.Name) + } + } else { + t.Error("Retrieved model is not of expected type") + } + }) + + // Test ModelPtr + t.Run("WithModelPtr and GetModelPtr", func(t *testing.T) { + type TestModel struct { + ID int + } + models := []*TestModel{} + ctx = WithModelPtr(ctx, &models) + retrieved := GetModelPtr(ctx) + if retrieved == nil { + t.Error("Expected modelPtr to be retrieved, got nil") + } + }) + + // Test WithRequestData + t.Run("WithRequestData", func(t *testing.T) { + type TestModel struct { + ID int + Name string + } + model := &TestModel{ID: 1, Name: "test"} + modelPtr := &[]*TestModel{} + + ctx = WithRequestData(ctx, "test_schema", "test_entity", "test_schema.test_entity", model, modelPtr) + + if GetSchema(ctx) != "test_schema" { + t.Errorf("Expected schema 'test_schema', got '%s'", GetSchema(ctx)) + } + if GetEntity(ctx) != "test_entity" { + t.Errorf("Expected entity 'test_entity', got '%s'", GetEntity(ctx)) + } + if GetTableName(ctx) != "test_schema.test_entity" { + t.Errorf("Expected tableName 'test_schema.test_entity', got '%s'", GetTableName(ctx)) + } + if GetModel(ctx) == nil { + t.Error("Expected model to be set") + } + if GetModelPtr(ctx) == nil { + t.Error("Expected modelPtr to be set") + } + }) +} + +func TestEmptyContext(t *testing.T) { + ctx := context.Background() + + t.Run("GetSchema with empty context", func(t *testing.T) { + schema := GetSchema(ctx) + if schema != "" { + t.Errorf("Expected empty schema, got '%s'", schema) + } + }) + + t.Run("GetEntity with empty context", func(t *testing.T) { + entity := GetEntity(ctx) + if entity != "" { + t.Errorf("Expected empty entity, got '%s'", entity) + } + }) + + t.Run("GetTableName with empty context", func(t *testing.T) { + tableName := GetTableName(ctx) + if tableName != "" { + t.Errorf("Expected empty tableName, got '%s'", tableName) + } + }) + + t.Run("GetModel with empty context", func(t *testing.T) { + model := GetModel(ctx) + if model != nil { + t.Errorf("Expected nil model, got %v", model) + } + }) + + t.Run("GetModelPtr with empty context", func(t *testing.T) { + modelPtr := GetModelPtr(ctx) + if modelPtr != nil { + t.Errorf("Expected nil modelPtr, got %v", modelPtr) + } + }) +} diff --git a/pkg/resolvespec/handler_test.go b/pkg/resolvespec/handler_test.go new file mode 100644 index 0000000..ac36b6f --- /dev/null +++ b/pkg/resolvespec/handler_test.go @@ -0,0 +1,367 @@ +package resolvespec + +import ( + "reflect" + "testing" + + "github.com/bitechdev/ResolveSpec/pkg/common" +) + +func TestNewHandler(t *testing.T) { + // Note: We can't create a real handler without actual DB and registry + // But we can test that the constructor doesn't panic with nil values + handler := NewHandler(nil, nil) + if handler == nil { + t.Error("Expected handler to be created, got nil") + } + + if handler.hooks == nil { + t.Error("Expected hooks registry to be initialized") + } +} + +func TestHandlerHooks(t *testing.T) { + handler := NewHandler(nil, nil) + hooks := handler.Hooks() + if hooks == nil { + t.Error("Expected hooks registry, got nil") + } +} + +func TestSetFallbackHandler(t *testing.T) { + handler := NewHandler(nil, nil) + + // We can't directly call the fallback without mocks, but we can verify it's set + handler.SetFallbackHandler(func(w common.ResponseWriter, r common.Request, params map[string]string) { + // Fallback handler implementation + }) + + if handler.fallbackHandler == nil { + t.Error("Expected fallback handler to be set") + } +} + +func TestGetDatabase(t *testing.T) { + handler := NewHandler(nil, nil) + db := handler.GetDatabase() + // Should return nil since we passed nil + if db != nil { + t.Error("Expected nil database") + } +} + +func TestParseTableName(t *testing.T) { + handler := NewHandler(nil, nil) + + tests := []struct { + name string + fullTableName string + expectedSchema string + expectedTable string + }{ + { + name: "Table with schema", + fullTableName: "public.users", + expectedSchema: "public", + expectedTable: "users", + }, + { + name: "Table without schema", + fullTableName: "users", + expectedSchema: "", + expectedTable: "users", + }, + { + name: "Multiple dots (use last)", + fullTableName: "db.public.users", + expectedSchema: "db.public", + expectedTable: "users", + }, + { + name: "Empty string", + fullTableName: "", + expectedSchema: "", + expectedTable: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema, table := handler.parseTableName(tt.fullTableName) + if schema != tt.expectedSchema { + t.Errorf("Expected schema '%s', got '%s'", tt.expectedSchema, schema) + } + if table != tt.expectedTable { + t.Errorf("Expected table '%s', got '%s'", tt.expectedTable, table) + } + }) + } +} + +func TestGetColumnType(t *testing.T) { + tests := []struct { + name string + field reflect.StructField + expectedType string + }{ + { + name: "String field", + field: reflect.StructField{ + Name: "Name", + Type: reflect.TypeOf(""), + }, + expectedType: "string", + }, + { + name: "Int field", + field: reflect.StructField{ + Name: "Count", + Type: reflect.TypeOf(int(0)), + }, + expectedType: "integer", + }, + { + name: "Int32 field", + field: reflect.StructField{ + Name: "ID", + Type: reflect.TypeOf(int32(0)), + }, + expectedType: "integer", + }, + { + name: "Int64 field", + field: reflect.StructField{ + Name: "BigID", + Type: reflect.TypeOf(int64(0)), + }, + expectedType: "bigint", + }, + { + name: "Float32 field", + field: reflect.StructField{ + Name: "Price", + Type: reflect.TypeOf(float32(0)), + }, + expectedType: "float", + }, + { + name: "Float64 field", + field: reflect.StructField{ + Name: "Amount", + Type: reflect.TypeOf(float64(0)), + }, + expectedType: "double", + }, + { + name: "Bool field", + field: reflect.StructField{ + Name: "Active", + Type: reflect.TypeOf(false), + }, + expectedType: "boolean", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + colType := getColumnType(tt.field) + if colType != tt.expectedType { + t.Errorf("Expected column type '%s', got '%s'", tt.expectedType, colType) + } + }) + } +} + +func TestIsNullable(t *testing.T) { + tests := []struct { + name string + field reflect.StructField + nullable bool + }{ + { + name: "Pointer type is nullable", + field: reflect.StructField{ + Name: "Name", + Type: reflect.TypeOf((*string)(nil)), + }, + nullable: true, + }, + { + name: "Non-pointer type without explicit 'not null' tag", + field: reflect.StructField{ + Name: "ID", + Type: reflect.TypeOf(int(0)), + }, + nullable: true, // isNullable returns true if there's no explicit "not null" tag + }, + { + name: "Field with 'not null' tag is not nullable", + field: reflect.StructField{ + Name: "Email", + Type: reflect.TypeOf(""), + Tag: `gorm:"not null"`, + }, + nullable: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNullable(tt.field) + if result != tt.nullable { + t.Errorf("Expected nullable=%v, got %v", tt.nullable, result) + } + }) + } +} + +func TestToSnakeCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + { + input: "UserID", + expected: "user_id", + }, + { + input: "DepartmentName", + expected: "department_name", + }, + { + input: "ID", + expected: "id", + }, + { + input: "HTTPServer", + expected: "http_server", + }, + { + input: "createdAt", + expected: "created_at", + }, + { + input: "name", + expected: "name", + }, + { + input: "", + expected: "", + }, + { + input: "A", + expected: "a", + }, + { + input: "APIKey", + expected: "api_key", + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := toSnakeCase(tt.input) + if result != tt.expected { + t.Errorf("toSnakeCase(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestExtractTagValue(t *testing.T) { + handler := NewHandler(nil, nil) + + tests := []struct { + name string + tag string + key string + expected string + }{ + { + name: "Extract foreignKey", + tag: "foreignKey:UserID;references:ID", + key: "foreignKey", + expected: "UserID", + }, + { + name: "Extract references", + tag: "foreignKey:UserID;references:ID", + key: "references", + expected: "ID", + }, + { + name: "Key not found", + tag: "foreignKey:UserID;references:ID", + key: "notfound", + expected: "", + }, + { + name: "Empty tag", + tag: "", + key: "foreignKey", + expected: "", + }, + { + name: "Single value", + tag: "many2many:user_roles", + key: "many2many", + expected: "user_roles", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := handler.extractTagValue(tt.tag, tt.key) + if result != tt.expected { + t.Errorf("extractTagValue(%q, %q) = %q, expected %q", tt.tag, tt.key, result, tt.expected) + } + }) + } +} + +func TestApplyFilter(t *testing.T) { + // Note: Without a real database, we can't fully test query execution + // But we can test that the method exists + _ = NewHandler(nil, nil) + + // The applyFilter method exists and can be tested with actual queries + // but requires database setup which is beyond unit test scope + t.Log("applyFilter method exists and is used in handler operations") +} + +func TestShouldUseNestedProcessor(t *testing.T) { + handler := NewHandler(nil, nil) + + tests := []struct { + name string + data map[string]interface{} + expected bool + }{ + { + name: "Has _request field", + data: map[string]interface{}{ + "_request": "nested", + "name": "test", + }, + expected: true, + }, + { + name: "No special fields", + data: map[string]interface{}{ + "name": "test", + "email": "test@example.com", + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Note: Without a real model, we can't fully test this + // But we can verify the function exists + result := handler.shouldUseNestedProcessor(tt.data, nil) + // The actual result depends on the model structure + _ = result + }) + } +} diff --git a/pkg/resolvespec/hooks_test.go b/pkg/resolvespec/hooks_test.go new file mode 100644 index 0000000..533c4e8 --- /dev/null +++ b/pkg/resolvespec/hooks_test.go @@ -0,0 +1,400 @@ +package resolvespec + +import ( + "context" + "fmt" + "testing" +) + +func TestHookRegistry(t *testing.T) { + registry := NewHookRegistry() + + // Test registering a hook + called := false + hook := func(ctx *HookContext) error { + called = true + return nil + } + + registry.Register(BeforeRead, hook) + + if registry.Count(BeforeRead) != 1 { + t.Errorf("Expected 1 hook, got %d", registry.Count(BeforeRead)) + } + + // Test executing a hook + ctx := &HookContext{ + Context: context.Background(), + Schema: "test", + Entity: "users", + } + + err := registry.Execute(BeforeRead, ctx) + if err != nil { + t.Errorf("Hook execution failed: %v", err) + } + + if !called { + t.Error("Hook was not called") + } +} + +func TestHookExecutionOrder(t *testing.T) { + registry := NewHookRegistry() + + order := []int{} + + hook1 := func(ctx *HookContext) error { + order = append(order, 1) + return nil + } + + hook2 := func(ctx *HookContext) error { + order = append(order, 2) + return nil + } + + hook3 := func(ctx *HookContext) error { + order = append(order, 3) + return nil + } + + registry.Register(BeforeCreate, hook1) + registry.Register(BeforeCreate, hook2) + registry.Register(BeforeCreate, hook3) + + ctx := &HookContext{ + Context: context.Background(), + Schema: "test", + Entity: "users", + } + + err := registry.Execute(BeforeCreate, ctx) + if err != nil { + t.Errorf("Hook execution failed: %v", err) + } + + if len(order) != 3 { + t.Errorf("Expected 3 hooks to be called, got %d", len(order)) + } + + if order[0] != 1 || order[1] != 2 || order[2] != 3 { + t.Errorf("Hooks executed in wrong order: %v", order) + } +} + +func TestHookError(t *testing.T) { + registry := NewHookRegistry() + + executed := []string{} + + hook1 := func(ctx *HookContext) error { + executed = append(executed, "hook1") + return nil + } + + hook2 := func(ctx *HookContext) error { + executed = append(executed, "hook2") + return fmt.Errorf("hook2 error") + } + + hook3 := func(ctx *HookContext) error { + executed = append(executed, "hook3") + return nil + } + + registry.Register(BeforeUpdate, hook1) + registry.Register(BeforeUpdate, hook2) + registry.Register(BeforeUpdate, hook3) + + ctx := &HookContext{ + Context: context.Background(), + Schema: "test", + Entity: "users", + } + + err := registry.Execute(BeforeUpdate, ctx) + if err == nil { + t.Error("Expected error from hook execution") + } + + if len(executed) != 2 { + t.Errorf("Expected only 2 hooks to be executed, got %d", len(executed)) + } + + if executed[0] != "hook1" || executed[1] != "hook2" { + t.Errorf("Unexpected execution order: %v", executed) + } +} + +func TestHookDataModification(t *testing.T) { + registry := NewHookRegistry() + + modifyHook := func(ctx *HookContext) error { + if dataMap, ok := ctx.Data.(map[string]interface{}); ok { + dataMap["modified"] = true + ctx.Data = dataMap + } + return nil + } + + registry.Register(BeforeCreate, modifyHook) + + data := map[string]interface{}{ + "name": "test", + } + + ctx := &HookContext{ + Context: context.Background(), + Schema: "test", + Entity: "users", + Data: data, + } + + err := registry.Execute(BeforeCreate, ctx) + if err != nil { + t.Errorf("Hook execution failed: %v", err) + } + + modifiedData := ctx.Data.(map[string]interface{}) + if !modifiedData["modified"].(bool) { + t.Error("Data was not modified by hook") + } +} + +func TestRegisterMultiple(t *testing.T) { + registry := NewHookRegistry() + + called := 0 + hook := func(ctx *HookContext) error { + called++ + return nil + } + + registry.RegisterMultiple([]HookType{ + BeforeRead, + BeforeCreate, + BeforeUpdate, + }, hook) + + if registry.Count(BeforeRead) != 1 { + t.Error("Hook not registered for BeforeRead") + } + if registry.Count(BeforeCreate) != 1 { + t.Error("Hook not registered for BeforeCreate") + } + if registry.Count(BeforeUpdate) != 1 { + t.Error("Hook not registered for BeforeUpdate") + } + + ctx := &HookContext{ + Context: context.Background(), + Schema: "test", + Entity: "users", + } + + registry.Execute(BeforeRead, ctx) + registry.Execute(BeforeCreate, ctx) + registry.Execute(BeforeUpdate, ctx) + + if called != 3 { + t.Errorf("Expected hook to be called 3 times, got %d", called) + } +} + +func TestClearHooks(t *testing.T) { + registry := NewHookRegistry() + + hook := func(ctx *HookContext) error { + return nil + } + + registry.Register(BeforeRead, hook) + registry.Register(BeforeCreate, hook) + + if registry.Count(BeforeRead) != 1 { + t.Error("Hook not registered") + } + + registry.Clear(BeforeRead) + + if registry.Count(BeforeRead) != 0 { + t.Error("Hook not cleared") + } + + if registry.Count(BeforeCreate) != 1 { + t.Error("Wrong hook was cleared") + } +} + +func TestClearAllHooks(t *testing.T) { + registry := NewHookRegistry() + + hook := func(ctx *HookContext) error { + return nil + } + + registry.Register(BeforeRead, hook) + registry.Register(BeforeCreate, hook) + registry.Register(BeforeUpdate, hook) + + registry.ClearAll() + + if registry.Count(BeforeRead) != 0 || registry.Count(BeforeCreate) != 0 || registry.Count(BeforeUpdate) != 0 { + t.Error("Not all hooks were cleared") + } +} + +func TestHasHooks(t *testing.T) { + registry := NewHookRegistry() + + if registry.HasHooks(BeforeRead) { + t.Error("Should not have hooks initially") + } + + hook := func(ctx *HookContext) error { + return nil + } + + registry.Register(BeforeRead, hook) + + if !registry.HasHooks(BeforeRead) { + t.Error("Should have hooks after registration") + } +} + +func TestGetAllHookTypes(t *testing.T) { + registry := NewHookRegistry() + + hook := func(ctx *HookContext) error { + return nil + } + + registry.Register(BeforeRead, hook) + registry.Register(BeforeCreate, hook) + registry.Register(AfterUpdate, hook) + + types := registry.GetAllHookTypes() + + if len(types) != 3 { + t.Errorf("Expected 3 hook types, got %d", len(types)) + } + + // Verify all expected types are present + expectedTypes := map[HookType]bool{ + BeforeRead: true, + BeforeCreate: true, + AfterUpdate: true, + } + + for _, hookType := range types { + if !expectedTypes[hookType] { + t.Errorf("Unexpected hook type: %s", hookType) + } + } +} + +func TestHookContextHandler(t *testing.T) { + registry := NewHookRegistry() + + var capturedHandler *Handler + + hook := func(ctx *HookContext) error { + if ctx.Handler == nil { + return fmt.Errorf("handler is nil in hook context") + } + capturedHandler = ctx.Handler + return nil + } + + registry.Register(BeforeRead, hook) + + handler := &Handler{ + hooks: registry, + } + + ctx := &HookContext{ + Context: context.Background(), + Handler: handler, + Schema: "test", + Entity: "users", + } + + err := registry.Execute(BeforeRead, ctx) + if err != nil { + t.Errorf("Hook execution failed: %v", err) + } + + if capturedHandler == nil { + t.Error("Handler was not captured from hook context") + } + + if capturedHandler != handler { + t.Error("Captured handler does not match original handler") + } +} + +func TestHookAbort(t *testing.T) { + registry := NewHookRegistry() + + abortHook := func(ctx *HookContext) error { + ctx.Abort = true + ctx.AbortMessage = "Operation aborted by hook" + ctx.AbortCode = 403 + return nil + } + + registry.Register(BeforeCreate, abortHook) + + ctx := &HookContext{ + Context: context.Background(), + Schema: "test", + Entity: "users", + } + + err := registry.Execute(BeforeCreate, ctx) + if err == nil { + t.Error("Expected error when hook sets Abort=true") + } + + if err.Error() != "operation aborted by hook: Operation aborted by hook" { + t.Errorf("Expected abort error message, got: %v", err) + } +} + +func TestHookTypes(t *testing.T) { + // Test all hook type constants + hookTypes := []HookType{ + BeforeRead, + AfterRead, + BeforeCreate, + AfterCreate, + BeforeUpdate, + AfterUpdate, + BeforeDelete, + AfterDelete, + BeforeScan, + } + + for _, hookType := range hookTypes { + if string(hookType) == "" { + t.Errorf("Hook type should not be empty: %v", hookType) + } + } +} + +func TestExecuteWithNoHooks(t *testing.T) { + registry := NewHookRegistry() + + ctx := &HookContext{ + Context: context.Background(), + Schema: "test", + Entity: "users", + } + + // Executing with no registered hooks should not cause an error + err := registry.Execute(BeforeRead, ctx) + if err != nil { + t.Errorf("Execute should not fail with no hooks, got: %v", err) + } +} diff --git a/pkg/resolvespec/integration_test.go b/pkg/resolvespec/integration_test.go new file mode 100644 index 0000000..b7b8c6c --- /dev/null +++ b/pkg/resolvespec/integration_test.go @@ -0,0 +1,508 @@ +// +build integration + +package resolvespec + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gorilla/mux" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/bitechdev/ResolveSpec/pkg/common" +) + +// Test models +type TestUser struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Age int `json:"age"` + Active bool `gorm:"default:true" json:"active"` + CreatedAt time.Time `json:"created_at"` + Posts []TestPost `gorm:"foreignKey:UserID" json:"posts,omitempty"` +} + +func (TestUser) TableName() string { + return "test_users" +} + +type TestPost struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null" json:"user_id"` + Title string `gorm:"not null" json:"title"` + Content string `json:"content"` + Published bool `gorm:"default:false" json:"published"` + CreatedAt time.Time `json:"created_at"` + User *TestUser `gorm:"foreignKey:UserID" json:"user,omitempty"` + Comments []TestComment `gorm:"foreignKey:PostID" json:"comments,omitempty"` +} + +func (TestPost) TableName() string { + return "test_posts" +} + +type TestComment struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"not null" json:"post_id"` + Content string `gorm:"not null" json:"content"` + CreatedAt time.Time `json:"created_at"` + Post *TestPost `gorm:"foreignKey:PostID" json:"post,omitempty"` +} + +func (TestComment) TableName() string { + return "test_comments" +} + +// Test helper functions +func setupTestDB(t *testing.T) *gorm.DB { + // Get connection string from environment or use default + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5434 sslmode=disable" + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Skipf("Skipping integration test: database not available: %v", err) + return nil + } + + // Run migrations + err = db.AutoMigrate(&TestUser{}, &TestPost{}, &TestComment{}) + if err != nil { + t.Skipf("Skipping integration test: failed to migrate database: %v", err) + return nil + } + + return db +} + +func cleanupTestDB(t *testing.T, db *gorm.DB) { + // Clean up test data + db.Exec("TRUNCATE TABLE test_comments CASCADE") + db.Exec("TRUNCATE TABLE test_posts CASCADE") + db.Exec("TRUNCATE TABLE test_users CASCADE") +} + +func createTestData(t *testing.T, db *gorm.DB) { + users := []TestUser{ + {Name: "John Doe", Email: "john@example.com", Age: 30, Active: true}, + {Name: "Jane Smith", Email: "jane@example.com", Age: 25, Active: true}, + {Name: "Bob Johnson", Email: "bob@example.com", Age: 35, Active: false}, + } + + for i := range users { + if err := db.Create(&users[i]).Error; err != nil { + t.Fatalf("Failed to create test user: %v", err) + } + } + + posts := []TestPost{ + {UserID: users[0].ID, Title: "First Post", Content: "Hello World", Published: true}, + {UserID: users[0].ID, Title: "Second Post", Content: "More content", Published: true}, + {UserID: users[1].ID, Title: "Jane's Post", Content: "Jane's content", Published: false}, + } + + for i := range posts { + if err := db.Create(&posts[i]).Error; err != nil { + t.Fatalf("Failed to create test post: %v", err) + } + } + + comments := []TestComment{ + {PostID: posts[0].ID, Content: "Great post!"}, + {PostID: posts[0].ID, Content: "Thanks for sharing"}, + {PostID: posts[1].ID, Content: "Interesting"}, + } + + for i := range comments { + if err := db.Create(&comments[i]).Error; err != nil { + t.Fatalf("Failed to create test comment: %v", err) + } + } +} + +// Integration tests +func TestIntegration_CreateOperation(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + handler := NewHandlerWithGORM(db) + handler.RegisterModel("public", "test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Create a new user + requestBody := map[string]interface{}{ + "operation": "create", + "data": map[string]interface{}{ + "name": "Test User", + "email": "test@example.com", + "age": 28, + }, + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v. Error: %v", response.Success, response.Error) + } + + // Verify user was created + var user TestUser + if err := db.Where("email = ?", "test@example.com").First(&user).Error; err != nil { + t.Errorf("Failed to find created user: %v", err) + } + + if user.Name != "Test User" { + t.Errorf("Expected name 'Test User', got '%s'", user.Name) + } +} + +func TestIntegration_ReadOperation(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.RegisterModel("public", "test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Read all users + requestBody := map[string]interface{}{ + "operation": "read", + "options": map[string]interface{}{ + "limit": 10, + }, + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + if response.Metadata == nil { + t.Fatal("Expected metadata, got nil") + } + + if response.Metadata.Total != 3 { + t.Errorf("Expected 3 users, got %d", response.Metadata.Total) + } +} + +func TestIntegration_ReadWithFilters(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.RegisterModel("public", "test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Read users with age > 25 + requestBody := map[string]interface{}{ + "operation": "read", + "options": map[string]interface{}{ + "filters": []map[string]interface{}{ + { + "column": "age", + "operator": "gt", + "value": 25, + }, + }, + }, + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Should return 2 users (John: 30, Bob: 35) + if response.Metadata.Total != 2 { + t.Errorf("Expected 2 filtered users, got %d", response.Metadata.Total) + } +} + +func TestIntegration_UpdateOperation(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.RegisterModel("public", "test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Get user ID + var user TestUser + db.Where("email = ?", "john@example.com").First(&user) + + // Update user + requestBody := map[string]interface{}{ + "operation": "update", + "data": map[string]interface{}{ + "id": user.ID, + "age": 31, + "name": "John Doe Updated", + }, + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest("POST", fmt.Sprintf("/public/test_users/%d", user.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Verify update + var updatedUser TestUser + db.First(&updatedUser, user.ID) + + if updatedUser.Age != 31 { + t.Errorf("Expected age 31, got %d", updatedUser.Age) + } + if updatedUser.Name != "John Doe Updated" { + t.Errorf("Expected name 'John Doe Updated', got '%s'", updatedUser.Name) + } +} + +func TestIntegration_DeleteOperation(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.RegisterModel("public", "test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Get user ID + var user TestUser + db.Where("email = ?", "bob@example.com").First(&user) + + // Delete user + requestBody := map[string]interface{}{ + "operation": "delete", + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest("POST", fmt.Sprintf("/public/test_users/%d", user.ID), bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Verify deletion + var count int64 + db.Model(&TestUser{}).Where("id = ?", user.ID).Count(&count) + + if count != 0 { + t.Errorf("Expected user to be deleted, but found %d records", count) + } +} + +func TestIntegration_MetadataOperation(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + handler := NewHandlerWithGORM(db) + handler.RegisterModel("public", "test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Get metadata + requestBody := map[string]interface{}{ + "operation": "meta", + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Check that metadata includes columns + // The response.Data is an interface{}, we need to unmarshal it properly + dataBytes, _ := json.Marshal(response.Data) + var metadata common.TableMetadata + if err := json.Unmarshal(dataBytes, &metadata); err != nil { + t.Fatalf("Failed to unmarshal metadata: %v. Raw data: %+v", err, response.Data) + } + + if len(metadata.Columns) == 0 { + t.Error("Expected metadata to contain columns") + } + + // Verify some expected columns + hasID := false + hasName := false + hasEmail := false + for _, col := range metadata.Columns { + if col.Name == "id" { + hasID = true + if !col.IsPrimary { + t.Error("Expected 'id' column to be primary key") + } + } + if col.Name == "name" { + hasName = true + } + if col.Name == "email" { + hasEmail = true + } + } + + if !hasID || !hasName || !hasEmail { + t.Error("Expected metadata to contain 'id', 'name', and 'email' columns") + } +} + +func TestIntegration_ReadWithPreload(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.RegisterModel("public", "test_users", TestUser{}) + handler.RegisterModel("public", "test_posts", TestPost{}) + handler.RegisterModel("public", "test_comments", TestComment{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Read users with posts preloaded + requestBody := map[string]interface{}{ + "operation": "read", + "options": map[string]interface{}{ + "filters": []map[string]interface{}{ + { + "column": "email", + "operator": "eq", + "value": "john@example.com", + }, + }, + "preload": []map[string]interface{}{ + {"relation": "posts"}, + }, + }, + } + + body, _ := json.Marshal(requestBody) + req := httptest.NewRequest("POST", "/public/test_users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Verify posts are preloaded + dataBytes, _ := json.Marshal(response.Data) + var users []TestUser + json.Unmarshal(dataBytes, &users) + + if len(users) == 0 { + t.Fatal("Expected at least one user") + } + + if len(users[0].Posts) == 0 { + t.Error("Expected posts to be preloaded") + } +} diff --git a/pkg/resolvespec/resolvespec_test.go b/pkg/resolvespec/resolvespec_test.go new file mode 100644 index 0000000..c9e44f2 --- /dev/null +++ b/pkg/resolvespec/resolvespec_test.go @@ -0,0 +1,114 @@ +package resolvespec + +import ( + "testing" +) + +func TestParseModelName(t *testing.T) { + tests := []struct { + name string + fullName string + expectedSchema string + expectedEntity string + }{ + { + name: "Model with schema", + fullName: "public.users", + expectedSchema: "public", + expectedEntity: "users", + }, + { + name: "Model without schema", + fullName: "users", + expectedSchema: "", + expectedEntity: "users", + }, + { + name: "Model with custom schema", + fullName: "myschema.products", + expectedSchema: "myschema", + expectedEntity: "products", + }, + { + name: "Empty string", + fullName: "", + expectedSchema: "", + expectedEntity: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema, entity := parseModelName(tt.fullName) + if schema != tt.expectedSchema { + t.Errorf("Expected schema '%s', got '%s'", tt.expectedSchema, schema) + } + if entity != tt.expectedEntity { + t.Errorf("Expected entity '%s', got '%s'", tt.expectedEntity, entity) + } + }) + } +} + +func TestBuildRoutePath(t *testing.T) { + tests := []struct { + name string + schema string + entity string + expectedPath string + }{ + { + name: "With schema", + schema: "public", + entity: "users", + expectedPath: "/public/users", + }, + { + name: "Without schema", + schema: "", + entity: "users", + expectedPath: "/users", + }, + { + name: "Custom schema", + schema: "admin", + entity: "logs", + expectedPath: "/admin/logs", + }, + { + name: "Empty entity with schema", + schema: "public", + entity: "", + expectedPath: "/public/", + }, + { + name: "Both empty", + schema: "", + entity: "", + expectedPath: "/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := buildRoutePath(tt.schema, tt.entity) + if path != tt.expectedPath { + t.Errorf("Expected path '%s', got '%s'", tt.expectedPath, path) + } + }) + } +} + +func TestNewStandardMuxRouter(t *testing.T) { + router := NewStandardMuxRouter() + if router == nil { + t.Error("Expected router to be created, got nil") + } +} + +func TestNewStandardBunRouter(t *testing.T) { + router := NewStandardBunRouter() + if router == nil { + t.Error("Expected router to be created, got nil") + } +} diff --git a/pkg/restheadspec/context_test.go b/pkg/restheadspec/context_test.go new file mode 100644 index 0000000..53cbe83 --- /dev/null +++ b/pkg/restheadspec/context_test.go @@ -0,0 +1,181 @@ +package restheadspec + +import ( + "context" + "testing" + + "github.com/bitechdev/ResolveSpec/pkg/common" +) + +func TestContextOperations(t *testing.T) { + ctx := context.Background() + + // Test Schema + t.Run("WithSchema and GetSchema", func(t *testing.T) { + ctx = WithSchema(ctx, "public") + schema := GetSchema(ctx) + if schema != "public" { + t.Errorf("Expected schema 'public', got '%s'", schema) + } + }) + + // Test Entity + t.Run("WithEntity and GetEntity", func(t *testing.T) { + ctx = WithEntity(ctx, "users") + entity := GetEntity(ctx) + if entity != "users" { + t.Errorf("Expected entity 'users', got '%s'", entity) + } + }) + + // Test TableName + t.Run("WithTableName and GetTableName", func(t *testing.T) { + ctx = WithTableName(ctx, "public.users") + tableName := GetTableName(ctx) + if tableName != "public.users" { + t.Errorf("Expected tableName 'public.users', got '%s'", tableName) + } + }) + + // Test Model + t.Run("WithModel and GetModel", func(t *testing.T) { + type TestModel struct { + ID int + Name string + } + model := &TestModel{ID: 1, Name: "test"} + ctx = WithModel(ctx, model) + retrieved := GetModel(ctx) + if retrieved == nil { + t.Error("Expected model to be retrieved, got nil") + } + if retrievedModel, ok := retrieved.(*TestModel); ok { + if retrievedModel.ID != 1 || retrievedModel.Name != "test" { + t.Errorf("Expected model with ID=1 and Name='test', got ID=%d, Name='%s'", retrievedModel.ID, retrievedModel.Name) + } + } else { + t.Error("Retrieved model is not of expected type") + } + }) + + // Test ModelPtr + t.Run("WithModelPtr and GetModelPtr", func(t *testing.T) { + type TestModel struct { + ID int + } + models := []*TestModel{} + ctx = WithModelPtr(ctx, &models) + retrieved := GetModelPtr(ctx) + if retrieved == nil { + t.Error("Expected modelPtr to be retrieved, got nil") + } + }) + + // Test Options + t.Run("WithOptions and GetOptions", func(t *testing.T) { + limit := 10 + options := ExtendedRequestOptions{ + RequestOptions: common.RequestOptions{ + Limit: &limit, + }, + } + ctx = WithOptions(ctx, options) + retrieved := GetOptions(ctx) + if retrieved == nil { + t.Error("Expected options to be retrieved, got nil") + return + } + if retrieved.Limit == nil || *retrieved.Limit != 10 { + t.Error("Expected options to be retrieved with limit=10") + } + }) + + // Test WithRequestData + t.Run("WithRequestData", func(t *testing.T) { + type TestModel struct { + ID int + Name string + } + model := &TestModel{ID: 1, Name: "test"} + modelPtr := &[]*TestModel{} + limit := 20 + options := ExtendedRequestOptions{ + RequestOptions: common.RequestOptions{ + Limit: &limit, + }, + } + + ctx = WithRequestData(ctx, "test_schema", "test_entity", "test_schema.test_entity", model, modelPtr, options) + + if GetSchema(ctx) != "test_schema" { + t.Errorf("Expected schema 'test_schema', got '%s'", GetSchema(ctx)) + } + if GetEntity(ctx) != "test_entity" { + t.Errorf("Expected entity 'test_entity', got '%s'", GetEntity(ctx)) + } + if GetTableName(ctx) != "test_schema.test_entity" { + t.Errorf("Expected tableName 'test_schema.test_entity', got '%s'", GetTableName(ctx)) + } + if GetModel(ctx) == nil { + t.Error("Expected model to be set") + } + if GetModelPtr(ctx) == nil { + t.Error("Expected modelPtr to be set") + } + opts := GetOptions(ctx) + if opts == nil { + t.Error("Expected options to be set") + return + } + if opts.Limit == nil || *opts.Limit != 20 { + t.Error("Expected options to be set with limit=20") + } + }) +} + +func TestEmptyContext(t *testing.T) { + ctx := context.Background() + + t.Run("GetSchema with empty context", func(t *testing.T) { + schema := GetSchema(ctx) + if schema != "" { + t.Errorf("Expected empty schema, got '%s'", schema) + } + }) + + t.Run("GetEntity with empty context", func(t *testing.T) { + entity := GetEntity(ctx) + if entity != "" { + t.Errorf("Expected empty entity, got '%s'", entity) + } + }) + + t.Run("GetTableName with empty context", func(t *testing.T) { + tableName := GetTableName(ctx) + if tableName != "" { + t.Errorf("Expected empty tableName, got '%s'", tableName) + } + }) + + t.Run("GetModel with empty context", func(t *testing.T) { + model := GetModel(ctx) + if model != nil { + t.Errorf("Expected nil model, got %v", model) + } + }) + + t.Run("GetModelPtr with empty context", func(t *testing.T) { + modelPtr := GetModelPtr(ctx) + if modelPtr != nil { + t.Errorf("Expected nil modelPtr, got %v", modelPtr) + } + }) + + t.Run("GetOptions with empty context", func(t *testing.T) { + options := GetOptions(ctx) + // GetOptions returns nil when context is empty + if options != nil { + t.Errorf("Expected nil options in empty context, got %v", options) + } + }) +} diff --git a/pkg/restheadspec/handler.go b/pkg/restheadspec/handler.go index 696834f..48eaf5e 100644 --- a/pkg/restheadspec/handler.go +++ b/pkg/restheadspec/handler.go @@ -226,8 +226,20 @@ func (h *Handler) HandleGet(w common.ResponseWriter, r common.Request, params ma return } - metadata := h.generateMetadata(schema, entity, model) - h.sendResponse(w, metadata, nil) + // Parse request options from headers to get response format settings + options := h.parseOptionsFromHeaders(r, model) + + tableMetadata := h.generateMetadata(schema, entity, model) + // Send with formatted response to respect DetailApi/SimpleApi/Syncfusion format + // Create empty metadata for response wrapper + responseMetadata := &common.Metadata{ + Total: 0, + Filtered: 0, + Count: 0, + Limit: 0, + Offset: 0, + } + h.sendFormattedResponse(w, tableMetadata, responseMetadata, options) } // handleMeta processes meta operation requests diff --git a/pkg/restheadspec/handler_nested_test.go b/pkg/restheadspec/handler_nested_test.go index cdab932..605f01d 100644 --- a/pkg/restheadspec/handler_nested_test.go +++ b/pkg/restheadspec/handler_nested_test.go @@ -1,3 +1,5 @@ +// +build !integration + package restheadspec import ( diff --git a/pkg/restheadspec/headers_test.go b/pkg/restheadspec/headers_test.go new file mode 100644 index 0000000..8117483 --- /dev/null +++ b/pkg/restheadspec/headers_test.go @@ -0,0 +1,46 @@ +package restheadspec + +import ( + "testing" +) + +func TestDecodeHeaderValue(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Normal string", + input: "test", + expected: "test", + }, + { + name: "String without encoding prefix", + input: "hello world", + expected: "hello world", + }, + { + name: "Empty string", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := decodeHeaderValue(tt.input) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +// Note: The following functions are unexported (lowercase) and cannot be tested directly: +// - parseSelectFields +// - parseFieldFilter +// - mapSearchOperator +// - parseCommaSeparated +// - parseSorting +// These are tested indirectly through parseOptionsFromHeaders in query_params_test.go diff --git a/pkg/restheadspec/integration_test.go b/pkg/restheadspec/integration_test.go new file mode 100644 index 0000000..8bb7c67 --- /dev/null +++ b/pkg/restheadspec/integration_test.go @@ -0,0 +1,556 @@ +// +build integration + +package restheadspec + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/gorilla/mux" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/bitechdev/ResolveSpec/pkg/common" +) + +// Test models +type TestUser struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"not null" json:"name"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + Age int `json:"age"` + Active bool `gorm:"default:true" json:"active"` + CreatedAt time.Time `json:"created_at"` + Posts []TestPost `gorm:"foreignKey:UserID" json:"posts,omitempty"` +} + +func (TestUser) TableName() string { + return "test_users" +} + +type TestPost struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null" json:"user_id"` + Title string `gorm:"not null" json:"title"` + Content string `json:"content"` + Published bool `gorm:"default:false" json:"published"` + CreatedAt time.Time `json:"created_at"` + User *TestUser `gorm:"foreignKey:UserID" json:"user,omitempty"` + Comments []TestComment `gorm:"foreignKey:PostID" json:"comments,omitempty"` +} + +func (TestPost) TableName() string { + return "test_posts" +} + +type TestComment struct { + ID uint `gorm:"primaryKey" json:"id"` + PostID uint `gorm:"not null" json:"post_id"` + Content string `gorm:"not null" json:"content"` + CreatedAt time.Time `json:"created_at"` + Post *TestPost `gorm:"foreignKey:PostID" json:"post,omitempty"` +} + +func (TestComment) TableName() string { + return "test_comments" +} + +// Test helper functions +func setupTestDB(t *testing.T) *gorm.DB { + // Get connection string from environment or use default + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = "host=localhost user=postgres password=postgres dbname=restheadspec_test port=5434 sslmode=disable" + } + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Skipf("Skipping integration test: database not available: %v", err) + return nil + } + + // Run migrations + err = db.AutoMigrate(&TestUser{}, &TestPost{}, &TestComment{}) + if err != nil { + t.Skipf("Skipping integration test: failed to migrate database: %v", err) + return nil + } + + return db +} + +func cleanupTestDB(t *testing.T, db *gorm.DB) { + // Clean up test data + db.Exec("TRUNCATE TABLE test_comments CASCADE") + db.Exec("TRUNCATE TABLE test_posts CASCADE") + db.Exec("TRUNCATE TABLE test_users CASCADE") +} + +func createTestData(t *testing.T, db *gorm.DB) { + users := []TestUser{ + {Name: "John Doe", Email: "john@example.com", Age: 30, Active: true}, + {Name: "Jane Smith", Email: "jane@example.com", Age: 25, Active: true}, + {Name: "Bob Johnson", Email: "bob@example.com", Age: 35, Active: false}, + } + + for i := range users { + if err := db.Create(&users[i]).Error; err != nil { + t.Fatalf("Failed to create test user: %v", err) + } + } + + posts := []TestPost{ + {UserID: users[0].ID, Title: "First Post", Content: "Hello World", Published: true}, + {UserID: users[0].ID, Title: "Second Post", Content: "More content", Published: true}, + {UserID: users[1].ID, Title: "Jane's Post", Content: "Jane's content", Published: false}, + } + + for i := range posts { + if err := db.Create(&posts[i]).Error; err != nil { + t.Fatalf("Failed to create test post: %v", err) + } + } + + comments := []TestComment{ + {PostID: posts[0].ID, Content: "Great post!"}, + {PostID: posts[0].ID, Content: "Thanks for sharing"}, + {PostID: posts[1].ID, Content: "Interesting"}, + } + + for i := range comments { + if err := db.Create(&comments[i]).Error; err != nil { + t.Fatalf("Failed to create test comment: %v", err) + } + } +} + +// Integration tests +func TestIntegration_GetAllUsers(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + req := httptest.NewRequest("GET", "/public/test_users", nil) + req.Header.Set("X-DetailApi", "true") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + if response.Metadata == nil { + t.Fatal("Expected metadata, got nil") + } + + if response.Metadata.Total != 3 { + t.Errorf("Expected 3 users, got %d", response.Metadata.Total) + } +} + +func TestIntegration_GetUsersWithFilters(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Filter: age > 25 + req := httptest.NewRequest("GET", "/public/test_users", nil) + req.Header.Set("X-DetailApi", "true") + req.Header.Set("X-SearchOp-Gt-Age", "25") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Should return 2 users (John: 30, Bob: 35) + if response.Metadata.Total != 2 { + t.Errorf("Expected 2 filtered users, got %d", response.Metadata.Total) + } +} + +func TestIntegration_GetUsersWithPagination(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + req := httptest.NewRequest("GET", "/public/test_users", nil) + req.Header.Set("X-DetailApi", "true") + req.Header.Set("X-Limit", "2") + req.Header.Set("X-Offset", "1") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Total should still be 3, but we're only retrieving 2 records starting from offset 1 + if response.Metadata.Total != 3 { + t.Errorf("Expected total 3 users, got %d", response.Metadata.Total) + } + + if response.Metadata.Limit != 2 { + t.Errorf("Expected limit 2, got %d", response.Metadata.Limit) + } + + if response.Metadata.Offset != 1 { + t.Errorf("Expected offset 1, got %d", response.Metadata.Offset) + } +} + +func TestIntegration_GetUsersWithSorting(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Sort by age descending + req := httptest.NewRequest("GET", "/public/test_users", nil) + req.Header.Set("X-DetailApi", "true") + req.Header.Set("X-Sort", "-age") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Parse data to verify sort order + dataBytes, _ := json.Marshal(response.Data) + var users []TestUser + json.Unmarshal(dataBytes, &users) + + if len(users) < 3 { + t.Fatal("Expected at least 3 users") + } + + // Check that users are sorted by age descending (Bob:35, John:30, Jane:25) + if users[0].Age < users[1].Age || users[1].Age < users[2].Age { + t.Error("Expected users to be sorted by age descending") + } +} + +func TestIntegration_GetUsersWithColumnsSelection(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + req := httptest.NewRequest("GET", "/public/test_users", nil) + req.Header.Set("X-DetailApi", "true") + req.Header.Set("X-Columns", "id,name,email") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Verify data was returned (column selection doesn't affect metadata count) + if response.Metadata.Total != 3 { + t.Errorf("Expected 3 users, got %d", response.Metadata.Total) + } +} + +func TestIntegration_GetUsersWithPreload(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + req := httptest.NewRequest("GET", "/public/test_users?x-fieldfilter-email=john@example.com", nil) + req.Header.Set("X-DetailApi", "true") + req.Header.Set("X-Preload", "Posts") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Verify posts are preloaded + dataBytes, _ := json.Marshal(response.Data) + var users []TestUser + json.Unmarshal(dataBytes, &users) + + if len(users) == 0 { + t.Fatal("Expected at least one user") + } + + if len(users[0].Posts) == 0 { + t.Error("Expected posts to be preloaded") + } +} + +func TestIntegration_GetMetadata(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + req := httptest.NewRequest("GET", "/public/test_users/metadata", nil) + req.Header.Set("X-DetailApi", "true") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v. Body: %s, Error: %v", response.Success, w.Body.String(), response.Error) + } + + // Check that metadata includes columns + metadataBytes, _ := json.Marshal(response.Data) + var metadata common.TableMetadata + json.Unmarshal(metadataBytes, &metadata) + + if len(metadata.Columns) == 0 { + t.Error("Expected metadata to contain columns") + } + + // Verify some expected columns + hasID := false + hasName := false + hasEmail := false + for _, col := range metadata.Columns { + if col.Name == "id" { + hasID = true + } + if col.Name == "name" { + hasName = true + } + if col.Name == "email" { + hasEmail = true + } + } + + if !hasID || !hasName || !hasEmail { + t.Error("Expected metadata to contain 'id', 'name', and 'email' columns") + } +} + +func TestIntegration_OptionsRequest(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + req := httptest.NewRequest("OPTIONS", "/public/test_users", nil) + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Check CORS headers + if w.Header().Get("Access-Control-Allow-Origin") == "" { + t.Error("Expected Access-Control-Allow-Origin header") + } + + if w.Header().Get("Access-Control-Allow-Methods") == "" { + t.Error("Expected Access-Control-Allow-Methods header") + } +} + +func TestIntegration_QueryParamsOverHeaders(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Query params should override headers + req := httptest.NewRequest("GET", "/public/test_users?x-limit=1", nil) + req.Header.Set("X-DetailApi", "true") + req.Header.Set("X-Limit", "10") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Query param should win (limit=1) + if response.Metadata.Limit != 1 { + t.Errorf("Expected limit 1 from query param, got %d", response.Metadata.Limit) + } +} + +func TestIntegration_GetSingleRecord(t *testing.T) { + db := setupTestDB(t) + defer cleanupTestDB(t, db) + createTestData(t, db) + + handler := NewHandlerWithGORM(db) + handler.registry.RegisterModel("public.test_users", TestUser{}) + + muxRouter := mux.NewRouter() + SetupMuxRoutes(muxRouter, handler, nil) + + // Get first user ID + var user TestUser + db.Where("email = ?", "john@example.com").First(&user) + + req := httptest.NewRequest("GET", fmt.Sprintf("/public/test_users/%d", user.ID), nil) + req.Header.Set("X-DetailApi", "true") + w := httptest.NewRecorder() + + muxRouter.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var response common.Response + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if !response.Success { + t.Errorf("Expected success=true, got %v", response.Success) + } + + // Verify it's a single record + dataBytes, _ := json.Marshal(response.Data) + var resultUser TestUser + json.Unmarshal(dataBytes, &resultUser) + + if resultUser.Email != "john@example.com" { + t.Errorf("Expected user with email 'john@example.com', got '%s'", resultUser.Email) + } +} diff --git a/pkg/restheadspec/restheadspec.go b/pkg/restheadspec/restheadspec.go index f968214..3ebb9f4 100644 --- a/pkg/restheadspec/restheadspec.go +++ b/pkg/restheadspec/restheadspec.go @@ -128,15 +128,17 @@ func SetupMuxRoutes(muxRouter *mux.Router, handler *Handler, authMiddleware Midd } // Register routes for this entity + // IMPORTANT: Register more specific routes before wildcard routes + // GET, POST for /{schema}/{entity} muxRouter.Handle(entityPath, entityHandler).Methods("GET", "POST") + // GET for metadata (using HandleGet) - MUST be registered before /{id} route + muxRouter.Handle(metadataPath, metadataHandler).Methods("GET") + // GET, PUT, PATCH, DELETE, POST for /{schema}/{entity}/{id} muxRouter.Handle(entityWithIDPath, entityWithIDHandler).Methods("GET", "PUT", "PATCH", "DELETE", "POST") - // GET for metadata (using HandleGet) - muxRouter.Handle(metadataPath, metadataHandler).Methods("GET") - // OPTIONS for CORS preflight - returns metadata muxRouter.Handle(entityPath, optionsEntityHandler).Methods("OPTIONS") muxRouter.Handle(entityWithIDPath, optionsEntityWithIDHandler).Methods("OPTIONS") diff --git a/pkg/restheadspec/restheadspec_test.go b/pkg/restheadspec/restheadspec_test.go new file mode 100644 index 0000000..355938b --- /dev/null +++ b/pkg/restheadspec/restheadspec_test.go @@ -0,0 +1,114 @@ +package restheadspec + +import ( + "testing" +) + +func TestParseModelName(t *testing.T) { + tests := []struct { + name string + fullName string + expectedSchema string + expectedEntity string + }{ + { + name: "Model with schema", + fullName: "public.users", + expectedSchema: "public", + expectedEntity: "users", + }, + { + name: "Model without schema", + fullName: "users", + expectedSchema: "", + expectedEntity: "users", + }, + { + name: "Model with custom schema", + fullName: "myschema.products", + expectedSchema: "myschema", + expectedEntity: "products", + }, + { + name: "Empty string", + fullName: "", + expectedSchema: "", + expectedEntity: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + schema, entity := parseModelName(tt.fullName) + if schema != tt.expectedSchema { + t.Errorf("Expected schema '%s', got '%s'", tt.expectedSchema, schema) + } + if entity != tt.expectedEntity { + t.Errorf("Expected entity '%s', got '%s'", tt.expectedEntity, entity) + } + }) + } +} + +func TestBuildRoutePath(t *testing.T) { + tests := []struct { + name string + schema string + entity string + expectedPath string + }{ + { + name: "With schema", + schema: "public", + entity: "users", + expectedPath: "/public/users", + }, + { + name: "Without schema", + schema: "", + entity: "users", + expectedPath: "/users", + }, + { + name: "Custom schema", + schema: "admin", + entity: "logs", + expectedPath: "/admin/logs", + }, + { + name: "Empty entity with schema", + schema: "public", + entity: "", + expectedPath: "/public/", + }, + { + name: "Both empty", + schema: "", + entity: "", + expectedPath: "/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := buildRoutePath(tt.schema, tt.entity) + if path != tt.expectedPath { + t.Errorf("Expected path '%s', got '%s'", tt.expectedPath, path) + } + }) + } +} + +func TestNewStandardMuxRouter(t *testing.T) { + router := NewStandardMuxRouter() + if router == nil { + t.Error("Expected router to be created, got nil") + } +} + +func TestNewStandardBunRouter(t *testing.T) { + router := NewStandardBunRouter() + if router == nil { + t.Error("Expected router to be created, got nil") + } +} diff --git a/SECURITY_FEATURES.md b/pkg/security/SECURITY_FEATURES.md similarity index 100% rename from SECURITY_FEATURES.md rename to pkg/security/SECURITY_FEATURES.md diff --git a/scripts/init-test-dbs.sql b/scripts/init-test-dbs.sql new file mode 100644 index 0000000..4ceb9f0 --- /dev/null +++ b/scripts/init-test-dbs.sql @@ -0,0 +1,7 @@ +-- Create test databases for integration tests +CREATE DATABASE resolvespec_test; +CREATE DATABASE restheadspec_test; + +-- Grant all privileges to postgres user +GRANT ALL PRIVILEGES ON DATABASE resolvespec_test TO postgres; +GRANT ALL PRIVILEGES ON DATABASE restheadspec_test TO postgres; diff --git a/scripts/run-integration-tests.sh b/scripts/run-integration-tests.sh new file mode 100755 index 0000000..f35a337 --- /dev/null +++ b/scripts/run-integration-tests.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# Script to run integration tests with automatic PostgreSQL setup +# Usage: ./scripts/run-integration-tests.sh [package] +# package: optional, can be "resolvespec", "restheadspec", or omit for both + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}=== ResolveSpec Integration Tests ===${NC}\n" + +# Check if docker-compose is available +if ! command -v docker-compose &> /dev/null; then + echo -e "${RED}Error: docker-compose is not installed${NC}" + echo "Please install docker-compose or run PostgreSQL manually" + echo "See INTEGRATION_TESTS.md for details" + exit 1 +fi + +# Clean up any existing containers and networks from previous runs +echo -e "${YELLOW}Cleaning up existing containers and networks...${NC}" +docker-compose down -v 2>/dev/null || true + +# Start PostgreSQL +echo -e "${YELLOW}Starting PostgreSQL...${NC}" +docker-compose up -d postgres-test + +# Wait for PostgreSQL to be ready +echo -e "${YELLOW}Waiting for PostgreSQL to be ready...${NC}" +max_attempts=30 +attempt=0 + +while ! docker-compose exec -T postgres-test pg_isready -U postgres > /dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ $attempt -ge $max_attempts ]; then + echo -e "${RED}Error: PostgreSQL failed to start after ${max_attempts} seconds${NC}" + docker-compose logs postgres-test + docker-compose down + exit 1 + fi + sleep 1 + echo -n "." +done + +echo -e "\n${GREEN}PostgreSQL is ready!${NC}\n" + +# Create test databases +echo -e "${YELLOW}Creating test databases...${NC}" +docker-compose exec -T postgres-test psql -U postgres -c "CREATE DATABASE resolvespec_test;" 2>/dev/null || echo " resolvespec_test already exists" +docker-compose exec -T postgres-test psql -U postgres -c "CREATE DATABASE restheadspec_test;" 2>/dev/null || echo " restheadspec_test already exists" +echo -e "${GREEN}Test databases ready!${NC}\n" + +# Determine which tests to run +PACKAGE="" +if [ "$1" == "resolvespec" ]; then + PACKAGE="./pkg/resolvespec" + echo -e "${YELLOW}Running resolvespec integration tests...${NC}\n" +elif [ "$1" == "restheadspec" ]; then + PACKAGE="./pkg/restheadspec" + echo -e "${YELLOW}Running restheadspec integration tests...${NC}\n" +else + PACKAGE="./pkg/resolvespec ./pkg/restheadspec" + echo -e "${YELLOW}Running all integration tests...${NC}\n" +fi + +# Run tests +if go test -tags=integration $PACKAGE -v; then + echo -e "\n${GREEN}โœ“ All integration tests passed!${NC}" + EXIT_CODE=0 +else + echo -e "\n${RED}โœ— Some integration tests failed${NC}" + EXIT_CODE=1 +fi + +# Cleanup +echo -e "\n${YELLOW}Stopping PostgreSQL...${NC}" +docker-compose down + +exit $EXIT_CODE diff --git a/tests/INTEGRATION_TESTS.md b/tests/INTEGRATION_TESTS.md new file mode 100644 index 0000000..664ff24 --- /dev/null +++ b/tests/INTEGRATION_TESTS.md @@ -0,0 +1,265 @@ +# Integration Tests + +This document describes how to run integration tests for ResolveSpec packages with a PostgreSQL database. + +## Overview + +Integration tests validate the full functionality of both `pkg/resolvespec` and `pkg/restheadspec` packages with an actual PostgreSQL database. These tests cover: + +- CRUD operations (Create, Read, Update, Delete) +- Filtering and sorting +- Pagination +- Column selection +- Relationship preloading +- Metadata generation +- Query parameter parsing +- CORS handling + +## Prerequisites + +- Go 1.19 or later +- PostgreSQL 12 or later +- Docker and Docker Compose (optional, for easy setup) + +## Quick Start with Docker + +### 1. Start PostgreSQL with Docker Compose + +```bash +docker-compose up -d postgres-test +``` + +This starts a PostgreSQL container with the following default settings: +- Host: localhost +- Port: 5432 +- User: postgres +- Password: postgres +- Databases: resolvespec_test, restheadspec_test + +### 2. Run Integration Tests + +```bash +# Run all integration tests +go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v + +# Run only resolvespec integration tests +go test -tags=integration ./pkg/resolvespec -v + +# Run only restheadspec integration tests +go test -tags=integration ./pkg/restheadspec -v +``` + +### 3. Stop PostgreSQL + +```bash +docker-compose down +``` + +## Manual PostgreSQL Setup + +If you prefer to use an existing PostgreSQL installation: + +### 1. Create Test Databases + +```sql +CREATE DATABASE resolvespec_test; +CREATE DATABASE restheadspec_test; +``` + +### 2. Set Environment Variable + +```bash +# For resolvespec tests +export TEST_DATABASE_URL="host=localhost user=postgres password=yourpassword dbname=resolvespec_test port=5432 sslmode=disable" + +# For restheadspec tests (uses same env var with different dbname) +export TEST_DATABASE_URL="host=localhost user=postgres password=yourpassword dbname=restheadspec_test port=5432 sslmode=disable" +``` + +### 3. Run Tests + +```bash +go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v +``` + +## Test Coverage + +### pkg/resolvespec Integration Tests + +| Test | Description | +|------|-------------| +| `TestIntegration_CreateOperation` | Tests creating new records via API | +| `TestIntegration_ReadOperation` | Tests reading all records with pagination | +| `TestIntegration_ReadWithFilters` | Tests filtering records (e.g., age > 25) | +| `TestIntegration_UpdateOperation` | Tests updating existing records | +| `TestIntegration_DeleteOperation` | Tests deleting records | +| `TestIntegration_MetadataOperation` | Tests retrieving table metadata | +| `TestIntegration_ReadWithPreload` | Tests eager loading relationships | + +### pkg/restheadspec Integration Tests + +| Test | Description | +|------|-------------| +| `TestIntegration_GetAllUsers` | Tests GET request to retrieve all records | +| `TestIntegration_GetUsersWithFilters` | Tests header-based filtering | +| `TestIntegration_GetUsersWithPagination` | Tests limit/offset pagination | +| `TestIntegration_GetUsersWithSorting` | Tests sorting by column | +| `TestIntegration_GetUsersWithColumnsSelection` | Tests selecting specific columns | +| `TestIntegration_GetUsersWithPreload` | Tests relationship preloading | +| `TestIntegration_GetMetadata` | Tests metadata endpoint | +| `TestIntegration_OptionsRequest` | Tests OPTIONS/CORS handling | +| `TestIntegration_QueryParamsOverHeaders` | Tests query param precedence | +| `TestIntegration_GetSingleRecord` | Tests retrieving single record by ID | + +## Test Data + +Integration tests use the following test models: + +### TestUser +```go +type TestUser struct { + ID uint + Name string + Email string (unique) + Age int + Active bool + CreatedAt time.Time + Posts []TestPost +} +``` + +### TestPost +```go +type TestPost struct { + ID uint + UserID uint + Title string + Content string + Published bool + CreatedAt time.Time + User *TestUser + Comments []TestComment +} +``` + +### TestComment +```go +type TestComment struct { + ID uint + PostID uint + Content string + CreatedAt time.Time + Post *TestPost +} +``` + +## Troubleshooting + +### Connection Refused + +If you see "connection refused" errors: + +1. Check that PostgreSQL is running: + ```bash + docker-compose ps + ``` + +2. Verify connection parameters: + ```bash + psql -h localhost -U postgres -d resolvespec_test + ``` + +3. Check firewall settings if using remote PostgreSQL + +### Permission Denied + +Ensure the PostgreSQL user has necessary permissions: + +```sql +GRANT ALL PRIVILEGES ON DATABASE resolvespec_test TO postgres; +GRANT ALL PRIVILEGES ON DATABASE restheadspec_test TO postgres; +``` + +### Tests Fail with "relation does not exist" + +The tests automatically run migrations, but if you encounter this error: + +1. Ensure your DATABASE_URL environment variable is correct +2. Check that the database exists +3. Verify the user has CREATE TABLE permissions + +### Clean Database Between Runs + +Each test automatically cleans up its data using `TRUNCATE`. If you need a fresh database: + +```bash +# Stop and remove containers (removes data) +docker-compose down -v + +# Restart +docker-compose up -d postgres-test +``` + +## CI/CD Integration + +### GitHub Actions Example + +```yaml +name: Integration Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: resolvespec_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Run integration tests + env: + TEST_DATABASE_URL: "host=localhost user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable" + run: | + go test -tags=integration ./pkg/resolvespec -v + go test -tags=integration ./pkg/restheadspec -v +``` + +## Performance Considerations + +- Integration tests are slower than unit tests due to database I/O +- Each test sets up and tears down test data +- Consider running integration tests separately from unit tests in CI/CD +- Use connection pooling for better performance + +## Best Practices + +1. **Isolation**: Each test cleans up its data using TRUNCATE +2. **Independent**: Tests don't depend on each other's state +3. **Idempotent**: Tests can be run multiple times safely +4. **Fast Setup**: Migrations run automatically +5. **Flexible**: Works with any PostgreSQL instance via environment variables + +## Additional Resources + +- [PostgreSQL Docker Image](https://hub.docker.com/_/postgres) +- [GORM Documentation](https://gorm.io/) +- [Testing in Go](https://golang.org/doc/tutorial/add-a-test) diff --git a/tests/QUICKSTART_TESTING.md b/tests/QUICKSTART_TESTING.md new file mode 100644 index 0000000..f4a0a01 --- /dev/null +++ b/tests/QUICKSTART_TESTING.md @@ -0,0 +1,135 @@ +# Testing Quick Start + +## โšก 30-Second Start + +```bash +# Unit tests (no setup required) +go test ./pkg/resolvespec ./pkg/restheadspec -v + +# Integration tests (automated) +./scripts/run-integration-tests.sh +``` + +## ๐Ÿ“‹ Common Commands + +| What You Want | Command | +|---------------|---------| +| Run unit tests | `make test-unit` | +| Run integration tests | `./scripts/run-integration-tests.sh` | +| Run all tests | `make test` | +| Coverage report | `make coverage` | +| Start PostgreSQL | `make docker-up` | +| Stop PostgreSQL | `make docker-down` | +| See all commands | `make help` | + +## ๐Ÿ“Š Current Test Coverage + +- **pkg/resolvespec**: 11.2% (28 unit + 7 integration tests) +- **pkg/restheadspec**: 12.5% (50 unit + 10 integration tests) +- **Total**: 95 tests + +## ๐Ÿงช Test Types + +### Unit Tests (Fast, No Database) +Test individual functions and components in isolation. + +```bash +go test ./pkg/resolvespec -v +go test ./pkg/restheadspec -v +``` + +### Integration Tests (Requires PostgreSQL) +Test full API operations with real database. + +```bash +# Automated (recommended) +./scripts/run-integration-tests.sh + +# Manual +make docker-up +go test -tags=integration ./pkg/resolvespec -v +make docker-down +``` + +## ๐Ÿ” Run Specific Tests + +```bash +# Run a specific test function +go test ./pkg/resolvespec -run TestHookRegistry -v + +# Run tests matching a pattern +go test ./pkg/resolvespec -run "TestHook.*" -v + +# Run integration test for specific feature +go test -tags=integration ./pkg/restheadspec -run TestIntegration_GetUsersWithFilters -v +``` + +## ๐Ÿ“ˆ Coverage Reports + +```bash +# Generate HTML coverage report +make coverage + +# View in terminal +go test ./pkg/resolvespec -cover +``` + +## ๐Ÿ› Troubleshooting + +| Problem | Solution | +|---------|----------| +| "No tests found" | Use `-tags=integration` for integration tests | +| "Connection refused" | Run `make docker-up` to start PostgreSQL | +| "Permission denied" | Run `chmod +x scripts/run-integration-tests.sh` | +| Tests fail randomly | Use `-race` flag to detect race conditions | + +## ๐Ÿ“š Full Documentation + +- **Complete Guide**: [README_TESTS.md](./README_TESTS.md) +- **Integration Details**: [INTEGRATION_TESTS.md](./INTEGRATION_TESTS.md) +- **All Commands**: `make help` + +## ๐ŸŽฏ What Gets Tested? + +### pkg/resolvespec +- โœ… Context operations +- โœ… Hook system +- โœ… CRUD operations (Create, Read, Update, Delete) +- โœ… Filtering and sorting +- โœ… Relationship preloading +- โœ… Metadata generation + +### pkg/restheadspec +- โœ… Header-based API operations +- โœ… Query parameter parsing +- โœ… Pagination (limit/offset) +- โœ… Column selection +- โœ… CORS handling +- โœ… Sorting by columns + +## ๐Ÿš€ CI/CD + +GitHub Actions workflow is ready at `.github/workflows/tests.yml` + +Tests run automatically on: +- Push to main/develop branches +- Pull requests + +## ๐Ÿ’ก Tips + +1. **Run unit tests frequently** - They're fast (< 1 second) +2. **Run integration tests before commits** - Catches DB issues +3. **Use `make test-integration-docker`** - Handles everything automatically +4. **Check coverage reports** - Identify untested code +5. **Use `-v` flag** - See detailed test output + +## ๐ŸŽ“ Next Steps + +1. Run unit tests: `make test-unit` +2. Try integration tests: `./scripts/run-integration-tests.sh` +3. Generate coverage: `make coverage` +4. Read full guide: `README_TESTS.md` + +--- + +**Need Help?** Check [README_TESTS.md](./README_TESTS.md) for detailed instructions. diff --git a/tests/README_TESTS.md b/tests/README_TESTS.md new file mode 100644 index 0000000..ae88fa2 --- /dev/null +++ b/tests/README_TESTS.md @@ -0,0 +1,351 @@ +# Testing Guide + +This document provides a comprehensive guide to running tests for the ResolveSpec project. + +## Table of Contents + +- [Quick Start](#quick-start) +- [Unit Tests](#unit-tests) +- [Integration Tests](#integration-tests) +- [Test Coverage](#test-coverage) +- [CI/CD](#cicd) + +## Quick Start + +### Run All Unit Tests + +```bash +# Simple +go test ./pkg/resolvespec ./pkg/restheadspec -v + +# With coverage +make test-unit +``` + +### Run Integration Tests (Automated with Docker) + +```bash +# Easiest way - handles Docker automatically +./scripts/run-integration-tests.sh + +# Or with make +make test-integration-docker + +# Run specific package +./scripts/run-integration-tests.sh resolvespec +./scripts/run-integration-tests.sh restheadspec +``` + +### Run All Tests + +```bash +make test +``` + +## Unit Tests + +Unit tests are located alongside the source files with `_test.go` suffix and **do not** require a database. + +### Test Structure + +``` +pkg/ +โ”œโ”€โ”€ resolvespec/ +โ”‚ โ”œโ”€โ”€ context.go +โ”‚ โ”œโ”€โ”€ context_test.go # Unit tests +โ”‚ โ”œโ”€โ”€ handler.go +โ”‚ โ”œโ”€โ”€ handler_test.go # Unit tests +โ”‚ โ”œโ”€โ”€ hooks.go +โ”‚ โ”œโ”€โ”€ hooks_test.go # Unit tests +โ”‚ โ””โ”€โ”€ integration_test.go # Integration tests +โ””โ”€โ”€ restheadspec/ + โ”œโ”€โ”€ context.go + โ”œโ”€โ”€ context_test.go # Unit tests + โ””โ”€โ”€ integration_test.go # Integration tests +``` + +### Coverage Report + +#### Current Coverage + +- **pkg/resolvespec**: 11.2% (improved from 0%) +- **pkg/restheadspec**: 12.5% (improved from 10.5%) + +#### What's Tested + +##### pkg/resolvespec +- โœ… Context operations (WithSchema, GetEntity, etc.) +- โœ… Hook registry (register, execute, clear) +- โœ… Handler initialization +- โœ… Utility functions (parseModelName, buildRoutePath, toSnakeCase) +- โœ… Column type detection +- โœ… Table name parsing + +##### pkg/restheadspec +- โœ… Context operations including options +- โœ… Hook system +- โœ… Header parsing and decoding +- โœ… Query parameter parsing +- โœ… Nested relation detection +- โœ… Row number operations + +### Running Specific Tests + +```bash +# Run specific test function +go test ./pkg/resolvespec -run TestHookRegistry -v + +# Run tests matching pattern +go test ./pkg/resolvespec -run "TestHook.*" -v + +# Run with coverage +go test ./pkg/resolvespec -cover + +# Generate HTML coverage report +go test ./pkg/resolvespec -coverprofile=coverage.out +go tool cover -html=coverage.out +``` + +## Integration Tests + +Integration tests require a PostgreSQL database and use the `// +build integration` tag. + +### Prerequisites + +**Option 1: Docker (Recommended)** +- Docker and Docker Compose installed + +**Option 2: Manual PostgreSQL** +- PostgreSQL 12+ installed and running +- Create test databases manually (see below) + +### Setup with Docker + +1. **Start PostgreSQL**: + ```bash + make docker-up + # or + docker-compose up -d postgres-test + ``` + +2. **Run Tests**: + ```bash + # Automated (recommended) + ./scripts/run-integration-tests.sh + + # Manual + go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v + ``` + +3. **Stop PostgreSQL**: + ```bash + make docker-down + # or + docker-compose down + ``` + +### Setup without Docker + +1. **Create Databases**: + ```sql + CREATE DATABASE resolvespec_test; + CREATE DATABASE restheadspec_test; + ``` + +2. **Set Environment Variable**: + ```bash + export TEST_DATABASE_URL="host=localhost user=postgres password=yourpass dbname=resolvespec_test port=5432 sslmode=disable" + ``` + +3. **Run Tests**: + ```bash + go test -tags=integration ./pkg/resolvespec -v + + # For restheadspec, update dbname in TEST_DATABASE_URL + export TEST_DATABASE_URL="host=localhost user=postgres password=yourpass dbname=restheadspec_test port=5432 sslmode=disable" + go test -tags=integration ./pkg/restheadspec -v + ``` + +### Integration Test Coverage + +#### pkg/resolvespec (7 tests) +- โœ… Create operation +- โœ… Read operation with pagination +- โœ… Read with filters (age > 25) +- โœ… Update operation +- โœ… Delete operation +- โœ… Metadata retrieval +- โœ… Read with relationship preloading + +#### pkg/restheadspec (10 tests) +- โœ… GET all records +- โœ… GET with header-based filters +- โœ… GET with pagination (limit/offset) +- โœ… GET with sorting +- โœ… GET with column selection +- โœ… GET with relationship preloading +- โœ… Metadata endpoint +- โœ… OPTIONS/CORS handling +- โœ… Query params override headers +- โœ… GET single record by ID + +## Test Coverage + +### Generate Coverage Reports + +```bash +# Unit test coverage +make coverage + +# Integration test coverage +make coverage-integration + +# Both +make coverage && make coverage-integration +``` + +Coverage reports are generated as HTML files: +- `coverage.html` - Unit tests +- `coverage-integration.html` - Integration tests + +### View Coverage + +```bash +# Open in browser +open coverage.html # macOS +xdg-open coverage.html # Linux +start coverage.html # Windows +``` + +## Makefile Commands + +```bash +make help # Show all available commands +make test-unit # Run unit tests +make test-integration # Run integration tests (requires PostgreSQL) +make test # Run all tests +make docker-up # Start PostgreSQL +make docker-down # Stop PostgreSQL +make test-integration-docker # Full automated integration test +make clean # Clean up Docker volumes +make coverage # Generate unit test coverage +make coverage-integration # Generate integration test coverage +``` + +## CI/CD + +### GitHub Actions Example + +See `INTEGRATION_TESTS.md` for a complete GitHub Actions workflow example. + +Key points: +- Use PostgreSQL service container +- Run unit tests first (faster) +- Run integration tests separately +- Generate coverage reports +- Upload coverage to codecov/coveralls + +### GitLab CI Example + +```yaml +stages: + - test + +unit-tests: + stage: test + script: + - go test ./pkg/resolvespec ./pkg/restheadspec -v -cover + +integration-tests: + stage: test + services: + - postgres:15 + variables: + POSTGRES_DB: resolvespec_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + TEST_DATABASE_URL: "host=postgres user=postgres password=postgres dbname=resolvespec_test port=5432 sslmode=disable" + script: + - go test -tags=integration ./pkg/resolvespec ./pkg/restheadspec -v +``` + +## Troubleshooting + +### Tests Won't Run + +**Problem**: `go test` finds no tests +**Solution**: Make sure you're using the `-tags=integration` flag for integration tests + +```bash +# Wrong (for integration tests) +go test ./pkg/resolvespec -v + +# Correct +go test -tags=integration ./pkg/resolvespec -v +``` + +### Database Connection Failed + +**Problem**: "connection refused" or "database does not exist" + +**Solutions**: +1. Check PostgreSQL is running: `docker-compose ps` +2. Verify databases exist: `docker-compose exec postgres-test psql -U postgres -l` +3. Check environment variable: `echo $TEST_DATABASE_URL` +4. Recreate databases: `make clean && make docker-up` + +### Permission Denied on Script + +**Problem**: `./scripts/run-integration-tests.sh: Permission denied` + +**Solution**: +```bash +chmod +x scripts/run-integration-tests.sh +``` + +### Tests Pass Locally but Fail in CI + +**Possible causes**: +1. Different PostgreSQL version +2. Missing environment variables +3. Timezone differences +4. Race conditions (use `-race` flag to detect) + +```bash +go test -race -tags=integration ./pkg/resolvespec -v +``` + +## Best Practices + +1. **Run unit tests frequently** - They're fast and catch most issues +2. **Run integration tests before commits** - Ensures DB operations work +3. **Keep tests independent** - Each test should clean up after itself +4. **Use descriptive test names** - `TestIntegration_GetUsersWithFilters` vs `TestGet` +5. **Test error cases** - Not just the happy path +6. **Mock external dependencies** - Use interfaces for testability +7. **Maintain test data** - Keep test fixtures small and focused + +## Test Data + +Integration tests use these models: +- **TestUser**: id, name, email, age, active, posts[] +- **TestPost**: id, user_id, title, content, published, comments[] +- **TestComment**: id, post_id, content + +Sample test data: +- 3 users (John Doe, Jane Smith, Bob Johnson) +- 3 posts (2 by John, 1 by Jane) +- 3 comments (2 on first post, 1 on second) + +## Performance + +- **Unit tests**: ~0.003s per package +- **Integration tests**: ~0.5-2s per package (depends on database) +- **Total**: <10 seconds for all tests + +## Additional Resources + +- [Go Testing Documentation](https://golang.org/pkg/testing/) +- [Table Driven Tests](https://github.com/golang/go/wiki/TableDrivenTests) +- [GORM Testing](https://gorm.io/docs/testing.html) +- [Integration Tests Guide](./INTEGRATION_TESTS.md)