(Feat): Initial Commit
Some checks failed
Build & Test / build-test (push) Has been cancelled
Build & Test / swagger-codegen-cli (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled

This commit is contained in:
2026-07-03 19:41:31 +05:30
commit 7e940c83a7
461 changed files with 45002 additions and 0 deletions

40
internal/test/README.md Normal file
View File

@@ -0,0 +1,40 @@
# `/test`
General global test utilities.
### Regarding `test/test_*.go` and its `test.With*` functions
Test helpers like `test.WithTestDatabase` and `test.WithTestServer` require usage of a closure, as these functions automatically manage the setup **and** teardown (e.g. server shutdown, db connection drop) for your testcase.
Other pkgs don't have this requirement (e.g. the initialization code for `test.NewTestMailer` which covers the setup for the `mailer` mock), thus, please use this `With*` convention incl. closure **only** when it makes sense.
### Regarding `test/fixtures.go`
This are your global db test fixtures, that are only available while testing. However, feel free to setup specialized fixtures per package if required (e.g. just initialize an additional IntegreSQL template).
### Regarding `test/helper_*.go`
Please use this convention to specify test only utility functions.
### Regarding snapshot testing
Golden files can be created by using the `Snapshoter.Save(t TestingT, data ...interface{})` method. The snapshot can be configured to force an update, use a different replacer function or to set a different file location and suffix for the snaphot.
A snapshot can be updated by either calling the `Update(true)` method, or by using the global override by setting the environment variable `TEST_UPDATE_GOLDEN` to `true`. To update all snapshots the call might look like this: `TEST_UPDATE_GOLDEN=true make test`.
### `testdata` and `.` or `_` prefixed files
Note that Go will ignore directories or files that begin with "." or "_", so you have more flexibility in terms of how you name your test data directory.
> Go build ignores directory named testdata.
> The Go tool will ignore any directory in your $GOPATH that starts with a period, an underscore, or matches the word testdata
> When go test runs, it sets current directory as package directory
* https://github.com/golang-standards/project-layout/blob/master/test/README.md
* https://medium.com/@povilasve/go-advanced-tips-tricks-a872503ac859
* https://dave.cheney.net/2016/05/10/test-fixtures-in-go
Examples:
* https://github.com/openshift/origin/tree/master/test (test data is in the `/testdata` subdirectory)
https://github.com/golang-standards/project-layout/tree/master/test

172
internal/test/fixtures/fixtures.go vendored Normal file
View File

@@ -0,0 +1,172 @@
package fixtures
import (
"context"
"fmt"
"time"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/boil"
)
const (
PlainTestUserPassword = "password"
HashedTestUserPassword = "$argon2id$v=19$m=65536,t=1,p=4$RFO8ulg2c2zloG0029pAUQ$2Po6NUIhVCMm9vivVDuzo7k5KVWfZzJJfeXzC+n+row" //nolint:gosec
)
// Insertable represents a common interface for all model instances so they may be inserted via the Inserts() func
type Insertable interface {
Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error
}
// The main definition which fixtures are available through Fixtures().
// Mind the declaration order! The fields get inserted exactly in the order they are declared.
type FixtureMap struct {
User1 *models.User
User1AppUserProfile *models.AppUserProfile
User1AccessToken1 *models.AccessToken
User1RefreshToken1 *models.RefreshToken
User2 *models.User
User2AppUserProfile *models.AppUserProfile
User2AccessToken1 *models.AccessToken
User2RefreshToken1 *models.RefreshToken
UserDeactivated *models.User
UserDeactivatedAppUserProfile *models.AppUserProfile
UserDeactivatedAccessToken1 *models.AccessToken
UserDeactivatedRefreshToken1 *models.RefreshToken
User1PushToken *models.PushToken
User1PushTokenAPN *models.PushToken
UserRequiresConfirmation *models.User
UserRequiresConfirmationAppUserProfile *models.AppUserProfile
UserRequiresConfirmationConfirmationToken *models.ConfirmationToken
}
// Fixtures returns a function wrapping our fixtures, which tests are allowed to manipulate.
// Each test (which may run concurrently) receives a fresh copy, preventing side effects between test runs.
func Fixtures() FixtureMap {
now := time.Now()
f := FixtureMap{}
f.User1 = &models.User{
ID: "f6ede5d8-e22a-4ca5-aa12-67821865a3e5",
IsActive: true,
Username: null.StringFrom("user1@example.com"),
Password: null.StringFrom(HashedTestUserPassword),
Scopes: []string{"app"},
}
f.User1AppUserProfile = &models.AppUserProfile{
UserID: f.User1.ID,
LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)),
}
f.User1AccessToken1 = &models.AccessToken{
Token: "1cfc27d7-a178-4051-802b-f3ff3967c95c",
ValidUntil: now.Add(10 * 365 * 24 * time.Hour),
UserID: f.User1.ID,
}
f.User1RefreshToken1 = &models.RefreshToken{
Token: "66412eaf-2b89-404d-bbb5-46c3b8bf1a53",
UserID: f.User1.ID,
}
f.User2 = &models.User{
ID: "76a79a2b-fbd8-45a0-b35b-671a28a87acf",
IsActive: true,
Username: null.StringFrom("user2@example.com"),
Password: null.StringFrom(HashedTestUserPassword),
Scopes: []string{"app"},
}
f.User2AppUserProfile = &models.AppUserProfile{
UserID: f.User2.ID,
LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)),
}
f.User2AccessToken1 = &models.AccessToken{
Token: "115d28c5-f585-4fb5-9656-fb321739fee5",
ValidUntil: now.Add(10 * 365 * 24 * time.Hour),
UserID: f.User2.ID,
}
f.User2RefreshToken1 = &models.RefreshToken{
Token: "ea909c75-63d1-4348-a63c-4bcf8ab334a2",
UserID: f.User2.ID,
}
f.UserRequiresConfirmation = &models.User{
ID: "402e1669-fff7-43ca-8f08-071c06409479",
IsActive: false,
RequiresConfirmation: true,
Username: null.StringFrom("userrequiresconfirmation@example.com"),
Password: null.StringFrom(HashedTestUserPassword),
Scopes: []string{"app"},
}
f.UserRequiresConfirmationAppUserProfile = &models.AppUserProfile{
UserID: f.UserRequiresConfirmation.ID,
LegalAcceptedAt: null.TimeFrom(now.Add(time.Minute * -10)),
}
f.UserRequiresConfirmationConfirmationToken = &models.ConfirmationToken{
Token: "c9182e0b-4a46-4825-9f10-56f04a8b1665",
ValidUntil: now.Add(time.Hour),
UserID: f.UserRequiresConfirmation.ID,
}
f.UserDeactivated = &models.User{
ID: "d9c0dee9-239e-4323-979a-a5354d289627",
IsActive: false,
Username: null.StringFrom("userdeactivated@example.com"),
Password: null.StringFrom(HashedTestUserPassword),
Scopes: []string{"app"},
}
f.UserDeactivatedAppUserProfile = &models.AppUserProfile{
UserID: f.UserDeactivated.ID,
LegalAcceptedAt: null.Time{},
}
f.UserDeactivatedAccessToken1 = &models.AccessToken{
Token: "24d0b38d-387c-400c-80fc-a71d85031d4c",
ValidUntil: now.Add(10 * 365 * 24 * time.Hour),
UserID: f.UserDeactivated.ID,
}
f.UserDeactivatedRefreshToken1 = &models.RefreshToken{
Token: "b6e13a88-7b18-4f17-b819-71b196be2444",
UserID: f.UserDeactivated.ID,
}
f.User1PushToken = &models.PushToken{
ID: "98ad176b-af90-44b7-b991-d9ebfc5dd9a0",
Token: "cQ_Qk3ZCCZelUZ_K_Yn2BV:APA91bG4jst5srGYZqBAn_wRfiJUzAOQ4k8tV0sDcV4uas2ln5wNwkE_ebneR5Fqk7GvndZ-h3mWnjWaI8yZ4sVwo8qu_Aztotqup4mlEPNYgFGqTlJ5ltQrJG5oKp4RoYQ_0CeFaymn",
UserID: f.User1.ID,
Provider: models.ProviderTypeFCM,
}
f.User1PushTokenAPN = &models.PushToken{
ID: "5909b472-86f8-4d15-bb63-d49f4fad41a3",
Token: "0a863a72-d391-4217-9f26-388801684744",
UserID: f.User1.ID,
Provider: models.ProviderTypeApn,
}
return f
}
// Inserts defines the order in which the fixtures will be inserted
// into the test database
func Inserts() []Insertable {
fix := Fixtures()
insertableIfc := (*Insertable)(nil)
inserts, err := util.GetFieldsImplementing(&fix, insertableIfc)
if err != nil {
panic(fmt.Errorf("failed to get insertable fixture fields: %w", err))
}
return inserts
}

97
internal/test/fixtures/fixtures_test.go vendored Normal file
View File

@@ -0,0 +1,97 @@
package fixtures_test
import (
"database/sql"
"testing"
"allaboutapps.dev/aw/go-starter/internal/models"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"github.com/aarondl/null/v8"
"github.com/aarondl/sqlboiler/v4/boil"
_ "github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFixturesReload(t *testing.T) {
test.WithTestDatabase(t, func(db *sql.DB) {
err := fixtures.Fixtures().User1.Reload(t.Context(), db)
require.NoError(t, err)
})
}
func TestInsert(t *testing.T) {
test.WithTestDatabase(t, func(db *sql.DB) {
userNew := models.User{
ID: "6d00d09b-fab3-43d8-a163-279fe7ba533e",
IsActive: true,
Username: null.StringFrom("userNew@example.com"),
Password: null.StringFrom("$argon2id$v=19$m=65536,t=1,p=4$RFO8ulg2c2zloG0029pAUQ$2Po6NUIhVCMm9vivVDuzo7k5KVWfZzJJfeXzC+n+row"),
Scopes: []string{"app"},
}
err := userNew.Insert(t.Context(), db, boil.Infer())
require.NoError(t, err)
})
}
func TestUpdate(t *testing.T) {
test.WithTestDatabase(t, func(db *sql.DB) {
originalUser1 := fixtures.Fixtures().User1
updatedUser1 := *originalUser1
updatedUser1.Username = null.StringFrom("user_updated@example.com")
if updatedUser1.Username == originalUser1.Username {
t.Fatalf("names match!")
}
_, err := updatedUser1.Update(t.Context(), db, boil.Infer())
if err != nil {
t.Error("failed to update")
}
// Attention, this actually mutates our user1 fixture!!!
err = originalUser1.Reload(t.Context(), db)
if err != nil {
t.Error("failed to reload")
}
if updatedUser1.Username != originalUser1.Username {
t.Fatalf("names don't match!")
}
})
// with another testdatabase:
test.WithTestDatabase(t, func(db *sql.DB) {
originalUser1 := fixtures.Fixtures().User1
// ensure our fixture is the same again!
if originalUser1.Username != null.StringFrom("user1@example.com") {
err := originalUser1.Reload(t.Context(), db)
if err != nil {
t.Error("failed to reload")
}
if originalUser1.Username != null.StringFrom("user1@example.com") {
t.Fatalf("fixture even not the same after reload!")
}
t.Fatalf("fixture was modified!")
}
})
}
func TestInsertableInterface(t *testing.T) {
var user any = &models.AppUserProfile{
UserID: "62b13d29-5c4e-420e-b991-a631d3938776",
}
_, ok := user.(fixtures.Insertable)
assert.True(t, ok, "AppUserProfile should implement the Insertable interface")
}

