mirror of
https://github.com/pocketbase/pocketbase.git
synced 2025-02-05 10:45:09 +02:00
305 lines
8.4 KiB
Go
305 lines
8.4 KiB
Go
package tests
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/pocketbase/pocketbase/apis"
|
|
"github.com/pocketbase/pocketbase/core"
|
|
"github.com/pocketbase/pocketbase/tools/hook"
|
|
)
|
|
|
|
// ApiScenario defines a single api request test case/scenario.
|
|
type ApiScenario struct {
|
|
// Name is the test name.
|
|
Name string
|
|
|
|
// Method is the HTTP method of the test request to use.
|
|
Method string
|
|
|
|
// URL is the url/path of the endpoint you want to test.
|
|
URL string
|
|
|
|
// Body specifies the body to send with the request.
|
|
//
|
|
// For example:
|
|
//
|
|
// strings.NewReader(`{"title":"abc"}`)
|
|
Body io.Reader
|
|
|
|
// Headers specifies the headers to send with the request (e.g. "Authorization": "abc")
|
|
Headers map[string]string
|
|
|
|
// Delay adds a delay before checking the expectations usually
|
|
// to ensure that all fired non-awaited go routines have finished
|
|
Delay time.Duration
|
|
|
|
// Timeout specifies how long to wait before cancelling the request context.
|
|
//
|
|
// A zero or negative value means that there will be no timeout.
|
|
Timeout time.Duration
|
|
|
|
// expectations
|
|
// ---------------------------------------------------------------
|
|
|
|
// ExpectedStatus specifies the expected response HTTP status code.
|
|
ExpectedStatus int
|
|
|
|
// List of keywords that MUST exist in the response body.
|
|
//
|
|
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
|
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
|
ExpectedContent []string
|
|
|
|
// List of keywords that MUST NOT exist in the response body.
|
|
//
|
|
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
|
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
|
NotExpectedContent []string
|
|
|
|
// List of hook events to check whether they were fired or not.
|
|
//
|
|
// You can use the wildcard "*" event key if you want to ensure
|
|
// that no other hook events except those listed have been fired.
|
|
//
|
|
// For example:
|
|
//
|
|
// map[string]int{ "*": 0 } // no hook events were fired
|
|
// map[string]int{ "*": 0, "EventA": 2 } // no hook events, except EventA were fired
|
|
// map[string]int{ EventA": 2, "EventB": 0 } // ensures that EventA was fired exactly 2 times and EventB exactly 0 times.
|
|
ExpectedEvents map[string]int
|
|
|
|
// test hooks
|
|
// ---------------------------------------------------------------
|
|
|
|
TestAppFactory func(t testing.TB) *TestApp
|
|
BeforeTestFunc func(t testing.TB, app *TestApp, e *core.ServeEvent)
|
|
AfterTestFunc func(t testing.TB, app *TestApp, res *http.Response)
|
|
}
|
|
|
|
// Test executes the test scenario.
|
|
//
|
|
// Example:
|
|
//
|
|
// func TestListExample(t *testing.T) {
|
|
// scenario := tests.ApiScenario{
|
|
// Name: "list example collection",
|
|
// Method: http.MethodGet,
|
|
// URL: "/api/collections/example/records",
|
|
// ExpectedStatus: 200,
|
|
// ExpectedContent: []string{
|
|
// `"totalItems":3`,
|
|
// `"id":"0yxhwia2amd8gec"`,
|
|
// `"id":"achvryl401bhse3"`,
|
|
// `"id":"llvuca81nly1qls"`,
|
|
// },
|
|
// ExpectedEvents: map[string]int{
|
|
// "OnRecordsListRequest": 1,
|
|
// "OnRecordEnrich": 3,
|
|
// },
|
|
// }
|
|
//
|
|
// scenario.Test(t)
|
|
// }
|
|
func (scenario *ApiScenario) Test(t *testing.T) {
|
|
t.Run(scenario.normalizedName(), func(t *testing.T) {
|
|
scenario.test(t)
|
|
})
|
|
}
|
|
|
|
// Benchmark benchmarks the test scenario.
|
|
//
|
|
// Example:
|
|
//
|
|
// func BenchmarkListExample(b *testing.B) {
|
|
// scenario := tests.ApiScenario{
|
|
// Name: "list example collection",
|
|
// Method: http.MethodGet,
|
|
// URL: "/api/collections/example/records",
|
|
// ExpectedStatus: 200,
|
|
// ExpectedContent: []string{
|
|
// `"totalItems":3`,
|
|
// `"id":"0yxhwia2amd8gec"`,
|
|
// `"id":"achvryl401bhse3"`,
|
|
// `"id":"llvuca81nly1qls"`,
|
|
// },
|
|
// ExpectedEvents: map[string]int{
|
|
// "OnRecordsListRequest": 1,
|
|
// "OnRecordEnrich": 3,
|
|
// },
|
|
// }
|
|
//
|
|
// scenario.Benchmark(b)
|
|
// }
|
|
func (scenario *ApiScenario) Benchmark(b *testing.B) {
|
|
b.Run(scenario.normalizedName(), func(b *testing.B) {
|
|
for i := 0; i < b.N; i++ {
|
|
scenario.test(b)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (scenario *ApiScenario) normalizedName() string {
|
|
var name = scenario.Name
|
|
|
|
if name == "" {
|
|
name = fmt.Sprintf("%s:%s", scenario.Method, scenario.URL)
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
func (scenario *ApiScenario) test(t testing.TB) {
|
|
var testApp *TestApp
|
|
if scenario.TestAppFactory != nil {
|
|
testApp = scenario.TestAppFactory(t)
|
|
if testApp == nil {
|
|
t.Fatal("TestAppFactory must return a non-nill app instance")
|
|
}
|
|
} else {
|
|
var testAppErr error
|
|
testApp, testAppErr = NewTestApp()
|
|
if testAppErr != nil {
|
|
t.Fatalf("Failed to initialize the test app instance: %v", testAppErr)
|
|
}
|
|
}
|
|
defer testApp.Cleanup()
|
|
|
|
baseRouter, err := apis.NewRouter(testApp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// manually trigger the serve event to ensure that custom app routes and middlewares are registered
|
|
serveEvent := new(core.ServeEvent)
|
|
serveEvent.App = testApp
|
|
serveEvent.Router = baseRouter
|
|
|
|
serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
|
if scenario.BeforeTestFunc != nil {
|
|
scenario.BeforeTestFunc(t, testApp, e)
|
|
}
|
|
|
|
// reset the event counters in case a hook was triggered from a before func (eg. db save)
|
|
testApp.ResetEventCalls()
|
|
|
|
// add middleware to timeout long-running requests (eg. keep-alive routes)
|
|
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
|
|
Func: func(re *core.RequestEvent) error {
|
|
slowTimer := time.AfterFunc(3*time.Second, func() {
|
|
t.Logf("[WARN] Long running test %q", scenario.Name)
|
|
})
|
|
defer slowTimer.Stop()
|
|
|
|
if scenario.Timeout > 0 {
|
|
ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout)
|
|
defer cancelFunc()
|
|
re.Request = re.Request.Clone(ctx)
|
|
}
|
|
|
|
return re.Next()
|
|
},
|
|
Priority: -9999,
|
|
})
|
|
|
|
recorder := httptest.NewRecorder()
|
|
|
|
req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body)
|
|
|
|
// set default header
|
|
req.Header.Set("content-type", "application/json")
|
|
|
|
// set scenario headers
|
|
for k, v := range scenario.Headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
|
|
// execute request
|
|
mux, err := e.Router.BuildMux()
|
|
if err != nil {
|
|
t.Fatalf("Failed to build router mux: %v", err)
|
|
}
|
|
mux.ServeHTTP(recorder, req)
|
|
|
|
res := recorder.Result()
|
|
|
|
if res.StatusCode != scenario.ExpectedStatus {
|
|
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
|
|
}
|
|
|
|
if scenario.Delay > 0 {
|
|
time.Sleep(scenario.Delay)
|
|
}
|
|
|
|
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
|
|
if len(recorder.Body.Bytes()) != 0 {
|
|
t.Errorf("Expected empty body, got \n%v", recorder.Body.String())
|
|
}
|
|
} else {
|
|
// normalize json response format
|
|
buffer := new(bytes.Buffer)
|
|
err := json.Compact(buffer, recorder.Body.Bytes())
|
|
var normalizedBody string
|
|
if err != nil {
|
|
// not a json...
|
|
normalizedBody = recorder.Body.String()
|
|
} else {
|
|
normalizedBody = buffer.String()
|
|
}
|
|
|
|
for _, item := range scenario.ExpectedContent {
|
|
if !strings.Contains(normalizedBody, item) {
|
|
t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody)
|
|
break
|
|
}
|
|
}
|
|
|
|
for _, item := range scenario.NotExpectedContent {
|
|
if strings.Contains(normalizedBody, item) {
|
|
t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
remainingEvents := maps.Clone(testApp.EventCalls)
|
|
|
|
var noOtherEventsShouldRemain bool
|
|
for event, expectedNum := range scenario.ExpectedEvents {
|
|
if event == "*" && expectedNum <= 0 {
|
|
noOtherEventsShouldRemain = true
|
|
continue
|
|
}
|
|
|
|
actualNum := remainingEvents[event]
|
|
if actualNum != expectedNum {
|
|
t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum)
|
|
}
|
|
|
|
delete(remainingEvents, event)
|
|
}
|
|
|
|
if noOtherEventsShouldRemain && len(remainingEvents) > 0 {
|
|
t.Errorf("Missing expected remaining events:\n%#v\nAll triggered app events are:\n%#v", remainingEvents, testApp.EventCalls)
|
|
}
|
|
|
|
if scenario.AfterTestFunc != nil {
|
|
scenario.AfterTestFunc(t, testApp, res)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if serveErr != nil {
|
|
t.Fatalf("Failed to trigger app serve hook: %v", serveErr)
|
|
}
|
|
}
|