merge v0.23.0-rc changes

This commit is contained in:
Gani Georgiev
2024-09-29 19:23:19 +03:00
parent ad92992324
commit 844f18cac3
753 changed files with 85141 additions and 63396 deletions
+221 -110
View File
@@ -6,24 +6,38 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/labstack/echo/v5"
"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 string
Method string
Url string
Body io.Reader
RequestHeaders map[string]string
// 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
@@ -35,30 +49,116 @@ type ApiScenario struct {
Timeout time.Duration
// expectations
// ---
ExpectedStatus int
ExpectedContent []string
// ---------------------------------------------------------------
// 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
ExpectedEvents map[string]int
// 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.T) *TestApp
BeforeTestFunc func(t *testing.T, app *TestApp, e *echo.Echo)
AfterTestFunc func(t *testing.T, app *TestApp, res *http.Response)
// ---------------------------------------------------------------
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) {
var name = scenario.Name
if name == "" {
name = fmt.Sprintf("%s:%s", scenario.Method, scenario.Url)
}
t.Run(name, scenario.test)
t.Run(scenario.normalizedName(), func(t *testing.T) {
scenario.test(t)
})
}
func (scenario *ApiScenario) test(t *testing.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)
@@ -74,120 +174,131 @@ func (scenario *ApiScenario) test(t *testing.T) {
}
defer testApp.Cleanup()
e, err := apis.InitApi(testApp)
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
testApp.OnBeforeServe().Trigger(&core.ServeEvent{
App: testApp,
Router: e,
})
serveEvent := new(core.ServeEvent)
serveEvent.App = testApp
serveEvent.Router = baseRouter
if scenario.BeforeTestFunc != nil {
scenario.BeforeTestFunc(t, testApp, e)
}
recorder := httptest.NewRecorder()
req := httptest.NewRequest(scenario.Method, scenario.Url, scenario.Body)
// add middleware to timeout long-running requests (eg. keep-alive routes)
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) 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(c.Request().Context(), scenario.Timeout)
defer cancelFunc()
c.SetRequest(c.Request().Clone(ctx))
}
return next(c)
serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
if scenario.BeforeTestFunc != nil {
scenario.BeforeTestFunc(t, testApp, e)
}
})
// set default header
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
// reset the event counters in case a hook was triggered from a before func (eg. db save)
testApp.ResetEventCalls()
// set scenario headers
for k, v := range scenario.RequestHeaders {
req.Header.Set(k, v)
}
// 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()
// execute request
e.ServeHTTP(recorder, req)
if scenario.Timeout > 0 {
ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout)
defer cancelFunc()
re.Request = re.Request.Clone(ctx)
}
res := recorder.Result()
return re.Next()
},
Priority: -9999,
})
if res.StatusCode != scenario.ExpectedStatus {
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
}
recorder := httptest.NewRecorder()
if scenario.Delay > 0 {
time.Sleep(scenario.Delay)
}
req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body)
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())
// set default header
req.Header.Set("content-type", "application/json")
// set scenario headers
for k, v := range scenario.Headers {
req.Header.Set(k, v)
}
} else {
// normalize json response format
buffer := new(bytes.Buffer)
err := json.Compact(buffer, recorder.Body.Bytes())
var normalizedBody string
// execute request
mux, err := e.Router.BuildMux()
if err != nil {
// not a json...
normalizedBody = recorder.Body.String()
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 {
normalizedBody = buffer.String()
}
// 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.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
}
}
}
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
}
}
}
// to minimize the breaking changes we always expect the error
// events to be called on API error
if res.StatusCode >= 400 {
if scenario.ExpectedEvents == nil {
scenario.ExpectedEvents = map[string]int{}
}
if _, ok := scenario.ExpectedEvents["OnBeforeApiError"]; !ok {
scenario.ExpectedEvents["OnBeforeApiError"] = 1
}
if _, ok := scenario.ExpectedEvents["OnAfterApiError"]; !ok {
scenario.ExpectedEvents["OnAfterApiError"] = 1
}
}
actualNum := remainingEvents[event]
if actualNum != expectedNum {
t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum)
}
if len(testApp.EventCalls) > len(scenario.ExpectedEvents) {
t.Errorf("Expected events %v, got %v", scenario.ExpectedEvents, testApp.EventCalls)
}
for event, expectedCalls := range scenario.ExpectedEvents {
actualCalls := testApp.EventCalls[event]
if actualCalls != expectedCalls {
t.Errorf("Expected event %s to be called %d, got %d", event, expectedCalls, actualCalls)
delete(remainingEvents, event)
}
}
if scenario.AfterTestFunc != nil {
scenario.AfterTestFunc(t, testApp, res)
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)
}
}
+562 -282
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+147
View File
@@ -0,0 +1,147 @@
package tests
import (
"strconv"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
func StubOTPRecords(app core.App) error {
superuser2, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
if err != nil {
return err
}
superuser2.SetRaw("stubId", "superuser2")
superuser3, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test3@example.com")
if err != nil {
return err
}
superuser3.SetRaw("stubId", "superuser3")
user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
return err
}
user1.SetRaw("stubId", "user1")
now := types.NowDateTime()
old := types.NowDateTime().Add(-1 * time.Hour)
stubs := map[*core.Record][]types.DateTime{
superuser2: {now, now.Add(-1 * time.Millisecond), old, now.Add(-2 * time.Millisecond), old.Add(-1 * time.Millisecond)},
superuser3: {now.Add(-3 * time.Millisecond), now.Add(-2 * time.Minute)},
user1: {old},
}
for record, idDates := range stubs {
for i, date := range idDates {
otp := core.NewOTP(app)
otp.Id = record.GetString("stubId") + "_" + strconv.Itoa(i)
otp.SetRecordRef(record.Id)
otp.SetCollectionRef(record.Collection().Id)
otp.SetPassword("test123")
otp.SetRaw("created", date)
if err := app.SaveNoValidate(otp); err != nil {
return err
}
}
}
return nil
}
func StubMFARecords(app core.App) error {
superuser2, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test2@example.com")
if err != nil {
return err
}
superuser2.SetRaw("stubId", "superuser2")
superuser3, err := app.FindAuthRecordByEmail(core.CollectionNameSuperusers, "test3@example.com")
if err != nil {
return err
}
superuser3.SetRaw("stubId", "superuser3")
user1, err := app.FindAuthRecordByEmail("users", "test@example.com")
if err != nil {
return err
}
user1.SetRaw("stubId", "user1")
now := types.NowDateTime()
old := types.NowDateTime().Add(-1 * time.Hour)
type mfaData struct {
method string
date types.DateTime
}
stubs := map[*core.Record][]mfaData{
superuser2: {
{core.MFAMethodOTP, now},
{core.MFAMethodOTP, old},
{core.MFAMethodPassword, now.Add(-2 * time.Minute)},
{core.MFAMethodPassword, now.Add(-1 * time.Millisecond)},
{core.MFAMethodOAuth2, old.Add(-1 * time.Millisecond)},
},
superuser3: {
{core.MFAMethodOAuth2, now.Add(-3 * time.Millisecond)},
{core.MFAMethodPassword, now.Add(-3 * time.Minute)},
},
user1: {
{core.MFAMethodOAuth2, old},
},
}
for record, idDates := range stubs {
for i, data := range idDates {
otp := core.NewMFA(app)
otp.Id = record.GetString("stubId") + "_" + strconv.Itoa(i)
otp.SetRecordRef(record.Id)
otp.SetCollectionRef(record.Collection().Id)
otp.SetMethod(data.method)
otp.SetRaw("created", data.date)
if err := app.SaveNoValidate(otp); err != nil {
return err
}
}
}
return nil
}
func StubLogsData(app *TestApp) error {
_, err := app.AuxDB().NewQuery(`
delete from {{_logs}};
insert into {{_logs}} (
[[id]],
[[level]],
[[message]],
[[data]],
[[created]],
[[updated]]
)
values
(
"873f2133-9f38-44fb-bf82-c8f53b310d91",
0,
"test_message1",
'{"status":200}',
"2022-05-01 10:00:00.123Z",
"2022-05-01 10:00:00.123Z"
),
(
"f2133873-44fb-9f38-bf82-c918f53b310d",
8,
"test_message2",
'{"status":400}',
"2022-05-02 10:00:00.123Z",
"2022-05-02 10:00:00.123Z"
);
`).Execute()
return err
}
-35
View File
@@ -1,35 +0,0 @@
package tests
func MockLogsData(app *TestApp) error {
_, err := app.LogsDB().NewQuery(`
delete from {{_logs}};
insert into {{_logs}} (
[[id]],
[[level]],
[[message]],
[[data]],
[[created]],
[[updated]]
)
values
(
"873f2133-9f38-44fb-bf82-c8f53b310d91",
0,
"test_message1",
'{"status":200}',
"2022-05-01 10:00:00.123Z",
"2022-05-01 10:00:00.123Z"
),
(
"f2133873-44fb-9f38-bf82-c918f53b310d",
8,
"test_message2",
'{"status":400}',
"2022-05-02 10:00:00.123Z",
"2022-05-02 10:00:00.123Z"
);
`).Execute()
return err
}
+55 -16
View File
@@ -1,6 +1,7 @@
package tests
import (
"slices"
"sync"
"github.com/pocketbase/pocketbase/tools/mailer"
@@ -8,15 +9,19 @@ import (
var _ mailer.Mailer = (*TestMailer)(nil)
// TestMailer is a mock `mailer.Mailer` implementation.
// TestMailer is a mock [mailer.Mailer] implementation.
type TestMailer struct {
mux sync.Mutex
mux sync.Mutex
messages []*mailer.Message
}
TotalSend int
LastMessage mailer.Message
// Send implements [mailer.Mailer] interface.
func (tm *TestMailer) Send(m *mailer.Message) error {
tm.mux.Lock()
defer tm.mux.Unlock()
// @todo consider deprecating the above 2 fields?
SentMessages []mailer.Message
tm.messages = append(tm.messages, m)
return nil
}
// Reset clears any previously test collected data.
@@ -24,19 +29,53 @@ func (tm *TestMailer) Reset() {
tm.mux.Lock()
defer tm.mux.Unlock()
tm.TotalSend = 0
tm.LastMessage = mailer.Message{}
tm.SentMessages = nil
tm.messages = nil
}
// Send implements `mailer.Mailer` interface.
func (tm *TestMailer) Send(m *mailer.Message) error {
// TotalSend returns the total number of sent messages.
func (tm *TestMailer) TotalSend() int {
tm.mux.Lock()
defer tm.mux.Unlock()
tm.TotalSend++
tm.LastMessage = *m
tm.SentMessages = append(tm.SentMessages, tm.LastMessage)
return nil
return len(tm.messages)
}
// Messages returns a shallow copy of all of the collected test messages.
func (tm *TestMailer) Messages() []*mailer.Message {
tm.mux.Lock()
defer tm.mux.Unlock()
return slices.Clone(tm.messages)
}
// FirstMessage returns a shallow copy of the first sent message.
//
// Returns an empty mailer.Message struct if there are no sent messages.
func (tm *TestMailer) FirstMessage() mailer.Message {
tm.mux.Lock()
defer tm.mux.Unlock()
var m mailer.Message
if len(tm.messages) > 0 {
return *tm.messages[0]
}
return m
}
// LastMessage returns a shallow copy of the last sent message.
//
// Returns an empty mailer.Message struct if there are no sent messages.
func (tm *TestMailer) LastMessage() mailer.Message {
tm.mux.Lock()
defer tm.mux.Unlock()
var m mailer.Message
if len(tm.messages) > 0 {
return *tm.messages[len(tm.messages)-1]
}
return m
}
+32
View File
@@ -0,0 +1,32 @@
package tests
import (
"errors"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
// TestValidationErrors checks whether the provided rawErrors are
// instance of [validation.Errors] and contains the expectedErrors keys.
func TestValidationErrors(t *testing.T, rawErrors error, expectedErrors []string) {
var errs validation.Errors
if rawErrors != nil && !errors.As(rawErrors, &errs) {
t.Fatalf("Failed to parse errors, expected to find validation.Errors, got %T\n%v", rawErrors, rawErrors)
}
if len(errs) != len(expectedErrors) {
keys := make([]string, 0, len(errs))
for k := range errs {
keys = append(keys, k)
}
t.Fatalf("Expected error keys \n%v\ngot\n%v\n%v", expectedErrors, keys, errs)
}
for _, k := range expectedErrors {
if _, ok := errs[k]; !ok {
t.Fatalf("Missing expected error key %q in %v", k, errs)
}
}
}