View File

@@ -0,0 +1,88 @@
package test
import (
"crypto/sha256"
"fmt"
"io"
"net/http/httptest"
"os"
"strings"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func CompareFileHashes(t *testing.T, expectedFilePath string, actualFilePath string) {
t.Helper()
expectedFile, err := os.Open(expectedFilePath)
require.NoError(t, err)
defer expectedFile.Close()
expectedHash := sha256.New()
_, err = io.Copy(expectedHash, expectedFile)
require.NoError(t, err)
actualFile, err := os.Open(actualFilePath)
require.NoError(t, err)
defer actualFile.Close()
actualhash := sha256.New()
_, err = io.Copy(actualhash, actualFile)
require.NoError(t, err)
assert.Equal(t, expectedHash.Sum(nil), actualhash.Sum(nil))
}
func CompareAllPayload(t *testing.T, base map[string]interface{}, toCheck map[string]string, skipKeys map[string]bool, keyConvertFunc ...func(string) string) {
t.Helper()
var keyFunc func(string) string
if len(keyConvertFunc) > 0 {
keyFunc = keyConvertFunc[0]
} else {
keyFunc = func(s string) string {
return s
}
}
for key, val := range base {
if skipKeys[key] {
continue
}
// checks with contains because of dateTime and null.String struct
contains := strings.Contains(toCheck[keyFunc(key)], fmt.Sprintf("%v", val))
assert.Truef(t, contains, "Expected for %s: '%s'. Got: '%s'", key, val, toCheck[keyFunc(key)])
}
}
func CompareAll(t *testing.T, base map[string]string, toCheck map[string]string, skipKeys map[string]bool) {
t.Helper()
for key, val := range base {
if skipKeys[key] {
continue
}
// checks with contains because of dateTime and null.String struct
contains := strings.Contains(toCheck[key], val)
assert.Truef(t, contains, "Expected for %s: '%s'. Got: '%s'", key, val, toCheck[key])
}
}
func RequireHTTPError(t *testing.T, res *httptest.ResponseRecorder, httpError *httperrors.HTTPError) httperrors.HTTPError {
t.Helper()
if httpError.Code != nil {
require.Equal(t, int(*httpError.Code), res.Result().StatusCode)
}
var response httperrors.HTTPError
ParseResponseAndValidate(t, res, &response)
require.Equal(t, httpError, &response)
return response
}

View File

@@ -0,0 +1,97 @@
package test_test
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/strfmt/conv"
"github.com/stretchr/testify/require"
)
func TestCompareFileHashes(t *testing.T) {
tmpDir := t.TempDir()
newFilePath := tmpDir + "example2.jpg"
filePath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg")
file1, err := os.Open(filePath)
require.NoError(t, err)
defer file1.Close()
file2, err := os.Create(newFilePath)
require.NoError(t, err)
defer file2.Close()
_, err = io.Copy(file2, file1)
require.NoError(t, err)
require.FileExists(t, newFilePath)
test.CompareFileHashes(t, filePath, newFilePath)
}
func TestCompareAllPayload(t *testing.T) {
payload := test.GenericPayload{
"A": 1,
"B": "b",
"C": 2.3,
"D": true,
"E": "2020-02-01",
"F": conv.UUID4(strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e")),
"X_Y": "skipped",
}
response := map[string]string{
"A": "1",
"B": "b",
"C": "2.3",
"D": "true",
"E": util.Date(2020, 2, 1, time.UTC).String(),
"F": "0862573e-6ccb-4684-847d-276d3364e91e",
}
toSkip := map[string]bool{
"X_Y": true,
}
test.CompareAllPayload(t, payload, response, toSkip)
payload = test.GenericPayload{
"a": 1,
"B": "b",
"C": 2.3,
"d": true,
"e": "2020-02-01",
"F": conv.UUID4(strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e")),
"X_Y": "skipped",
}
test.CompareAllPayload(t, payload, response, toSkip, strings.ToUpper)
}
func TestCompareAll(t *testing.T) {
payload := map[string]string{
"A": "1",
"B": "b",
"C": "2.3",
"D": "true",
"E": "2020-02-01",
"F": strfmt.UUID4("0862573e-6ccb-4684-847d-276d3364e91e").String(),
"X_Y": "skipped",
}
response := map[string]string{
"A": "1",
"B": "b",
"C": "2.3",
"D": "true",
"E": util.Date(2020, 2, 1, time.UTC).String(),
"F": "0862573e-6ccb-4684-847d-276d3364e91e",
}
toSkip := map[string]bool{
"X_Y": true,
}
test.CompareAll(t, payload, response, toSkip)
}

View File

@@ -0,0 +1,41 @@
package test
import (
"errors"
"os"
"path/filepath"
"testing"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/util"
)
// DotEnvLoadLocalOrSkipTest tries to load the `.env.local` file in the projectroot
// overwriting ENV vars. If the file is not found, the test will be automatically skipped.
func DotEnvLoadLocalOrSkipTest(t *testing.T) {
t.Helper()
absolutePathToEnvFile := filepath.Join(util.GetProjectRootDir(), ".env.local")
DotEnvLoadFileOrSkipTest(t, absolutePathToEnvFile)
}
// DotEnvLoadFileOrSkipTest tries to load the overgiven path to the dotenv file overwriting
// ENV vars. If the file is not found, the test will be automatically skipped.
func DotEnvLoadFileOrSkipTest(t *testing.T, absolutePathToEnvFile string) {
t.Helper()
// this test should be automatically skipped if no default `.env.local` file was found.
err := config.DotEnvLoad(
absolutePathToEnvFile,
func(k string, v string) error { t.Setenv(k, v); return nil })
if err != nil {
if errors.Is(err, os.ErrNotExist) {
t.Skip(absolutePathToEnvFile, "not found, skipping test.")
} else {
t.Fatal(err)
}
} else {
t.Log(absolutePathToEnvFile, "override ENV variables!")
}
}

View File

@@ -0,0 +1,25 @@
package test_test
import (
"path/filepath"
"testing"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/stretchr/testify/assert"
)
// This test will run or skip depending upon if you currently have a `.env.local` in your project directory
// Note: We do not activate this test in the go-starter template, as it would always be skipped.
// func TestDotEnvLoadLocalOrSkipTest(t *testing.T) {
// test.DotEnvLoadLocalOrSkipTest(t)
// }
// This test will always run as the /internal/test/testdata/.env.test.local is checked into git.
func TestDotEnvLoadFileOrSkipTest(t *testing.T) {
// explicitly load a (test) dotenv file before getting a new config (for a testserver)
test.DotEnvLoadFileOrSkipTest(t, filepath.Join(util.GetProjectRootDir(), "/internal/test/testdata/.env.test.local"))
c := config.DefaultServiceConfigFromEnv()
assert.Equal(t, "http://overwritten.dotenv.frontend.url.tld:3000", c.Frontend.BaseURL)
}

View File

@@ -0,0 +1,62 @@
package test
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/stretchr/testify/require"
)
// PrepareTestFile copies a test file by name from test/testdata/<filename> to a folder unique to the test.
// Used for tests with document to load reference files by fixtures.
func PrepareTestFile(t *testing.T, fileName string, destFileName ...string) {
t.Helper()
src, err := os.Open(filepath.Join(util.GetProjectRootDir(), "test", "testdata", fileName))
require.NoError(t, err)
defer src.Close()
dest := fileName
if len(destFileName) > 0 {
dest = destFileName[0]
}
path := filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name()), "documents", dest)
err = os.MkdirAll(filepath.Dir(path), 0755)
require.NoError(t, err)
dst, err := os.Create(path)
require.NoError(t, err)
defer dst.Close()
_, err = io.Copy(dst, src)
require.NoError(t, err)
}
// CleanupTestFiles removes folder unique to the test if exists
func CleanupTestFiles(t *testing.T) {
t.Helper()
err := os.RemoveAll(filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name())))
require.NoError(t, err)
}
// WithTempDir creates a folder unique to the tests and ensures cleanup of the folder will be
// performed after the fn got called.
func WithTempDir(t *testing.T, testFunc func(localBasePath string, basePath string)) {
t.Helper()
localBasePath := filepath.Join(util.GetProjectRootDir(), "assets", "mnt", strings.ToLower(t.Name()))
basePath := "/documents"
path := filepath.Join(localBasePath, basePath)
err := os.MkdirAll(filepath.Dir(path), 0755)
require.NoError(t, err)
defer CleanupTestFiles(t)
testFunc(localBasePath, basePath)
}

View File

@@ -0,0 +1,31 @@
package test_test
import (
"os"
"path/filepath"
"strings"
"testing"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPrepareTestFile(t *testing.T) {
var path string
test.WithTempDir(t, func(localBasePath, basePath string) {
assert.True(t, strings.HasSuffix(localBasePath, strings.ToLower(t.Name())))
assert.NotEmpty(t, basePath)
fileName := "example.jpg"
test.PrepareTestFile(t, fileName)
path = filepath.Join(localBasePath, basePath, fileName)
_, err := os.Stat(path)
require.NoError(t, err)
})
_, err := os.Stat(path)
require.Error(t, err)
require.ErrorIs(t, err, os.ErrNotExist)
}

View File

@@ -0,0 +1,64 @@
package test
import (
"fmt"
"reflect"
"strings"
)
// GetMapFromStructByTag returns a map of a given struct using a tag name as key and
// the string of the property value as value.
// inspired by: https://stackoverflow.com/questions/55879028/golang-get-structs-field-name-by-json-tag
func GetMapFromStructByTag(tag string, input any) map[string]string {
res := make(map[string]string)
inputType := reflect.TypeOf(input)
if inputType.Kind() != reflect.Struct {
return res
}
val := reflect.ValueOf(input)
for i := 0; i < inputType.NumField(); i++ {
f := inputType.Field(i)
tagvalue := strings.Split(f.Tag.Get(tag), ",")[0] // use split to ignore tag "options" like omitempty, etc.
if tagvalue == "" {
continue
}
field := val.Field(i)
if field.Kind() == reflect.Ptr {
if !field.IsNil() {
res[tagvalue] = fmt.Sprintf("%v", field.Elem().Interface())
}
} else {
res[tagvalue] = fmt.Sprintf("%v", field.Interface())
}
}
return res
}
func GetMapFromStruct(input any) map[string]string {
res := make(map[string]string)
inputType := reflect.TypeOf(input)
if inputType.Kind() != reflect.Struct {
return res
}
val := reflect.ValueOf(input)
for i := 0; i < inputType.NumField(); i++ {
field := inputType.Field(i)
fieldValue := val.Field(i)
if fieldValue.Kind() == reflect.Ptr {
if !fieldValue.IsNil() {
res[field.Name] = fmt.Sprintf("%v", fieldValue.Elem().Interface())
}
} else {
res[field.Name] = fmt.Sprintf("%v", fieldValue.Interface())
}
}
return res
}

View File

@@ -0,0 +1,81 @@
package test_test
import (
"testing"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/go-openapi/swag"
"github.com/stretchr/testify/assert"
)
func TestGetMapFromStruct(t *testing.T) {
type tmp struct {
A string
B int
C interface{}
D float32
E bool
F *string
G *int
}
data := tmp{
A: "string",
B: 1,
C: tmp{
A: "string2",
},
D: 2.3,
E: true,
F: swag.String("stringPtr"),
G: swag.Int(5),
}
xMap := test.GetMapFromStruct(data)
assert.Len(t, xMap, 7)
assert.Equal(t, "string", xMap["A"])
assert.Equal(t, "1", xMap["B"])
assert.Contains(t, xMap["C"], "string2")
assert.Equal(t, "2.3", xMap["D"])
assert.Equal(t, "true", xMap["E"])
assert.Equal(t, "stringPtr", xMap["F"])
assert.Equal(t, "5", xMap["G"])
}
func TestGetMapFromStructByTag(t *testing.T) {
type tmp struct {
A string `x:"1,omitempty" y:"2"`
B int `x:"3"`
C interface{} `x:"12"`
D float32 `x:"2"`
E bool `x:"5"`
F *string `x:"4"`
G *int `x:"6"`
}
data := tmp{
A: "string",
B: 1,
C: tmp{
A: "string2",
},
D: 2.3,
E: true,
F: swag.String("stringPtr"),
G: swag.Int(5),
}
xMap := test.GetMapFromStructByTag("x", data)
assert.Len(t, xMap, 7)
assert.Equal(t, "string", xMap["1"])
assert.Equal(t, "1", xMap["3"])
assert.Contains(t, xMap["12"], "string2")
assert.Equal(t, "2.3", xMap["2"])
assert.Equal(t, "true", xMap["5"])
assert.Equal(t, "stringPtr", xMap["4"])
assert.Equal(t, "5", xMap["6"])
yMap := test.GetMapFromStructByTag("y", data)
assert.Len(t, yMap, 1)
assert.Equal(t, "string", yMap["2"])
}

View File

@@ -0,0 +1,139 @@
package test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"allaboutapps.dev/aw/go-starter/internal/api"
"github.com/go-openapi/runtime"
"github.com/go-openapi/strfmt"
"github.com/labstack/echo/v4"
)
type GenericPayload map[string]interface{}
type GenericArrayPayload []interface{}
func (g GenericPayload) Reader(t TestingT) *bytes.Reader {
t.Helper()
b, err := json.Marshal(g)
if err != nil {
t.Fatalf("failed to serialize payload: %v", err)
}
return bytes.NewReader(b)
}
func (g GenericArrayPayload) Reader(t TestingT) *bytes.Reader {
t.Helper()
b, err := json.Marshal(g)
if err != nil {
t.Fatalf("failed to serialize payload: %v", err)
}
return bytes.NewReader(b)
}
func PerformRequestWithParams(t TestingT, s *api.Server, method string, path string, body GenericPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder {
t.Helper()
if body == nil {
return PerformRequestWithRawBody(t, s, method, path, nil, headers, queryParams)
}
return PerformRequestWithRawBody(t, s, method, path, body.Reader(t), headers, queryParams)
}
func PerformRequestWithArrayAndParams(t TestingT, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder {
t.Helper()
if body == nil {
return PerformRequestWithRawBody(t, s, method, path, nil, headers, queryParams)
}
return PerformRequestWithRawBody(t, s, method, path, body.Reader(t), headers, queryParams)
}
func PerformRequestWithRawBody(t TestingT, s *api.Server, method string, path string, body io.Reader, headers http.Header, queryParams map[string]string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, body)
if headers != nil {
req.Header = headers
}
if body != nil && len(req.Header.Get(echo.HeaderContentType)) == 0 {
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
}
if queryParams != nil {
q := req.URL.Query()
for k, v := range queryParams {
q.Add(k, v)
}
req.URL.RawQuery = q.Encode()
}
res := httptest.NewRecorder()
s.Echo.ServeHTTP(res, req)
return res
}
func PerformRequest(t TestingT, s *api.Server, method string, path string, body GenericPayload, headers http.Header) *httptest.ResponseRecorder {
t.Helper()
return PerformRequestWithParams(t, s, method, path, body, headers, nil)
}
func PerformRequestWithArray(t TestingT, s *api.Server, method string, path string, body GenericArrayPayload, headers http.Header) *httptest.ResponseRecorder {
t.Helper()
return PerformRequestWithArrayAndParams(t, s, method, path, body, headers, nil)
}
func ParseResponseBody(t TestingT, res *httptest.ResponseRecorder, v interface{}) {
t.Helper()
if err := json.NewDecoder(res.Result().Body).Decode(&v); err != nil {
t.Fatalf("Failed to parse response body: %v", err)
}
}
func ParseResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) {
t.Helper()
ParseResponseBody(t, res, &v)
if err := v.Validate(strfmt.Default); err != nil {
t.Fatalf("Failed to validate response: %v", err)
}
}
func HeadersWithAuth(t TestingT, token string) http.Header {
t.Helper()
return HeadersWithConfigurableAuth(t, "Bearer", token)
}
func HeadersWithConfigurableAuth(t TestingT, scheme string, token string) http.Header {
t.Helper()
headers := http.Header{}
headers.Set(echo.HeaderAuthorization, fmt.Sprintf("%s %s", scheme, token))
return headers
}
func HeadersWithAPIKeyAuth(t TestingT, token string) http.Header {
t.Helper()
return HeadersWithConfigurableAuth(t, "APIKey", token)
}

View File

@@ -0,0 +1,354 @@
package test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strings"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/davecgh/go-spew/spew"
"github.com/go-openapi/runtime"
"github.com/pmezard/go-difflib/difflib"
)
var (
DefaultSnapshotDirPathAbs = filepath.Join(util.GetProjectRootDir(), "/test/testdata/snapshots")
UpdateGoldenGlobal = util.GetEnvAsBool("TEST_UPDATE_GOLDEN", false)
)
var defaultReplacer = func(s string) string {
return s
}
var spewConfig = spew.ConfigState{
Indent: " ",
SortKeys: true, // maps should be spewed in a deterministic order
DisablePointerAddresses: true, // don't spew the addresses of pointers
DisableCapacities: true, // don't spew capacities of collections
SpewKeys: true, // if unable to sort map keys then spew keys to strings and sort those
}
type snapshoter struct {
update bool
label string
replacer func(s string) string
location string
skips []string
}
var Snapshoter = snapshoter{
update: false,
label: "",
replacer: defaultReplacer,
location: DefaultSnapshotDirPathAbs,
}
// Save creates a formatted dump of the given data.
// It will fail the test if the dump is different from the saved dump.
// It will also fail if it is the creation or an update of the snapshot.
// vastly inspired by https://github.com/bradleyjkemp/cupaloy
// main reason for self implementation is the replacer function and general flexibility
func (s snapshoter) Save(t TestingT, data ...interface{}) {
t.Helper()
err := os.MkdirAll(s.location, os.ModePerm)
if err != nil {
t.Fatal(err)
}
dump := s.replacer(spewConfig.Sdump(data...))
s.save(t, dump)
}
// Save creates a dump of the given data.
// It will fail the test if the dump is different from the saved dump.
// It will also fail if it is the creation or an update of the snapshot.
// vastly inspired by https://github.com/bradleyjkemp/cupaloy
// main reason for self implementation is the replacer function and general flexibility
func (s snapshoter) SaveBytes(t TestingT, data []byte, fileExtensionOverride ...string) {
t.Helper()
err := os.MkdirAll(s.location, os.ModePerm)
if err != nil {
t.Fatal(err)
}
s.saveBytes(t, data, fileExtensionOverride...)
}
// SaveJSON creates a dump of the given data as JSON.
// It will fail the test if the dump is different from the saved dump.
// It will also fail if it is the creation or an update of the snapshot.
// vastly inspired by https://github.com/bradleyjkemp/cupaloy
// main reason for self implementation is the replacer function and general flexibility
func (s snapshoter) SaveJSON(t TestingT, data any) {
t.Helper()
// marshal data
marshaled, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
// indent data
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, marshaled, "", "\t"); err != nil {
t.Fatal(err)
}
jsonS := s
// set custom replacer for JSON compared to dumps
jsonS.replacer = func(target string) string {
skipString := strings.Join(jsonS.skips, "|")
re, err := regexp.Compile(fmt.Sprintf(`"(?i)(%s)": .*`, skipString))
if err != nil {
panic(err)
}
// replace lines with property name + <redacted>
return re.ReplaceAllString(target, `"$1": <redacted>,`)
}
jsonS.label += "JSON"
jsonS.SaveString(t, prettyJSON.String())
}
// SaveUJSON is a short version for .Update(true).SaveJSON(...)
func (s snapshoter) SaveUJSON(t TestingT, data any) {
t.Helper()
s.Update(true).SaveJSON(t, data)
}
// SaveString creates a snapshot of the raw string.
// Used to snapshot payloads or mails as formatted data.
// It will fail the test if the dump is different from the saved dump.
// It will also fail if it is the creation or an update of the snapshot.
// vastly inspired by https://github.com/bradleyjkemp/cupaloy
// main reason for self implementation is the replacer function and general flexibility
func (s snapshoter) SaveString(t TestingT, data string) {
t.Helper()
err := os.MkdirAll(s.location, os.ModePerm)
if err != nil {
t.Fatal(err)
}
data = s.replacer(data)
s.save(t, data)
}
func (s snapshoter) SaveUString(t TestingT, data string) {
t.Helper()
s.Update(true).save(t, data)
}
// SaveResponseAndValidate is used to create 2 snapshots for endpoint tests.
// One snapshot will save the raw JSON response as indented JSON.
// For the second snapshot the response will be parsed and validated using request helpers (helper_request.go)
// Afterwards a dump of the response will be saved.
// It will fail the test if the dump is different from the saved dump.
// It will also fail if it is the creation or an update of the snapshot.
func (s snapshoter) SaveResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) {
t.Helper()
// snapshot prettyfied json first
var prettyJSON bytes.Buffer
if err := json.Indent(&prettyJSON, res.Body.Bytes(), "", "\t"); err != nil {
t.Fatal(err)
}
jsonS := s
// set custom replacer for JSON compared to dumps
jsonS.replacer = func(target string) string {
skipString := strings.Join(jsonS.skips, "|")
re, err := regexp.Compile(fmt.Sprintf(`"(?i)(%s)": .*`, skipString))
if err != nil {
panic(err)
}
// replace lines with property name + <redacted>
return re.ReplaceAllString(target, `"$1": <redacted>,`)
}
jsonS.label += "JSON"
jsonS.SaveString(t, prettyJSON.String())
// bind and snapshot response type struct
ParseResponseAndValidate(t, res, v)
s.Save(t, v)
}
func (s snapshoter) SaveUResponseAndValidate(t TestingT, res *httptest.ResponseRecorder, v runtime.Validatable) {
t.Helper()
s.Update(true).SaveResponseAndValidate(t, res, v)
}
// SaveU is a short version for .Update(true).Save(...)
func (s snapshoter) SaveU(t TestingT, data ...interface{}) {
t.Helper()
s.Update(true).Save(t, data...)
}
// Skip creates a custom replace function using a regex, this will replace any
// replacer function set in the Snapshoter.
// Each line of the formatted dump is matched against the property name defined in skip and
// the value will be replaced to deal with generated values that change each test.
func (s snapshoter) Skip(skip []string) snapshoter {
s.skips = skip
s.replacer = func(target string) string {
skipString := fmt.Sprintf("\\s+%s", strings.Join(skip, "|\\s+"))
re, err := regexp.Compile(fmt.Sprintf("(?m)(%s): .*[^{]$", skipString))
if err != nil {
panic(err)
}
reStruct, err := regexp.Compile(fmt.Sprintf("((%s): .*){\n([^}]|\n)*}", skipString))
if err != nil {
panic(err)
}
// replace lines with property name + <redacted>
return reStruct.ReplaceAllString(re.ReplaceAllString(target, "$1: <redacted>,"), "$1 { <redacted> }")
}
return s
}
// Redact is a wrapper for Skip for easier usage with a variadic.
func (s snapshoter) Redact(skip ...string) snapshoter {
return s.Skip(skip)
}
// Upadte is used to force an update for the snapshot. Will fail the test.
func (s snapshoter) Update(update bool) snapshoter {
s.update = update
return s
}
// Label is used to add a suffix to the snapshots golden file.
func (s snapshoter) Label(label string) snapshoter {
s.label = label
return s
}
// Replacer is used to define a custom replace function in order to replace
// generated values (e.g. IDs).
func (s snapshoter) Replacer(replacer func(s string) string) snapshoter {
s.replacer = replacer
return s
}
// Location is used to save the golden file to a different location.
func (s snapshoter) Location(location string) snapshoter {
s.location = location
return s
}
func (s snapshoter) save(t TestingT, dump string) {
t.Helper()
snapshotName := fmt.Sprintf("%s%s", strings.ReplaceAll(t.Name(), "/", "-"), s.label)
snapshotAbsPath := filepath.Join(s.location, fmt.Sprintf("%s.golden", snapshotName))
if s.update || UpdateGoldenGlobal {
err := writeSnapshotString(snapshotAbsPath, dump)
if err != nil {
t.Fatal(err)
}
t.Errorf("Updating snapshot: '%s'", snapshotName)
return
}
prevSnapBytes, err := os.ReadFile(snapshotAbsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
err = writeSnapshotString(snapshotAbsPath, dump)
if err != nil {
t.Fatal(err)
}
t.Errorf("No snapshot exists for name: '%s'. Creating new snapshot", snapshotName)
return
}
t.Fatal(err)
}
prevSnap := string(prevSnapBytes)
if prevSnap != dump {
diff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
A: difflib.SplitLines(prevSnap),
B: difflib.SplitLines(dump),
FromFile: "Previous",
ToFile: "Current",
Context: 1,
})
if err != nil {
t.Fatal(err)
}
t.Error(fmt.Sprintf("%s: %s", snapshotName, diff))
}
}
func (s snapshoter) saveBytes(t TestingT, dump []byte, fileExtensionOverride ...string) {
t.Helper()
snapshotName := fmt.Sprintf("%s%s", strings.ReplaceAll(t.Name(), "/", "-"), s.label)
fileExtension := "golden"
if len(fileExtensionOverride) > 0 {
fileExtension = fileExtensionOverride[0]
}
snapshotAbsPath := filepath.Join(s.location, fmt.Sprintf("%s.%s", snapshotName, fileExtension))
if s.update || UpdateGoldenGlobal {
err := writeSnapshot(snapshotAbsPath, dump)
if err != nil {
t.Fatal(err)
}
t.Errorf("Updating snapshot: '%s'", snapshotName)
return
}
prevSnapBytes, err := os.ReadFile(snapshotAbsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
err = writeSnapshot(snapshotAbsPath, dump)
if err != nil {
t.Fatal(err)
}
t.Errorf("No snapshot exists for name: '%s'. Creating new snapshot", snapshotName)
return
}
t.Fatal(err)
}
if !bytes.Equal(prevSnapBytes, dump) {
t.Error(fmt.Sprintf("%s: Byte Snapshot Diff", snapshotName))
}
}
func writeSnapshotString(absPath string, dump string) error {
return writeSnapshot(absPath, []byte(dump))
}
func writeSnapshot(absPath string, dump []byte) error {
err := os.WriteFile(absPath, dump, os.FileMode(0644))
if err != nil {
return fmt.Errorf("failed to write snapshot: %w", err)
}
return nil
}

View File

@@ -0,0 +1,358 @@
package test_test
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"regexp"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"allaboutapps.dev/aw/go-starter/internal/test/mocks"
"allaboutapps.dev/aw/go-starter/internal/util"
apitypes "allaboutapps.dev/aw/go-starter/internal/types"
"github.com/aarondl/sqlboiler/v4/types"
"github.com/go-openapi/swag"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
const (
helloWorld = "Hello World!"
)
func TestSnapshot(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
data := struct {
A string
B int
C bool
D *string
}{
A: "foo",
B: 1,
C: true,
D: swag.String("bar"),
}
test.Snapshoter.Save(t, data, helloWorld)
}
func TestSnapshotWithReplacer(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
randID, err := util.GenerateRandomBase64String(20)
require.NoError(t, err)
data := struct {
ID string
A string
B int
C bool
D *string
}{
ID: randID,
A: "foo",
B: 1,
C: true,
D: swag.String("bar"),
}
replacer := func(s string) string {
re, err := regexp.Compile(`ID:.*"(.*)",`)
require.NoError(t, err)
return re.ReplaceAllString(s, "ID: <redacted>,")
}
test.Snapshoter.Replacer(replacer).Save(t, data)
}
func TestSnapshotShouldFail(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
data := struct {
A string
B int
C bool
D *string
}{
A: "fo",
B: 1,
C: true,
D: swag.String("bar"),
}
tMock := new(mocks.TestingT)
tMock.On("Helper").Return()
tMock.On("Name").Return("TestSnapshotShouldFail")
tMock.On("Error", mock.Anything).Return()
test.Snapshoter.Save(tMock, data, helloWorld)
tMock.AssertNotCalled(t, "Fatal")
tMock.AssertNotCalled(t, "Fatalf")
tMock.AssertCalled(t, "Error", mock.Anything)
}
func TestSnapshotWithUpdate(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
data := struct {
A string
B int
C bool
D *string
}{
A: "fo",
B: 1,
C: true,
D: swag.String("bar"),
}
tMock := new(mocks.TestingT)
tMock.On("Helper").Return()
tMock.On("Name").Return("TestSnapshotWithUpdate")
tMock.On("Errorf", mock.Anything, mock.Anything).Return()
test.Snapshoter.Update(true).Save(tMock, data, helloWorld)
tMock.AssertNotCalled(t, "Error")
tMock.AssertNotCalled(t, "Fatal")
tMock.AssertCalled(t, "Errorf", mock.Anything, mock.Anything)
}
func TestSnapshotNotExists(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
data := struct {
A string
B int
C bool
D *string
}{
A: "foo",
B: 1,
C: true,
D: swag.String("bar"),
}
defer func() {
os.Remove(filepath.Join(test.DefaultSnapshotDirPathAbs, "TestSnapshotNotExists.golden"))
}()
tMock := new(mocks.TestingT)
tMock.On("Helper").Return()
tMock.On("Name").Return("TestSnapshotNotExists")
tMock.On("Fatalf", mock.Anything, mock.Anything).Return()
tMock.On("Fatal", mock.Anything).Return()
tMock.On("Error", mock.Anything).Return()
tMock.On("Errorf", mock.Anything, mock.Anything).Return()
test.Snapshoter.Save(tMock, data, helloWorld)
tMock.AssertNotCalled(t, "Error")
tMock.AssertNotCalled(t, "Fatal")
tMock.AssertCalled(t, "Errorf", mock.Anything, mock.Anything)
}
func TestSnapshotSkipFields(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
randID, err := util.GenerateRandomBase64String(20)
require.NoError(t, err)
data := struct {
ID string
A string
B int
C bool
D *string
}{
ID: randID,
A: "foo",
B: 1,
C: true,
D: swag.String("bar"),
}
test.Snapshoter.Skip([]string{"ID"}).Save(t, data)
}
func TestSnapshotSkipPrefixedFields(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
data := struct {
ID string
OtherIDStr string
OtherIDInt int
OtherIDBool bool
OtherIDPTR *string
OtherIDStruct struct {
ID string
}
}{
ID: "foo",
OtherIDStr: "id str",
OtherIDInt: 4,
OtherIDBool: true,
OtherIDPTR: swag.String("ID str ptr"),
OtherIDStruct: struct{ ID string }{
ID: "foo",
},
}
test.Snapshoter.Skip([]string{"ID"}).Save(t, data)
}
func TestSnapshotSkipMultilineFields(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
randID, err := util.GenerateRandomBase64String(20)
require.NoError(t, err)
data := struct {
ID string
A string
B int
C bool
D interface{}
E []string
F map[string]int
}{
ID: randID,
A: "foo",
B: 1,
C: true,
D: struct {
Foo string
Bar int
}{
Foo: "skip me",
Bar: 3,
},
E: []string{"skip me", "skip me too"},
F: map[string]int{
"skip me": 1,
"skip me too": 2,
"skip me three": 3,
},
}
test.Snapshoter.Skip([]string{"ID", "D", "E", "F"}).Save(t, data)
}
func TestSnapshotWithLabel(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
data := struct {
A string
B int
C bool
D *string
}{
A: "foo",
B: 1,
C: true,
D: swag.String("bar"),
}
test.Snapshoter.Label("_A").Save(t, data)
test.Snapshoter.Label("_B").Save(t, helloWorld)
}
func TestSnapshotWithLocation(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
data := struct {
A string
B int
C bool
D *string
}{
A: "foo",
B: 1,
C: true,
D: swag.String("bar"),
}
location := filepath.Join(util.GetProjectRootDir(), "/internal/test/testdata")
test.Snapshoter.Location(location).Save(t, data)
}
func TestSaveResponseAndValidate(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
test.WithTestServer(t, func(s *api.Server) {
fix := fixtures.Fixtures()
res := test.PerformRequest(t, s, "GET", "/api/v1/auth/userinfo", nil, test.HeadersWithAuth(t, fix.User1AccessToken1.Token))
require.Equal(t, http.StatusOK, res.Result().StatusCode)
var response apitypes.GetUserInfoResponse
test.Snapshoter.Redact("Email", "UpdatedAt", "updated_at").SaveResponseAndValidate(t, res, &response)
})
}
func TestSnapshotJSON(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
randID, err := util.GenerateRandomBase64String(20)
require.NoError(t, err)
details := struct {
ID string
A string
B int
C bool
D interface{}
E []string
F map[string]int
}{
ID: randID,
A: "foo",
B: 1,
C: true,
D: struct {
Foo string
Bar int
}{
Foo: "skip me",
Bar: 3,
},
E: []string{"skip me", "skip me too"},
F: map[string]int{
"skip me": 1,
"skip me too": 2,
"skip me three": 3,
},
}
marshaled, err := json.Marshal(details)
require.NoError(t, err)
test.Snapshoter.Redact("ID").SaveJSON(t, types.JSON(json.RawMessage(marshaled)))
}
func TestSnapshotSaveBytesImage(t *testing.T) {
if test.UpdateGoldenGlobal {
t.Skip()
}
filepath := filepath.Join(util.GetProjectRootDir(), "/test/testdata", "example.jpg")
// read file and save bytes
content, err := os.ReadFile(filepath)
require.NoError(t, err)
test.Snapshoter.SaveBytes(t, content, "jpg")
}

View File

@@ -0,0 +1,154 @@
// Code generated by mockery. DO NOT EDIT.
// Initial code generated by mockery using a local installation.
// The methods for the testing.T interface will not change that often so no
// automatic generation of the mock is required.
package mocks
import mock "github.com/stretchr/testify/mock"
// TestingT is an autogenerated mock type for the TestingT type
type TestingT struct {
mock.Mock
}
// Cleanup provides a mock function with given fields: _a0
func (_m *TestingT) Cleanup(_a0 func()) {
_m.Called(_a0)
}
// Error provides a mock function with given fields: args
func (_m *TestingT) Error(args ...interface{}) {
var _ca []interface{}
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Errorf provides a mock function with given fields: format, args
func (_m *TestingT) Errorf(format string, args ...interface{}) {
var _ca []interface{}
_ca = append(_ca, format)
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Fail provides a mock function with given fields:
func (_m *TestingT) Fail() {
_m.Called()
}
// FailNow provides a mock function with given fields:
func (_m *TestingT) FailNow() {
_m.Called()
}
// Failed provides a mock function with given fields:
func (_m *TestingT) Failed() bool {
ret := _m.Called()
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// Fatal provides a mock function with given fields: args
func (_m *TestingT) Fatal(args ...interface{}) {
var _ca []interface{}
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Fatalf provides a mock function with given fields: format, args
func (_m *TestingT) Fatalf(format string, args ...interface{}) {
var _ca []interface{}
_ca = append(_ca, format)
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Helper provides a mock function with given fields:
func (_m *TestingT) Helper() {
_m.Called()
}
// Log provides a mock function with given fields: args
func (_m *TestingT) Log(args ...interface{}) {
var _ca []interface{}
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Logf provides a mock function with given fields: format, args
func (_m *TestingT) Logf(format string, args ...interface{}) {
var _ca []interface{}
_ca = append(_ca, format)
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Name provides a mock function with given fields:
func (_m *TestingT) Name() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// Skip provides a mock function with given fields: args
func (_m *TestingT) Skip(args ...interface{}) {
var _ca []interface{}
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// SkipNow provides a mock function with given fields:
func (_m *TestingT) SkipNow() {
_m.Called()
}
// Skipf provides a mock function with given fields: format, args
func (_m *TestingT) Skipf(format string, args ...interface{}) {
var _ca []interface{}
_ca = append(_ca, format)
_ca = append(_ca, args...)
_m.Called(_ca...)
}
// Skipped provides a mock function with given fields:
func (_m *TestingT) Skipped() bool {
ret := _m.Called()
var r0 bool
if rf, ok := ret.Get(0).(func() bool); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(bool)
}
return r0
}
// TempDir provides a mock function with given fields:
func (_m *TestingT) TempDir() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}

View File

@@ -0,0 +1,27 @@
package test
import (
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/api"
"github.com/dropbox/godropbox/time2"
)
func GetMockClock(t *testing.T, clock time2.Clock) *time2.MockClock {
t.Helper()
mc, ok := clock.(*time2.MockClock)
if !ok {
t.Fatalf("invalid clock type, got %T, want *time2.MockClock", clock)
}
return mc
}
func SetMockClock(t *testing.T, s *api.Server, time time.Time) {
t.Helper()
mockClock := GetMockClock(t, s.Clock)
mockClock.Set(time)
}

View File

@@ -0,0 +1,26 @@
package test_test
import (
"testing"
"time"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTestClock(t *testing.T) {
test.WithTestServer(t, func(s *api.Server) {
now := time.Date(2025, 2, 5, 11, 42, 30, 0, time.UTC)
test.SetMockClock(t, s, now)
assert.Equal(t, now, s.Clock.Now())
clock := test.GetMockClock(t, s.Clock)
require.NotNil(t, clock)
assert.Equal(t, now, clock.Now())
})
}

View File

@@ -0,0 +1,382 @@
package test
import (
"context"
"crypto/md5" //nolint:gosec
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
pUtil "allaboutapps.dev/aw/go-starter/internal/util"
dbutil "allaboutapps.dev/aw/go-starter/internal/util/db"
"github.com/aarondl/sqlboiler/v4/boil"
"github.com/allaboutapps/integresql-client-go"
"github.com/allaboutapps/integresql-client-go/pkg/util"
"github.com/pkg/errors"
migrate "github.com/rubenv/sql-migrate"
)
var (
client *integresql.Client
// tracks IntegreSQL template initialization and hash relookup (to reidentify the pool from a precomputed poolID)
poolInitMap = &sync.Map{} // "poolID" -> *sync.Once
poolHashMap = &sync.Map{} // "poolID" -> "poolHash"
// we will compute a db template hash over the following dirs/files
migDir = config.DatabaseMigrationFolder
fixFile = filepath.Join(pUtil.GetProjectRootDir(), "/internal/test/fixtures/fixtures.go")
selfFile = filepath.Join(pUtil.GetProjectRootDir(), "/internal/test/test_database.go")
defaultPoolPaths = []string{migDir, fixFile, selfFile}
)
//nolint:gochecknoinits
func init() {
// autoinitialize IntegreSQL client
c, err := integresql.DefaultClientFromEnv()
if err != nil {
panic(errors.Wrap(err, "Failed to create new integresql-client"))
}
client = c
// pin migrate to use the globally defined `migrations` table identifier
migrate.SetTable(config.DatabaseMigrationTable)
}
// WithTestDatabase returns an isolated test database based on the current migrations and fixtures.
func WithTestDatabase(t *testing.T, closure func(db *sql.DB)) {
t.Helper()
ctx := t.Context()
WithTestDatabaseContext(ctx, t, closure)
}
// WithTestDatabaseContext returns an isolated test database based on the current migrations and fixtures.
// The provided context will be used during setup (instead of the default background context).
func WithTestDatabaseContext(ctx context.Context, t *testing.T, closure func(db *sql.DB)) {
t.Helper()
poolID := strings.Join(defaultPoolPaths, ",")
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
doOnce, ok := initOnce.(*sync.Once)
if !ok {
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
}
doOnce.Do(func() {
t.Helper()
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
poolHash := storePoolHash(t, poolID, defaultPoolPaths)
// properly build up the template database once
execClosureNewIntegresTemplate(ctx, t, poolHash, func(db *sql.DB) error {
t.Helper()
countMigrations, err := ApplyMigrations(t, db)
if err != nil {
t.Fatalf("Failed to apply migrations for %q: %v\n", poolHash, err)
return err
}
t.Logf("Applied %d migrations for hash %q", countMigrations, poolHash)
countFixtures, err := ApplyTestFixtures(ctx, t, db)
if err != nil {
t.Fatalf("Failed to apply test fixtures for %q: %v\n", poolHash, err)
return err
}
t.Logf("Applied %d test fixtures for hash %q", countFixtures, poolHash)
return nil
})
})
// execute closure in a new IntegreSQL database build from above template
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabase", closure)
}
type DatabaseDumpConfig struct {
DumpFile string // required, absolute path to dump file
ApplyMigrations bool // optional, default false
ApplyTestFixtures bool // optional, default false
}
// WithTestDatabaseFromDump returns an isolated test database based on a dump file.
func WithTestDatabaseFromDump(t *testing.T, config DatabaseDumpConfig, closure func(db *sql.DB)) {
t.Helper()
ctx := t.Context()
WithTestDatabaseFromDumpContext(ctx, t, config, closure)
}
// WithTestDatabaseFromDumpContext returns an isolated test database based on a dump file.
// The provided context will be used during setup (instead of the default background context).
func WithTestDatabaseFromDumpContext(ctx context.Context, t *testing.T, config DatabaseDumpConfig, closure func(db *sql.DB)) {
t.Helper()
// DumpFile is mandadory.
if config.DumpFile == "" {
t.Fatal("DatabaseDumpConfig.DumpFile is mandadory and cannot be ''")
}
// poolID must incorporate additional config args in the final hash
fragments := fmt.Sprintf("?migrations=%v&fixtures=%v", config.ApplyMigrations, config.ApplyTestFixtures)
poolID := strings.Join([]string{config.DumpFile, selfFile}, ",") + fragments
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
doOnce, ok := initOnce.(*sync.Once)
if !ok {
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
}
doOnce.Do(func() {
t.Helper()
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
poolHash := storePoolHash(t, poolID, []string{config.DumpFile, selfFile}, fragments)
// properly build up the template database once
execClosureNewIntegresTemplate(ctx, t, poolHash, func(db *sql.DB) error {
t.Helper()
if err := ApplyDump(ctx, t, db, config.DumpFile); err != nil {
t.Fatalf("Failed to apply dumps for %q: %v\n", poolHash, err)
return err
}
t.Logf("Applied dump for hash %q", poolHash)
if config.ApplyMigrations {
countMigrations, err := ApplyMigrations(t, db)
if err != nil {
t.Fatalf("Failed to apply migrations for %q: %v\n", poolHash, err)
return err
}
t.Logf("Applied %d migrations for hash %q", countMigrations, poolHash)
}
if config.ApplyTestFixtures {
countFixtures, err := ApplyTestFixtures(ctx, t, db)
if err != nil {
t.Fatalf("Failed to apply test fixtures for %q: %v\n", poolHash, err)
return err
}
t.Logf("Applied %d test fixtures for hash %q", countFixtures, poolHash)
}
return nil
})
})
// execute closure in a new IntegreSQL database build from above template
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabaseFromDump", closure)
}
// WithTestDatabaseEmpty returns an isolated test database with no migrations applied or fixtures inserted.
func WithTestDatabaseEmpty(t *testing.T, closure func(db *sql.DB)) {
t.Helper()
ctx := t.Context()
WithTestDatabaseEmptyContext(ctx, t, closure)
}
// WithTestDatabaseEmptyContext returns an isolated test database with no migrations applied or fixtures inserted.
// The provided context will be used during setup (instead of the default background context).
func WithTestDatabaseEmptyContext(ctx context.Context, t *testing.T, closure func(db *sql.DB)) {
t.Helper()
poolID := selfFile
// Get a hold of the &sync.Once{} for this integresql pool in this pkg scope...
initOnce, _ := poolInitMap.LoadOrStore(poolID, &sync.Once{})
doOnce, ok := initOnce.(*sync.Once)
if !ok {
t.Fatalf("Expected doOnce to be of type *sync.Once, but got %T", initOnce)
}
doOnce.Do(func() {
t.Helper()
// compute and store poolID -> poolHash map (computes hash of all files/dirs specified)
poolHash := storePoolHash(t, poolID, []string{selfFile})
// properly build up the template database once (noop)
execClosureNewIntegresTemplate(ctx, t, poolHash, func(_ *sql.DB) error {
t.Helper()
return nil
})
})
// execute closure in a new IntegreSQL database build from above template
execClosureNewIntegresDatabase(ctx, t, getPoolHash(t, poolID), "WithTestDatabaseEmpty", closure)
}
// Adds poolID to poolHashMap pointing to the final integresql hash
// Expects hashPaths to be absolute paths to actual files or directories (its contents will be md5 hashed)
// Optional fragments can be used to further enhance the computed md5
func storePoolHash(t *testing.T, poolID string, hashPaths []string, fragments ...string) string {
t.Helper()
// compute a new integreSQL pool hash
poolHash, err := util.GetTemplateHash(hashPaths...)
if err != nil {
t.Fatalf("Failed to create template hash for %v: %#v", poolID, err)
}
// update the hash with optional provided fragments
if len(fragments) > 0 {
poolHash = fmt.Sprintf("%x", md5.Sum([]byte(poolHash+strings.Join(fragments, ",")))) //nolint:gosec
}
// and point poolID to it (sideffect synchronized store!)
poolHashMap.Store(poolID, poolHash) // save it for all runners
return poolHash
}
// Gets precomputed integresql hash via poolID identifier from our synchronized map (see storePoolHash)
func getPoolHash(t *testing.T, poolID string) string {
t.Helper()
poolHash, ok := poolHashMap.Load(poolID)
if !ok {
t.Fatalf("Failed to get poolHash from poolID '%v'. Is poolHashMap initialized yet?", poolID)
return ""
}
hash, ok := poolHash.(string)
if !ok {
t.Fatalf("Expected poolHash to be of type string, but got %T", poolHash)
return ""
}
return hash
}
// Executes closure on an integresql **template** database
func execClosureNewIntegresTemplate(ctx context.Context, t *testing.T, poolHash string, closure func(db *sql.DB) error) {
t.Helper()
if err := client.SetupTemplateWithDBClient(ctx, poolHash, closure); err != nil {
// This error is exceptionally fatal as it hinders ANY future other
// test execution with this hash as the template was *never* properly
// setuped successfully. All GetTestDatabase will wait unti timeout
// unless we interrupt them by discarding the base template...
discardError := client.DiscardTemplate(ctx, poolHash)
if discardError != nil {
t.Fatalf("Failed to setup template database, also discarding failed for poolHash %q: %v, %v", poolHash, err, discardError)
}
t.Fatalf("Failed to setup template database (discarded) for poolHash %q: %v", poolHash, err)
}
}
// Executes closure on an integresql **test** database (scaffolded from a template)
func execClosureNewIntegresDatabase(ctx context.Context, t *testing.T, poolHash string, callee string, closure func(db *sql.DB)) {
t.Helper()
testDatabase, err := client.GetTestDatabase(ctx, poolHash)
if err != nil {
t.Fatalf("Failed to obtain test database: %v", err)
}
connectionString := testDatabase.Config.ConnectionString()
t.Logf("%v: %q", callee, testDatabase.Config.Database)
db, err := sql.Open("postgres", connectionString)
if err != nil {
t.Fatalf("Failed to setup test database for connectionString %q: %v", connectionString, err)
}
if err := db.PingContext(ctx); err != nil {
t.Fatalf("Failed to ping test database for connectionString %q: %v", connectionString, err)
}
closure(db)
// this database object is managed and should close automatically after running the test
if err := db.Close(); err != nil {
t.Fatalf("Failed to close db %q: %v", connectionString, err)
}
// disallow any further refs to managed object after running the test
//nolint: wastedassign
db = nil
}
// ApplyMigrations applies all current database migrations to db
func ApplyMigrations(t *testing.T, db *sql.DB) (int, error) {
t.Helper()
// In case an old default sql-migrate migration table (named "gorp_migrations") still exists we rename it to the new name equivalent
// in sync with the settings in dbconfig.yml and config.DatabaseMigrationTable.
if _, err := db.ExecContext(t.Context(), fmt.Sprintf("ALTER TABLE IF EXISTS gorp_migrations RENAME TO %s;", config.DatabaseMigrationTable)); err != nil {
return 0, fmt.Errorf("failed to rename migrations table: %w", err)
}
migrations := &migrate.FileMigrationSource{Dir: migDir}
countMigrations, err := migrate.Exec(db, "postgres", migrations, migrate.Up)
if err != nil {
return 0, fmt.Errorf("failed to apply migrations: %w", err)
}
return countMigrations, nil
}
// ApplyTestFixtures applies all current test fixtures (insert) to db
func ApplyTestFixtures(ctx context.Context, t *testing.T, db *sql.DB) (int, error) {
t.Helper()
inserts := fixtures.Inserts()
// insert test fixtures in an auto-managed db transaction
err := dbutil.WithTransaction(ctx, db, func(tx boil.ContextExecutor) error {
t.Helper()
for _, fixture := range inserts {
if err := fixture.Insert(ctx, tx, boil.Infer()); err != nil {
return fmt.Errorf("failed to insert fixture: %w", err)
}
}
return nil
})
if err != nil {
return 0, fmt.Errorf("failed to apply test fixtures: %w", err)
}
return len(inserts), nil
}
// ApplyDump applies dumpFile (absolute path to .sql file) to db
func ApplyDump(ctx context.Context, t *testing.T, db *sql.DB, dumpFile string) error {
t.Helper()
// ensure file exists
if _, err := os.Stat(dumpFile); err != nil {
return fmt.Errorf("failed to stat dump file: %w", err)
}
// we need to get the db name before being able to do anything.
var targetDB string
if err := db.QueryRowContext(ctx, "SELECT current_database();").Scan(&targetDB); err != nil {
return fmt.Errorf("failed to get current database: %w", err)
}
cmd := exec.CommandContext(ctx, "bash", "-c", fmt.Sprintf("cat %q | psql %q", dumpFile, targetDB)) //nolint:gosec
combinedOutput, err := cmd.CombinedOutput()
if err != nil {
return errors.Wrap(err, string(combinedOutput))
}
return nil
}

View File

@@ -0,0 +1,229 @@
package test_test
import (
"database/sql"
"fmt"
"path/filepath"
"strings"
"sync"
"testing"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/test"
pUtil "allaboutapps.dev/aw/go-starter/internal/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithTestDatabaseConcurrentUsage(t *testing.T) {
wg := sync.WaitGroup{}
wg.Add(4)
go func() {
test.WithTestDatabase(t, func(db1 *sql.DB) {
assert.NotNil(t, db1)
wg.Done()
})
}()
go func() {
test.WithTestDatabaseEmpty(t, func(db2 *sql.DB) {
assert.NotNil(t, db2)
wg.Done()
})
}()
go func() {
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/plain.sql")}, func(db3 *sql.DB) {
assert.NotNil(t, db3)
wg.Done()
})
}()
go func() {
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql")}, func(db4 *sql.DB) {
assert.NotNil(t, db4)
wg.Done()
})
}()
// the above will concurrently write to the database pool maps,
wg.Wait()
}
func TestWithTestDatabase(t *testing.T) {
test.WithTestDatabase(t, func(db1 *sql.DB) {
test.WithTestDatabase(t, func(db2 *sql.DB) {
var db1Name string
err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name)
if err != nil {
t.Fatal(err)
}
var db2Name string
err = db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name)
if err != nil {
t.Fatal(err)
}
require.NotEqual(t, db1Name, db2Name)
})
})
}
func TestWithTestDatabaseFromDump(t *testing.T) {
dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql")
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db1 *sql.DB) {
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db2 *sql.DB) {
var db1Name string
if err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil {
t.Fatal(err)
}
var db2Name string
if err := db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil {
t.Fatal(err)
}
require.NotEqual(t, db1Name, db2Name)
if _, err := db2.ExecContext(t.Context(), "DELETE FROM users WHERE true;"); err != nil {
t.Fatal(err)
}
var userCount1 int
if err := db1.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&userCount1); err != nil {
t.Fatal(err)
}
require.Equal(t, 3, userCount1)
var userCount2 int
if err := db2.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&userCount2); err != nil {
t.Fatal(err)
}
require.Equal(t, 0, userCount2)
})
})
}
func TestWithTestDatabaseFromDumpAutoMigrateAndTestFixtures(t *testing.T) {
dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/plain.sql")
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile}, func(db0 *sql.DB) {
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true}, func(db1 *sql.DB) {
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true, ApplyTestFixtures: true}, func(db2 *sql.DB) {
// db0: has only a plain dump
// db1: has migrations
// db2: has migrations and testFixtures
var db0Name string
if err := db0.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db0Name); err != nil {
t.Fatal(err)
}
var db1Name string
if err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil {
t.Fatal(err)
}
var db2Name string
if err := db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil {
t.Fatal(err)
}
require.NotEqual(t, db0Name, db1Name)
require.NotEqual(t, db1Name, db2Name)
require.NotEqual(t, db2Name, db0Name)
// expect hash to be different for all 3 databases!
db0Hash := strings.Split(strings.Join(strings.Split(db0Name, "integresql_test_"), ""), "_")[0]
db1Hash := strings.Split(strings.Join(strings.Split(db1Name, "integresql_test_"), ""), "_")[0]
db2Hash := strings.Split(strings.Join(strings.Split(db2Name, "integresql_test_"), ""), "_")[0]
require.NotEqual(t, db0Hash, db1Hash)
require.NotEqual(t, db1Hash, db2Hash)
require.NotEqual(t, db2Hash, db0Hash)
})
})
})
}
func TestWithTestDatabaseFromDumpGorp(t *testing.T) {
dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/uuid_extension_only.sql")
// migrate transforms gorp_migratons to migrations
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true}, func(db *sql.DB) {
// check that we properly renamed the "gorp_migrations" migration tracking table to config.DatabaseMigrationTable
var migrationsTableName string
if err := db.QueryRowContext(t.Context(), fmt.Sprintf("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '%s';", config.DatabaseMigrationTable)).Scan(&migrationsTableName); err != nil {
t.Fatal(err)
}
require.Equal(t, config.DatabaseMigrationTable, migrationsTableName)
// check that gorp_migrations does not exist!
var gorp string
err := db.QueryRowContext(t.Context(), "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'gorp_migrations';").Scan(&gorp)
require.Error(t, err)
// we can expect that the '20200428064736-install-extension-uuid.sql' migration within the uuid_extension_only.sql is a very stable migrations.
// let's check if other migrations were applied...
var migrationsCount int
err = db.QueryRowContext(t.Context(), "SELECT COUNT(table_name) FROM information_schema.tables WHERE table_schema = 'public';").Scan(&migrationsCount)
require.NoError(t, err)
require.Greater(t, migrationsCount, 1)
})
// with no migrate, gorp_migrations still exists:
test.WithTestDatabaseFromDump(t, test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: false}, func(db *sql.DB) {
// check that "gorp_migrations" migration tracking table still exists (as we have not set ApplyMigrations to true!)
var migrationsTableName string
if err := db.QueryRowContext(t.Context(), "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'gorp_migrations';").Scan(&migrationsTableName); err != nil {
t.Fatal(err)
}
require.Equal(t, "gorp_migrations", migrationsTableName)
})
}
func TestWithTestDatabaseEmpty(t *testing.T) {
test.WithTestDatabaseEmpty(t, func(db1 *sql.DB) {
test.WithTestDatabaseEmpty(t, func(db2 *sql.DB) {
var db1Name string
err := db1.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name)
if err != nil {
t.Fatal(err)
}
var db2Name string
err = db2.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name)
if err != nil {
t.Fatal(err)
}
require.NotEqual(t, db1Name, db2Name)
// test apply migrations + fixtures to a empty database 1
_, err = test.ApplyMigrations(t, db1)
require.NoError(t, err)
_, err = test.ApplyTestFixtures(t.Context(), t, db1)
require.NoError(t, err)
// test apply dump to a empty database 2
dumpFile := filepath.Join(pUtil.GetProjectRootDir(), "/test/testdata/users.sql")
err = test.ApplyDump(t.Context(), t, db2, dumpFile)
require.NoError(t, err)
// check user count
var usrCount int
if err := db1.QueryRowContext(t.Context(), "SELECT count(id) FROM users;").Scan(&usrCount); err != nil {
t.Fatal(err)
}
require.Equal(t, 4, usrCount)
})
})
}

View File

@@ -0,0 +1,66 @@
package test
import (
"testing"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/mailer"
"allaboutapps.dev/aw/go-starter/internal/mailer/transport"
"github.com/jordan-wright/email"
)
const (
TestMailerDefaultSender = "test@example.com"
)
func NewTestMailer(t *testing.T) *mailer.Mailer {
t.Helper()
return newMailerWithTransporter(t, transport.NewMock())
}
func NewSMTPMailerFromDefaultEnv(t *testing.T) *mailer.Mailer {
t.Helper()
config := config.DefaultServiceConfigFromEnv().SMTP
return newMailerWithTransporter(t, transport.NewSMTP(config))
}
func GetTestMailerMockTransport(t *testing.T, m *mailer.Mailer) *transport.MockMailTransport {
t.Helper()
mt, ok := m.Transport.(*transport.MockMailTransport)
if !ok {
t.Fatalf("invalid mailer transport type, got %T, want *transport.MockMailTransport", m.Transport)
}
return mt
}
func newMailerWithTransporter(t *testing.T, transporter transport.MailTransporter) *mailer.Mailer {
t.Helper()
config := config.DefaultServiceConfigFromEnv().Mailer
config.DefaultSender = TestMailerDefaultSender
mailer := mailer.New(config, transporter)
if err := mailer.ParseTemplates(); err != nil {
t.Fatal("Failed to parse mailer templates", err)
}
return mailer
}
func GetLastSentMail(t *testing.T, m *mailer.Mailer) *email.Email {
t.Helper()
mt := GetTestMailerMockTransport(t, m)
return mt.GetLastSentMail()
}
func GetSentMails(t *testing.T, m *mailer.Mailer) []*email.Email {
t.Helper()
mt := GetTestMailerMockTransport(t, m)
return mt.GetSentMails()
}

View File

@@ -0,0 +1,67 @@
package test_test
import (
"testing"
"allaboutapps.dev/aw/go-starter/internal/mailer/transport"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithTestMailer(t *testing.T) {
ctx := t.Context()
fix := fixtures.Fixtures()
//nolint:gosec
passwordResetLink := "http://localhost/password/reset/12345"
mailer1 := test.NewTestMailer(t)
mailer2 := test.NewTestMailer(t)
err := mailer1.SendPasswordReset(ctx, fix.User1.Username.String, passwordResetLink)
require.NoError(t, err)
sender2 := "test2@example.com"
mailer2.Config.DefaultSender = sender2
err = mailer2.SendPasswordReset(ctx, fix.User1.Username.String, passwordResetLink)
require.NoError(t, err)
mt1 := test.GetTestMailerMockTransport(t, mailer1)
mail := mt1.GetLastSentMail()
mails := mt1.GetSentMails()
assert.Equal(t, mail, test.GetLastSentMail(t, mailer1))
assert.Equal(t, mails, test.GetSentMails(t, mailer1))
require.NotNil(t, mail)
require.Len(t, mails, 1)
assert.Equal(t, mailer1.Config.DefaultSender, mail.From)
assert.Len(t, mail.To, 1)
assert.Equal(t, fix.User1.Username.String, mail.To[0])
assert.Equal(t, test.TestMailerDefaultSender, mail.From)
assert.Equal(t, "Password reset", mail.Subject)
assert.Contains(t, string(mail.HTML), passwordResetLink)
mt2 := test.GetTestMailerMockTransport(t, mailer2)
mail = mt2.GetLastSentMail()
mails = mt2.GetSentMails()
assert.Equal(t, mail, test.GetLastSentMail(t, mailer2))
assert.Equal(t, mails, test.GetSentMails(t, mailer2))
require.NotNil(t, mail)
require.Len(t, mails, 1)
assert.Equal(t, mailer2.Config.DefaultSender, mail.From)
assert.Len(t, mail.To, 1)
assert.Equal(t, fix.User1.Username.String, mail.To[0])
assert.Equal(t, sender2, mail.From)
assert.Equal(t, "Password reset", mail.Subject)
assert.Contains(t, string(mail.HTML), passwordResetLink)
}
func TestWithSMTPMailerFromDefaultEnv(t *testing.T) {
m := test.NewSMTPMailerFromDefaultEnv(t)
require.NotNil(t, m)
require.NotEmpty(t, m.Transport)
assert.IsType(t, &transport.SMTPMailTransport{}, m.Transport)
}

View File

@@ -0,0 +1,28 @@
package test
import (
"database/sql"
"testing"
"allaboutapps.dev/aw/go-starter/internal/push"
"allaboutapps.dev/aw/go-starter/internal/push/provider"
)
func WithTestPusher(t *testing.T, closure func(p *push.Service, db *sql.DB)) {
t.Helper()
WithTestDatabase(t, func(db *sql.DB) {
t.Helper()
closure(NewTestPusher(t, db), db)
})
}
func NewTestPusher(t *testing.T, db *sql.DB) *push.Service {
t.Helper()
pushService := push.New(db)
mockProvider := provider.NewMock(push.ProviderTypeFCM)
pushService.RegisterProvider(mockProvider)
return pushService
}

View File

@@ -0,0 +1,93 @@
package test
import (
"context"
"database/sql"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/api/router"
"allaboutapps.dev/aw/go-starter/internal/config"
)
// WithTestServer returns a fully configured server (using the default server config).
func WithTestServer(t *testing.T, closure func(s *api.Server)) {
t.Helper()
defaultConfig := config.DefaultServiceConfigFromEnv()
WithTestServerConfigurable(t, defaultConfig, closure)
}
// WithTestServerFromDump returns a fully configured server (using the default server config) and allows for a database dump to be injected.
func WithTestServerFromDump(t *testing.T, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) {
t.Helper()
defaultConfig := config.DefaultServiceConfigFromEnv()
WithTestServerConfigurableFromDump(t, defaultConfig, dumpConfig, closure)
}
// WithTestServerConfigurable returns a fully configured server, allowing for configuration using the provided server config.
func WithTestServerConfigurable(t *testing.T, config config.Server, closure func(s *api.Server)) {
t.Helper()
ctx := t.Context()
WithTestServerConfigurableContext(ctx, t, config, closure)
}
// WithTestServerConfigurableContext returns a fully configured server, allowing for configuration using the provided server config.
// The provided context will be used during setup (instead of the default background context).
func WithTestServerConfigurableContext(ctx context.Context, t *testing.T, config config.Server, closure func(s *api.Server)) {
t.Helper()
WithTestDatabaseContext(ctx, t, func(db *sql.DB) {
t.Helper()
execClosureNewTestServer(ctx, t, config, db, closure)
})
}
// WithTestServerConfigurableFromDump returns a fully configured server, allowing for configuration using the provided server config and a database dump to be injected.
func WithTestServerConfigurableFromDump(t *testing.T, config config.Server, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) {
t.Helper()
ctx := t.Context()
WithTestServerConfigurableFromDumpContext(ctx, t, config, dumpConfig, closure)
}
// WithTestServerConfigurableFromDumpContext returns a fully configured server, allowing for configuration using the provided server config and a database dump to be injected.
// The provided context will be used during setup (instead of the default background context).
func WithTestServerConfigurableFromDumpContext(ctx context.Context, t *testing.T, config config.Server, dumpConfig DatabaseDumpConfig, closure func(s *api.Server)) {
t.Helper()
WithTestDatabaseFromDump(t, dumpConfig, func(db *sql.DB) {
t.Helper()
execClosureNewTestServer(ctx, t, config, db, closure)
})
}
// Executes closure on a new test server with a pre-provided database
func execClosureNewTestServer(ctx context.Context, t *testing.T, config config.Server, db *sql.DB, closure func(s *api.Server)) {
t.Helper()
// https://stackoverflow.com/questions/43424787/how-to-use-next-available-port-in-http-listenandserve
// You may use port 0 to indicate you're not specifying an exact port but you want a free, available port selected by the system
config.Echo.ListenAddress = ":0"
// always use the mock pusher in tests
config.Push.UseFCMProvider = false
config.Push.UseMockProvider = true
s, err := api.InitNewServerWithDB(config, db, t)
if err != nil {
t.Fatalf("Failed to initialize server: %v", err)
}
err = router.Init(s)
if err != nil {
t.Fatalf("Failed to init router: %v", err)
}
closure(s)
// echo is managed and should close automatically after running the test
if err := s.Echo.Shutdown(ctx); err != nil {
t.Fatalf("failed to shutdown server: %v", err)
}
// disallow any further refs to managed object after running the test
//nolint: wastedassign
s = nil
}

View File

@@ -0,0 +1,151 @@
package test_test
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"testing"
"allaboutapps.dev/aw/go-starter/internal/api"
"allaboutapps.dev/aw/go-starter/internal/config"
"allaboutapps.dev/aw/go-starter/internal/test"
"allaboutapps.dev/aw/go-starter/internal/util"
"github.com/go-openapi/strfmt"
"github.com/go-openapi/swag"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type TestRequestPayload struct {
Name string `json:"name"`
}
type TestResponsePayload struct {
Hello string `json:"hello"`
}
// Validate validates this request payload
func (m *TestRequestPayload) Validate(_ strfmt.Registry) error {
return nil
}
// MarshalBinary interface implementation
func (m *TestRequestPayload) MarshalBinary() ([]byte, error) {
if m == nil {
return nil, nil
}
res, err := swag.WriteJSON(m)
if err != nil {
return nil, fmt.Errorf("failed to marshal binary: %w", err)
}
return res, nil
}
// UnmarshalBinary interface implementation
func (m *TestRequestPayload) UnmarshalBinary(b []byte) error {
var res TestRequestPayload
if err := swag.ReadJSON(b, &res); err != nil {
return fmt.Errorf("failed to unmarshal binary: %w", err)
}
*m = res
return nil
}
// Validate validates this response payload
func (m *TestResponsePayload) Validate(_ strfmt.Registry) error {
return nil
}
// MarshalBinary interface implementation
func (m *TestResponsePayload) MarshalBinary() ([]byte, error) {
if m == nil {
return nil, nil
}
res, err := swag.WriteJSON(m)
if err != nil {
return nil, fmt.Errorf("failed to marshal binary: %w", err)
}
return res, nil
}
// UnmarshalBinary interface implementation
func (m *TestResponsePayload) UnmarshalBinary(b []byte) error {
var res TestResponsePayload
if err := swag.ReadJSON(b, &res); err != nil {
return fmt.Errorf("failed to unmarshal binary: %w", err)
}
*m = res
return nil
}
func TestWithTestServer(t *testing.T) {
test.WithTestServer(t, func(server1 *api.Server) {
test.WithTestServer(t, func(server2 *api.Server) {
path := "/testing-f679dbac-62bb-445d-b7e8-9f2c71ca382c"
// add an new route to s1 for the purpose of this test.
server1.Echo.POST(path, func(c echo.Context) error {
var body TestRequestPayload
if err := util.BindAndValidateBody(c, &body); err != nil {
t.Fatal(err)
}
response := TestResponsePayload{
Hello: body.Name,
}
return util.ValidateAndReturn(c, http.StatusOK, &response)
})
payload := test.GenericPayload{
"name": "Mario",
}
res1 := test.PerformRequest(t, server1, "POST", path, payload, nil)
assert.Equal(t, http.StatusOK, res1.Result().StatusCode)
var response1 TestResponsePayload
test.ParseResponseAndValidate(t, res1, &response1)
assert.Equal(t, "Mario", response1.Hello)
res2 := test.PerformRequest(t, server2, "POST", path, payload, nil)
assert.Equal(t, http.StatusNotFound, res2.Result().StatusCode)
})
})
}
func TestWithTestServerFromDump(t *testing.T) {
dumpFile := filepath.Join(util.GetProjectRootDir(), "/test/testdata/plain.sql")
serverConfig := config.DefaultServiceConfigFromEnv()
dumpConfig := test.DatabaseDumpConfig{DumpFile: dumpFile, ApplyMigrations: true, ApplyTestFixtures: true}
test.WithTestServerFromDump(t, dumpConfig, func(server1 *api.Server) {
test.WithTestServerConfigurableFromDump(t, serverConfig, dumpConfig, func(server2 *api.Server) {
var db1Name string
if err := server1.DB.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db1Name); err != nil {
t.Fatal(err)
}
var db2Name string
if err := server2.DB.QueryRowContext(t.Context(), "SELECT current_database();").Scan(&db2Name); err != nil {
t.Fatal(err)
}
require.NotEqual(t, db1Name, db2Name)
// same dumpConfig settings - must be same base template hash.
db1Hash := strings.Split(strings.Join(strings.Split(db1Name, "integresql_test_"), ""), "_")[0]
db2Hash := strings.Split(strings.Join(strings.Split(db2Name, "integresql_test_"), ""), "_")[0]
require.Equal(t, db1Hash, db2Hash)
})
})
}

View File

@@ -0,0 +1,2 @@
# for a simple overwrite test via TestDotEnvLoadFileOrSkipTest
SERVER_FRONTEND_BASE_URL=http://overwritten.dotenv.frontend.url.tld:3000

View File

@@ -0,0 +1,6 @@
(struct { A string; B int; C bool; D *string }) {
A: (string) (len=3) "foo",
B: (int) 1,
C: (bool) true,
D: (*string)((len=3) "bar")
}

View File

@@ -0,0 +1,31 @@
package test
import "testing"
// TestingT is used to generate a mock of testing.T to enable testing
// of helper methods which are using assert/require
// Inspired by: https://github.com/uber-go/zap/blob/master/zaptest/testingt_test.go, commit 5b0fd114dcc089875ee61dfad3617c3a43c2e93e
//
//nolint:interfacebloat
type TestingT interface {
Cleanup(f func())
Error(args ...interface{})
Errorf(format string, args ...interface{})
Fail()
FailNow()
Failed() bool
Fatal(args ...interface{})
Fatalf(format string, args ...interface{})
Helper()
Log(args ...interface{})
Logf(format string, args ...interface{})
Name() string
Skip(args ...interface{})
SkipNow()
Skipf(format string, args ...interface{})
Skipped() bool
TempDir() string
}
// used to ensure compatibility between this interface and testing.TB
var _ TestingT = (testing.TB)(nil)