mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add tests to email, buildinfo, storage packages
This commit is contained in:
parent
b148616a5c
commit
e25b41ef8b
|
|
@ -28,23 +28,25 @@ var (
|
|||
func init() { //nolint:gochecknoinits // we need to prepare build info
|
||||
logutil.ShowCritical = IsDevEnv()
|
||||
|
||||
// only strings can be inserted via ldflags which means
|
||||
// some information needs to be parsed. Malformed injected values
|
||||
// fall back to zero defaults instead of failing the boot.
|
||||
_info.Version, _info.Timestamp = parseBuildValues(_version, _timestampString)
|
||||
}
|
||||
|
||||
version, err := semver.ParseTolerant(_version)
|
||||
// parseBuildValues parses the injected version and timestamp values.
|
||||
// Only strings can be inserted via ldflags which means some
|
||||
// information needs to be parsed. Malformed injected values fall back
|
||||
// to zero defaults instead of failing the boot.
|
||||
func parseBuildValues(rawVersion, rawTimestamp string) (semver.Version, time.Time) {
|
||||
version, err := semver.ParseTolerant(rawVersion)
|
||||
if err != nil {
|
||||
version = semver.Version{}
|
||||
}
|
||||
|
||||
_info.Version = version
|
||||
|
||||
timestamp, err := timeutil.Parse(_timestampString)
|
||||
timestamp, err := timeutil.Parse(rawTimestamp)
|
||||
if err != nil {
|
||||
timestamp = time.Time{}
|
||||
}
|
||||
|
||||
_info.Timestamp = timestamp
|
||||
return version, timestamp
|
||||
}
|
||||
|
||||
// BuildInfo contains information about the executable.
|
||||
|
|
|
|||
143
server/core/internal/buildinfo/buildinfo_test.go
Normal file
143
server/core/internal/buildinfo/buildinfo_test.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package buildinfo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/blang/semver/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func Test_parseBuildValues(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
RawVersion string
|
||||
RawTimestamp string
|
||||
Version semver.Version
|
||||
Timestamp time.Time
|
||||
}{
|
||||
"Malformed version": {
|
||||
RawVersion: "not-a-version",
|
||||
RawTimestamp: "2006-12-09T19:00:00Z",
|
||||
Version: semver.Version{},
|
||||
Timestamp: time.Date(2006, 12, 9, 19, 0, 0, 0, time.UTC),
|
||||
},
|
||||
"Malformed timestamp": {
|
||||
RawVersion: "1.2.3",
|
||||
RawTimestamp: "not-a-timestamp",
|
||||
Version: semver.MustParse("1.2.3"),
|
||||
Timestamp: time.Time{},
|
||||
},
|
||||
"Successful parsing": {
|
||||
RawVersion: "v1.2.3-rc.1+name",
|
||||
RawTimestamp: "2021-01-02T03:04:05Z",
|
||||
Version: semver.MustParse("1.2.3-rc.1+name"),
|
||||
Timestamp: time.Date(2021, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
version, timestamp := parseBuildValues(c.RawVersion, c.RawTimestamp)
|
||||
assert.Equal(t, c.Version, version)
|
||||
assert.Equal(t, c.Timestamp, timestamp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_BuildInfo_String(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := BuildInfo{
|
||||
Name: "oxynote_core",
|
||||
Version: semver.MustParse("1.2.3"),
|
||||
Commit: "abc1234",
|
||||
Timestamp: time.Date(2021, 1, 2, 3, 4, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"oxynote_core 1.2.3 (abc1234; 2021-01-02 03:04 UTC)",
|
||||
b.String(),
|
||||
)
|
||||
}
|
||||
|
||||
func Test_BuildInfo_VersionName(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Version semver.Version
|
||||
Result string
|
||||
}{
|
||||
"No build metadata": {
|
||||
Version: semver.MustParse("1.2.3"),
|
||||
Result: "unknown",
|
||||
},
|
||||
"Build metadata present": {
|
||||
Version: semver.MustParse("1.2.3+name.extra"),
|
||||
Result: "name",
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := BuildInfo{Version: c.Version}
|
||||
assert.Equal(t, c.Result, b.VersionName())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_BuildInfo_PlainVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := BuildInfo{Version: semver.MustParse("1.2.3-rc.1+name")}
|
||||
assert.Equal(t, "1.2.3-rc.1", b.PlainVersion())
|
||||
}
|
||||
|
||||
func Test_BuildInfo_FormattedTimestamp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
b := BuildInfo{Timestamp: time.Date(2021, 1, 2, 3, 4, 0, 0, time.UTC)}
|
||||
assert.Equal(t, "2021-01-02 03:04 UTC", b.FormattedTimestamp())
|
||||
}
|
||||
|
||||
func Test_EnvName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(t, "OXYNOTE_CORE_FOO", EnvName("FOO"))
|
||||
}
|
||||
|
||||
func Test_Getenv(t *testing.T) {
|
||||
t.Setenv("OXYNOTE_CORE_TEST_KEY", "test-value")
|
||||
|
||||
assert.Equal(t, "test-value", Getenv("TEST_KEY"))
|
||||
}
|
||||
|
||||
func Test_Full(t *testing.T) {
|
||||
// no t.Parallel: Test_IsDevEnv mutates the shared _info variable.
|
||||
full := Full()
|
||||
|
||||
require.NotZero(t, full)
|
||||
assert.Equal(t, _info, full)
|
||||
}
|
||||
|
||||
func Test_IsDevEnv(t *testing.T) {
|
||||
// the environment name lives in the package-level _info variable,
|
||||
// so the false branch temporarily mutates it. No t.Parallel to
|
||||
// keep other tests from observing the mutation.
|
||||
assert.True(t, IsDevEnv())
|
||||
|
||||
env := _info.Env
|
||||
defer func() { _info.Env = env }()
|
||||
|
||||
_info.Env = "prod"
|
||||
|
||||
assert.False(t, IsDevEnv())
|
||||
}
|
||||
|
|
@ -20,12 +20,14 @@ func (a *agent) InsertDocumentSearchJob(ctx context.Context, blockDiff search.Bl
|
|||
return err
|
||||
}
|
||||
|
||||
// FetchDocumentSearchJobs retrieves a batch of document search jobs.
|
||||
func (a *agent) FetchDocumentSearchJobs(ctx context.Context, limit int64) ([]search.DocumentSearchJob, error) {
|
||||
// FetchDocumentSearchJobs retrieves a batch of document search jobs with IDs
|
||||
// greater than the given offset ID.
|
||||
func (a *agent) FetchDocumentSearchJobs(ctx context.Context, offsetID, limit int64) ([]search.DocumentSearchJob, error) {
|
||||
q, args := a.builder.Select(
|
||||
"id",
|
||||
"block_diff",
|
||||
).From("document_search_jobs").
|
||||
Where(sq.Gt{"id": offsetID}).
|
||||
OrderBy("id ASC").
|
||||
Limit(uint64(limit)).
|
||||
MustSql()
|
||||
|
|
|
|||
|
|
@ -137,21 +137,26 @@ func Test_agent_FetchDocumentSearchJobs(t *testing.T) {
|
|||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
res, err := db.FetchDocumentSearchJobs(ctx, 10)
|
||||
res, err := db.FetchDocumentSearchJobs(ctx, 0, 10)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, res)
|
||||
|
||||
// success - no jobs
|
||||
res, err = db.FetchDocumentSearchJobs(context.Background(), 10)
|
||||
res, err = db.FetchDocumentSearchJobs(context.Background(), 0, 10)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, res)
|
||||
|
||||
// success - limited batch in id order
|
||||
jobs := prepDocumentSearchJobs(t, db, 3, nil)
|
||||
|
||||
res, err = db.FetchDocumentSearchJobs(context.Background(), 2)
|
||||
res, err = db.FetchDocumentSearchJobs(context.Background(), 0, 2)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, jobs[:2], res)
|
||||
|
||||
// success - offset skips earlier jobs
|
||||
res, err = db.FetchDocumentSearchJobs(context.Background(), jobs[0].ID, 10)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, jobs[1:], res)
|
||||
}
|
||||
|
||||
func Test_agent_DeleteDocumentSearchJob(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -86,28 +86,20 @@ func (m *Manager) Start(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// documentBranchKey is the cache key for the documents map in ProcessingState.
|
||||
type documentBranchKey struct {
|
||||
// ID is the document ID.
|
||||
ID xid.ID
|
||||
|
||||
// BranchID is the document branch ID.
|
||||
BranchID null.Value[xid.ID]
|
||||
}
|
||||
|
||||
// ProcessingState holds the state during hook processing.
|
||||
type ProcessingState struct {
|
||||
// OffsetID is the last processed hook ID.
|
||||
OffsetID xid.ID
|
||||
|
||||
// Documents caches documents by (ID, branchID) to avoid redundant fetches.
|
||||
Documents map[documentBranchKey]*document.Document
|
||||
// Documents caches documents by branch ID to avoid redundant fetches;
|
||||
// a branch belongs to exactly one document.
|
||||
Documents map[xid.ID]*document.Document
|
||||
}
|
||||
|
||||
// processHooks processes document hooks in a paginated manner.
|
||||
func (m *Manager) processHooks(ctx context.Context) error { //nolint:gocognit // this method is complex, however, it's well-structured
|
||||
ps := &ProcessingState{
|
||||
Documents: make(map[documentBranchKey]*document.Document),
|
||||
Documents: make(map[xid.ID]*document.Document),
|
||||
}
|
||||
|
||||
for {
|
||||
|
|
@ -206,10 +198,7 @@ func (m *Manager) ensureHook(
|
|||
state *ProcessingState,
|
||||
h *hook.Hook,
|
||||
) bool {
|
||||
key := documentBranchKey{
|
||||
ID: h.DocumentID,
|
||||
BranchID: h.BranchID,
|
||||
}
|
||||
key := h.BranchID.V
|
||||
|
||||
doc, ok := state.Documents[key]
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -244,12 +244,10 @@ func Test_Manager_processHooks(t *testing.T) {
|
|||
},
|
||||
"Documents are fetched once per branch": {
|
||||
Hooks: func(t *testing.T) []hook.Hook {
|
||||
// hooks on the same branch share a document.
|
||||
first := stubHook(t, branchID, time.Now().Add(time.Hour), time.Now())
|
||||
second := stubHook(t, branchID, time.Now().Add(time.Hour), time.Now())
|
||||
second.DocumentID = first.DocumentID
|
||||
|
||||
return []hook.Hook{first, second}
|
||||
return []hook.Hook{
|
||||
stubHook(t, branchID, time.Now().Add(time.Hour), time.Now()),
|
||||
stubHook(t, branchID, time.Now().Add(time.Hour), time.Now()),
|
||||
}
|
||||
},
|
||||
Doc: stubDocument(),
|
||||
Checks: checks(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import (
|
|||
// Summaries is a collection of Summary objects.
|
||||
type Summaries []Summary
|
||||
|
||||
// Swap swaps the elements in the Summaries slice.
|
||||
// Swap returns a copy of the Summaries slice with the summary moved to the
|
||||
// given sort index. The receiver is left untouched.
|
||||
func (ss Summaries) Swap(id xid.ID, sortIndex int) (Summaries, error) {
|
||||
if sortIndex < 0 || sortIndex >= len(ss) {
|
||||
return nil, errutil.New(
|
||||
|
|
@ -31,12 +32,14 @@ func (ss Summaries) Swap(id xid.ID, sortIndex int) (Summaries, error) {
|
|||
return nil, errutil.ErrNotFound
|
||||
}
|
||||
|
||||
sliceutil.Move(ss, index, sortIndex)
|
||||
nss := slices.Clone(ss)
|
||||
sliceutil.Move(nss, index, sortIndex)
|
||||
|
||||
return ss, nil
|
||||
return nss, nil
|
||||
}
|
||||
|
||||
// Remove removes a summary from the Summaries slice by its id.
|
||||
// Remove returns a copy of the Summaries slice without the summary carrying
|
||||
// the given id. The receiver is left untouched.
|
||||
func (ss Summaries) Remove(id xid.ID) (Summaries, error) {
|
||||
index := slices.IndexFunc(ss, func(d Summary) bool {
|
||||
return d.ID == id
|
||||
|
|
@ -45,7 +48,7 @@ func (ss Summaries) Remove(id xid.ID) (Summaries, error) {
|
|||
return nil, errutil.ErrNotFound
|
||||
}
|
||||
|
||||
return append(ss[:index], ss[index+1:]...), nil
|
||||
return slices.Delete(slices.Clone(ss), index, index+1), nil
|
||||
}
|
||||
|
||||
// Summary represents summary of the document.
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ func Test_Summaries_Swap(t *testing.T) {
|
|||
res[1].DocumentName,
|
||||
res[2].DocumentName,
|
||||
})
|
||||
|
||||
// the receiver is left untouched.
|
||||
assert.Equal(t, "a", ss[0].DocumentName)
|
||||
})
|
||||
|
||||
t.Run("Out-of-range index fails", func(t *testing.T) {
|
||||
|
|
@ -74,6 +77,11 @@ func Test_Summaries_Remove(t *testing.T) {
|
|||
require.Len(t, res, 2)
|
||||
assert.Equal(t, "a", res[0].DocumentName)
|
||||
assert.Equal(t, "c", res[1].DocumentName)
|
||||
|
||||
// the receiver is left untouched.
|
||||
require.Len(t, ss, 3)
|
||||
assert.Equal(t, "b", ss[1].DocumentName)
|
||||
assert.Equal(t, "c", ss[2].DocumentName)
|
||||
})
|
||||
|
||||
t.Run("Unknown ID fails with not found", func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ var _ DB = &DBMock{}
|
|||
// DeleteDocumentSearchJobFunc: func(ctx context.Context, id int64) error {
|
||||
// panic("mock out the DeleteDocumentSearchJob method")
|
||||
// },
|
||||
// FetchDocumentSearchJobsFunc: func(ctx context.Context, limit int64) ([]search.DocumentSearchJob, error) {
|
||||
// FetchDocumentSearchJobsFunc: func(ctx context.Context, offsetID int64, limit int64) ([]search.DocumentSearchJob, error) {
|
||||
// panic("mock out the FetchDocumentSearchJobs method")
|
||||
// },
|
||||
// }
|
||||
|
|
@ -37,7 +37,7 @@ type DBMock struct {
|
|||
DeleteDocumentSearchJobFunc func(ctx context.Context, id int64) error
|
||||
|
||||
// FetchDocumentSearchJobsFunc mocks the FetchDocumentSearchJobs method.
|
||||
FetchDocumentSearchJobsFunc func(ctx context.Context, limit int64) ([]search.DocumentSearchJob, error)
|
||||
FetchDocumentSearchJobsFunc func(ctx context.Context, offsetID int64, limit int64) ([]search.DocumentSearchJob, error)
|
||||
|
||||
// calls tracks calls to the methods.
|
||||
calls struct {
|
||||
|
|
@ -52,6 +52,8 @@ type DBMock struct {
|
|||
FetchDocumentSearchJobs []struct {
|
||||
// Ctx is the ctx argument value.
|
||||
Ctx context.Context
|
||||
// OffsetID is the offsetID argument value.
|
||||
OffsetID int64
|
||||
// Limit is the limit argument value.
|
||||
Limit int64
|
||||
}
|
||||
|
|
@ -100,13 +102,15 @@ func (mock *DBMock) DeleteDocumentSearchJobCalls() []struct {
|
|||
}
|
||||
|
||||
// FetchDocumentSearchJobs calls FetchDocumentSearchJobsFunc.
|
||||
func (mock *DBMock) FetchDocumentSearchJobs(ctx context.Context, limit int64) ([]search.DocumentSearchJob, error) {
|
||||
func (mock *DBMock) FetchDocumentSearchJobs(ctx context.Context, offsetID int64, limit int64) ([]search.DocumentSearchJob, error) {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
Limit int64
|
||||
Ctx context.Context
|
||||
OffsetID int64
|
||||
Limit int64
|
||||
}{
|
||||
Ctx: ctx,
|
||||
Limit: limit,
|
||||
Ctx: ctx,
|
||||
OffsetID: offsetID,
|
||||
Limit: limit,
|
||||
}
|
||||
mock.lockFetchDocumentSearchJobs.Lock()
|
||||
mock.calls.FetchDocumentSearchJobs = append(mock.calls.FetchDocumentSearchJobs, callInfo)
|
||||
|
|
@ -118,7 +122,7 @@ func (mock *DBMock) FetchDocumentSearchJobs(ctx context.Context, limit int64) ([
|
|||
)
|
||||
return documentSearchJobsOut, errOut
|
||||
}
|
||||
return mock.FetchDocumentSearchJobsFunc(ctx, limit)
|
||||
return mock.FetchDocumentSearchJobsFunc(ctx, offsetID, limit)
|
||||
}
|
||||
|
||||
// FetchDocumentSearchJobsCalls gets all the calls that were made to FetchDocumentSearchJobs.
|
||||
|
|
@ -126,12 +130,14 @@ func (mock *DBMock) FetchDocumentSearchJobs(ctx context.Context, limit int64) ([
|
|||
//
|
||||
// len(mockedDB.FetchDocumentSearchJobsCalls())
|
||||
func (mock *DBMock) FetchDocumentSearchJobsCalls() []struct {
|
||||
Ctx context.Context
|
||||
Limit int64
|
||||
Ctx context.Context
|
||||
OffsetID int64
|
||||
Limit int64
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
Limit int64
|
||||
Ctx context.Context
|
||||
OffsetID int64
|
||||
Limit int64
|
||||
}
|
||||
mock.lockFetchDocumentSearchJobs.RLock()
|
||||
calls = mock.calls.FetchDocumentSearchJobs
|
||||
|
|
|
|||
|
|
@ -58,21 +58,24 @@ func (m *Manager) Start(ctx context.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// processJobs processes document search jobs in a paginated manner.
|
||||
// processJobs processes document search jobs in a paginated manner. Failed
|
||||
// jobs stay in the database and are retried on the next processing interval.
|
||||
func (m *Manager) processJobs(ctx context.Context) error {
|
||||
var offsetID int64
|
||||
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jobs, err := m.db.FetchDocumentSearchJobs(ctx, _processingBatch)
|
||||
jobs, err := m.db.FetchDocumentSearchJobs(ctx, offsetID, _processingBatch)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching paginated document search jobs: %w", err)
|
||||
}
|
||||
|
||||
var deleted int
|
||||
|
||||
for _, job := range jobs {
|
||||
offsetID = job.ID
|
||||
|
||||
err := m.searchGateway.ReplaceDocumentBlocks(ctx, job.BlockDiff)
|
||||
if err != nil {
|
||||
m.log.With("job_id", job.ID).
|
||||
|
|
@ -87,17 +90,10 @@ func (m *Manager) processJobs(ctx context.Context) error {
|
|||
m.log.With("job_id", job.ID).
|
||||
With("error", err).
|
||||
Error("deleting document search job")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
deleted++
|
||||
}
|
||||
|
||||
// the fetch has no offset, so without progress the next pass
|
||||
// would see the same failing jobs again — leave them for the
|
||||
// next processing interval instead of spinning on them.
|
||||
if len(jobs) < _processingBatch || deleted == 0 {
|
||||
if len(jobs) < _processingBatch {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -110,8 +106,9 @@ func (m *Manager) processJobs(ctx context.Context) error {
|
|||
//go:generate ../../../scripts/codegen/mock -t internal DB db
|
||||
type DB interface {
|
||||
// FetchDocumentSearchJobs should retrieve a batch of document search
|
||||
// jobs, limited to the specified number of jobs.
|
||||
FetchDocumentSearchJobs(ctx context.Context, limit int64) ([]search.DocumentSearchJob, error)
|
||||
// jobs with IDs greater than the given offset ID, limited to the
|
||||
// specified number of jobs.
|
||||
FetchDocumentSearchJobs(ctx context.Context, offsetID, limit int64) ([]search.DocumentSearchJob, error)
|
||||
|
||||
// DeleteDocumentSearchJob should delete the given document search job from the database.
|
||||
DeleteDocumentSearchJob(ctx context.Context, id int64) error
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// jobs builds n sequential search jobs.
|
||||
func jobs(n int) []search.DocumentSearchJob {
|
||||
// jobs builds n sequential search jobs with IDs starting at start.
|
||||
func jobs(start, n int) []search.DocumentSearchJob {
|
||||
jj := make([]search.DocumentSearchJob, 0, n)
|
||||
|
||||
for i := range n {
|
||||
jj = append(jj, search.DocumentSearchJob{ID: int64(i + 1)})
|
||||
jj = append(jj, search.DocumentSearchJob{ID: int64(start + i)})
|
||||
}
|
||||
|
||||
return jj
|
||||
|
|
@ -73,23 +73,29 @@ func Test_Manager_processJobs(t *testing.T) {
|
|||
),
|
||||
},
|
||||
"Jobs are processed and deleted": {
|
||||
Batches: [][]search.DocumentSearchJob{jobs(3)},
|
||||
Batches: [][]search.DocumentSearchJob{jobs(1, 3)},
|
||||
Checks: checks(
|
||||
hasError(false),
|
||||
wasReplaceCalled(3),
|
||||
wasDeleteCalled(3),
|
||||
),
|
||||
},
|
||||
"Full batches keep paginating": {
|
||||
Batches: [][]search.DocumentSearchJob{jobs(_processingBatch), jobs(2)},
|
||||
"Full batches keep paginating past the last job ID": {
|
||||
Batches: [][]search.DocumentSearchJob{jobs(1, _processingBatch), jobs(101, 2)},
|
||||
Checks: checks(
|
||||
hasError(false),
|
||||
wasReplaceCalled(_processingBatch+2),
|
||||
wasDeleteCalled(_processingBatch+2),
|
||||
func(t *testing.T, db *DBMock, _ *SearchGatewayMock, _ error) {
|
||||
ff := db.FetchDocumentSearchJobsCalls()
|
||||
require.Len(t, ff, 2)
|
||||
assert.Equal(t, int64(0), ff[0].OffsetID)
|
||||
assert.Equal(t, int64(_processingBatch), ff[1].OffsetID)
|
||||
},
|
||||
),
|
||||
},
|
||||
"Replace failure keeps the job for the next pass": {
|
||||
Batches: [][]search.DocumentSearchJob{jobs(2)},
|
||||
"Replace failure keeps the job for the next interval": {
|
||||
Batches: [][]search.DocumentSearchJob{jobs(1, 2)},
|
||||
ReplaceErr: assert.AnError,
|
||||
Checks: checks(
|
||||
hasError(false),
|
||||
|
|
@ -97,19 +103,26 @@ func Test_Manager_processJobs(t *testing.T) {
|
|||
wasDeleteCalled(0),
|
||||
),
|
||||
},
|
||||
"Persistently failing full batch terminates": {
|
||||
// without the no-progress guard this would refetch the
|
||||
// same failing batch forever.
|
||||
Batches: [][]search.DocumentSearchJob{jobs(_processingBatch), jobs(_processingBatch)},
|
||||
"Persistently failing full batches advance and terminate": {
|
||||
// the offset moves past failing jobs, so a wall of
|
||||
// failures cannot refetch the same batch forever.
|
||||
Batches: [][]search.DocumentSearchJob{
|
||||
jobs(1, _processingBatch),
|
||||
jobs(101, _processingBatch),
|
||||
nil,
|
||||
},
|
||||
ReplaceErr: assert.AnError,
|
||||
Checks: checks(
|
||||
hasError(false),
|
||||
wasReplaceCalled(_processingBatch),
|
||||
wasReplaceCalled(2*_processingBatch),
|
||||
wasDeleteCalled(0),
|
||||
),
|
||||
},
|
||||
"Delete failure does not count as progress": {
|
||||
Batches: [][]search.DocumentSearchJob{jobs(_processingBatch), jobs(_processingBatch)},
|
||||
"Delete failure still advances the offset": {
|
||||
Batches: [][]search.DocumentSearchJob{
|
||||
jobs(1, _processingBatch),
|
||||
nil,
|
||||
},
|
||||
DeleteErr: assert.AnError,
|
||||
Checks: checks(
|
||||
hasError(false),
|
||||
|
|
@ -126,7 +139,7 @@ func Test_Manager_processJobs(t *testing.T) {
|
|||
fetches := 0
|
||||
|
||||
db := &DBMock{
|
||||
FetchDocumentSearchJobsFunc: func(context.Context, int64) ([]search.DocumentSearchJob, error) {
|
||||
FetchDocumentSearchJobsFunc: func(context.Context, int64, int64) ([]search.DocumentSearchJob, error) {
|
||||
if fetches >= len(tc.Batches) {
|
||||
if len(tc.Batches) == 0 {
|
||||
return nil, assert.AnError
|
||||
|
|
|
|||
436
server/core/internal/storage/client_test.go
Normal file
436
server/core/internal/storage/client_test.go
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func Test_NewClient(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Fake *fakeS3
|
||||
URL string
|
||||
Err error
|
||||
}{
|
||||
"Error returned by setupClient": {
|
||||
URL: "://invalid",
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Successful creation": {
|
||||
Fake: &fakeS3{bucketExists: true},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := Options{
|
||||
Bucket: "test-bucket",
|
||||
URL: c.URL,
|
||||
AccessKey: "access-key",
|
||||
SecretKey: "secret-key",
|
||||
}
|
||||
|
||||
if c.Fake != nil {
|
||||
opts.URL = newFakeS3Server(t, c.Fake)
|
||||
}
|
||||
|
||||
client, err := NewClient(context.Background(), opts)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
require.NotNil(t, client)
|
||||
assert.NotNil(t, client.client)
|
||||
assert.Equal(t, "test-bucket", client.bucket)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_setupClient(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Fake *fakeS3
|
||||
URL string
|
||||
Created bool
|
||||
Err error
|
||||
}{
|
||||
"Invalid URL": {
|
||||
URL: "://invalid",
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Error returned by minio.New": {
|
||||
URL: "http://",
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Error returned by BucketExists": {
|
||||
Fake: &fakeS3{bucketExists: true, failBucket: true},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Error returned by MakeBucket": {
|
||||
Fake: &fakeS3{failMakeBucket: true},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Bucket created when missing": {
|
||||
Fake: &fakeS3{},
|
||||
Created: true,
|
||||
},
|
||||
"Existing bucket": {
|
||||
Fake: &fakeS3{bucketExists: true},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
opts := Options{
|
||||
Bucket: "test-bucket",
|
||||
URL: c.URL,
|
||||
AccessKey: "access-key",
|
||||
SecretKey: "secret-key",
|
||||
}
|
||||
|
||||
if c.Fake != nil {
|
||||
opts.URL = newFakeS3Server(t, c.Fake)
|
||||
}
|
||||
|
||||
mc, err := setupClient(context.Background(), opts)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
require.NotNil(t, mc)
|
||||
|
||||
if c.Created {
|
||||
assert.True(t, c.Fake.bucketExists)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// prepClient creates a storage client backed by the provided fake S3
|
||||
// server, marking the fake's bucket as existing so that setup succeeds.
|
||||
func prepClient(t *testing.T, f *fakeS3) *Client {
|
||||
t.Helper()
|
||||
|
||||
f.bucketExists = true
|
||||
|
||||
client, err := NewClient(context.Background(), Options{
|
||||
Bucket: "test-bucket",
|
||||
URL: newFakeS3Server(t, f),
|
||||
AccessKey: "access-key",
|
||||
SecretKey: "secret-key",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// newFakeS3Server starts an HTTP test server backed by the provided
|
||||
// fake S3 implementation and returns its URL.
|
||||
func newFakeS3Server(t *testing.T, f *fakeS3) string {
|
||||
t.Helper()
|
||||
|
||||
srv := httptest.NewServer(f)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// fakeS3 is a minimal in-memory S3 implementation covering only the
|
||||
// requests the minio client performs for the storage package: bucket
|
||||
// location lookup, bucket creation, multipart upload, object stat,
|
||||
// object retrieval and object removal.
|
||||
type fakeS3 struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// bucketExists indicates whether the bucket location lookup
|
||||
// should report the bucket as present.
|
||||
bucketExists bool
|
||||
|
||||
// objects maps completed object keys to their data.
|
||||
objects map[string][]byte
|
||||
|
||||
// contentTypes maps completed object keys to their content type.
|
||||
contentTypes map[string]string
|
||||
|
||||
// parts accumulates uploaded multipart data per object key.
|
||||
parts map[string][]byte
|
||||
|
||||
// failBucket forces the bucket existence probe to fail.
|
||||
failBucket bool
|
||||
|
||||
// failMakeBucket forces bucket creation to fail.
|
||||
failMakeBucket bool
|
||||
|
||||
// failUpload forces multipart upload initiation to fail.
|
||||
failUpload bool
|
||||
|
||||
// failStat forces object stat requests to fail.
|
||||
failStat bool
|
||||
|
||||
// failDelete forces object removal to fail.
|
||||
failDelete bool
|
||||
}
|
||||
|
||||
// ServeHTTP dispatches the S3 REST requests issued by the minio client.
|
||||
func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.objects == nil {
|
||||
f.objects = make(map[string][]byte)
|
||||
f.contentTypes = make(map[string]string)
|
||||
f.parts = make(map[string][]byte)
|
||||
}
|
||||
|
||||
segments := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 2)
|
||||
|
||||
var key string
|
||||
|
||||
if len(segments) > 1 {
|
||||
key = segments[1]
|
||||
}
|
||||
|
||||
query := r.URL.Query()
|
||||
|
||||
switch {
|
||||
case key == "" && r.Method == http.MethodGet && query.Has("location"):
|
||||
f.handleLocation(w)
|
||||
case key == "" && r.Method == http.MethodHead:
|
||||
f.handleHeadBucket(w)
|
||||
case key == "" && r.Method == http.MethodPut:
|
||||
f.handleMakeBucket(w)
|
||||
case r.Method == http.MethodPost && query.Has("uploads"):
|
||||
f.handleInitiateUpload(w, r, key)
|
||||
case r.Method == http.MethodPut && query.Get("uploadId") != "":
|
||||
f.handleUploadPart(w, r, key)
|
||||
case r.Method == http.MethodPost && query.Get("uploadId") != "":
|
||||
f.handleCompleteUpload(w, key)
|
||||
case r.Method == http.MethodHead:
|
||||
f.handleStat(w, key)
|
||||
case r.Method == http.MethodGet:
|
||||
f.handleGet(w, key)
|
||||
case r.Method == http.MethodDelete:
|
||||
f.handleDelete(w, key)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// handleLocation serves the bucket location lookup that backs the
|
||||
// minio client's BucketExists call.
|
||||
func (f *fakeS3) handleLocation(w http.ResponseWriter) {
|
||||
if !f.bucketExists {
|
||||
writeS3Error(w, http.StatusNotFound, "NoSuchBucket")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprint(w, `<?xml version="1.0"?><LocationConstraint></LocationConstraint>`) //nolint:errcheck // test server response errors are irrelevant
|
||||
}
|
||||
|
||||
// handleHeadBucket serves the bucket existence probe.
|
||||
func (f *fakeS3) handleHeadBucket(w http.ResponseWriter) {
|
||||
if f.failBucket {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !f.bucketExists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleMakeBucket serves bucket creation.
|
||||
func (f *fakeS3) handleMakeBucket(w http.ResponseWriter) {
|
||||
if f.failMakeBucket {
|
||||
writeS3Error(w, http.StatusForbidden, "AccessDenied")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f.bucketExists = true
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleInitiateUpload serves multipart upload initiation, recording
|
||||
// the object's content type provided by the client.
|
||||
func (f *fakeS3) handleInitiateUpload(w http.ResponseWriter, r *http.Request, key string) {
|
||||
if f.failUpload {
|
||||
writeS3Error(w, http.StatusForbidden, "AccessDenied")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
f.contentTypes[key] = r.Header.Get("Content-Type")
|
||||
|
||||
fmt.Fprintf( //nolint:errcheck,gosec // test server response errors and input taint are irrelevant
|
||||
w,
|
||||
`<?xml version="1.0"?><InitiateMultipartUploadResult><Key>%s</Key><UploadId>test-upload</UploadId></InitiateMultipartUploadResult>`,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
// handleUploadPart accumulates a single multipart upload part.
|
||||
func (f *fakeS3) handleUploadPart(w http.ResponseWriter, r *http.Request, key string) {
|
||||
f.parts[key] = append(f.parts[key], readS3Body(r)...)
|
||||
|
||||
w.Header().Set("ETag", `"part-etag"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleCompleteUpload finalizes a multipart upload by promoting the
|
||||
// accumulated parts to a stored object.
|
||||
func (f *fakeS3) handleCompleteUpload(w http.ResponseWriter, key string) {
|
||||
f.objects[key] = f.parts[key]
|
||||
|
||||
delete(f.parts, key)
|
||||
|
||||
fmt.Fprintf( //nolint:errcheck,gosec // test server response errors and input taint are irrelevant
|
||||
w,
|
||||
`<?xml version="1.0"?><CompleteMultipartUploadResult><Bucket>test-bucket</Bucket><Key>%s</Key><ETag>"test-etag"</ETag></CompleteMultipartUploadResult>`,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
// handleStat serves object metadata requests.
|
||||
func (f *fakeS3) handleStat(w http.ResponseWriter, key string) {
|
||||
if f.failStat {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
data, ok := f.objects[key]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeS3ObjectHeaders(w, data, f.contentTypes[key])
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// handleGet serves object data requests.
|
||||
func (f *fakeS3) handleGet(w http.ResponseWriter, key string) {
|
||||
data, ok := f.objects[key]
|
||||
if !ok {
|
||||
writeS3Error(w, http.StatusNotFound, "NoSuchKey")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
writeS3ObjectHeaders(w, data, f.contentTypes[key])
|
||||
w.Write(data) //nolint:errcheck,gosec // test server response errors are irrelevant
|
||||
}
|
||||
|
||||
// handleDelete serves object removal requests.
|
||||
func (f *fakeS3) handleDelete(w http.ResponseWriter, key string) {
|
||||
if f.failDelete {
|
||||
writeS3Error(w, http.StatusForbidden, "AccessDenied")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
delete(f.objects, key)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeS3ObjectHeaders writes the object metadata headers the minio
|
||||
// client requires to build stat results.
|
||||
func writeS3ObjectHeaders(w http.ResponseWriter, data []byte, contentType string) {
|
||||
w.Header().Set("ETag", `"test-etag"`)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("Last-Modified", "Mon, 02 Jan 2006 15:04:05 GMT")
|
||||
}
|
||||
|
||||
// writeS3Error writes an S3-style XML error response.
|
||||
func writeS3Error(w http.ResponseWriter, status int, code string) {
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf( //nolint:errcheck // test server response errors are irrelevant
|
||||
w,
|
||||
`<?xml version="1.0"?><Error><Code>%s</Code><Message>%s</Message></Error>`,
|
||||
code,
|
||||
code,
|
||||
)
|
||||
}
|
||||
|
||||
// readS3Body reads a request body, decoding the aws-chunked streaming
|
||||
// signature framing the minio client uses for unknown-size uploads.
|
||||
// Each frame is a "<hex size>;chunk-signature=<sig>" header line
|
||||
// followed by the payload, both CRLF-terminated; a zero-size frame
|
||||
// ends the stream.
|
||||
func readS3Body(r *http.Request) []byte {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(r.Header.Get("X-Amz-Content-Sha256"), "STREAMING-") {
|
||||
return raw
|
||||
}
|
||||
|
||||
var out []byte
|
||||
|
||||
for {
|
||||
idx := bytes.Index(raw, []byte("\r\n"))
|
||||
if idx < 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
header := string(raw[:idx])
|
||||
raw = raw[idx+2:]
|
||||
|
||||
if semi := strings.Index(header, ";"); semi >= 0 {
|
||||
header = header[:semi]
|
||||
}
|
||||
|
||||
length, err := parseHexLength(header)
|
||||
if err != nil || length == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
out = append(out, raw[:length]...)
|
||||
raw = raw[length+2:]
|
||||
}
|
||||
}
|
||||
|
||||
// parseHexLength parses a hexadecimal chunk length.
|
||||
func parseHexLength(val string) (int, error) {
|
||||
var length int
|
||||
|
||||
_, err := fmt.Sscanf(val, "%x", &length)
|
||||
|
||||
return length, err
|
||||
}
|
||||
262
server/core/internal/storage/object_test.go
Normal file
262
server/core/internal/storage/object_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// _testPNG is a data prefix carrying the PNG magic bytes so content
|
||||
// type sniffing detects image/png.
|
||||
var _testPNG = append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{1}, 1024)...)
|
||||
|
||||
// _testJPEG is a data prefix carrying the JPEG magic bytes so content
|
||||
// type sniffing detects image/jpeg.
|
||||
var _testJPEG = append([]byte("\xff\xd8\xff"), bytes.Repeat([]byte{2}, 1024)...)
|
||||
|
||||
// _testWebP is a data prefix carrying the WebP magic bytes so content
|
||||
// type sniffing detects image/webp.
|
||||
var _testWebP = append([]byte("RIFF\x00\x00\x00\x00WEBPVP8 "), bytes.Repeat([]byte{3}, 1024)...)
|
||||
|
||||
func Test_Client_Upload(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Fake *fakeS3
|
||||
Reader io.Reader
|
||||
Data []byte
|
||||
ContentType string
|
||||
Err error
|
||||
}{
|
||||
"Error returned by reader": {
|
||||
Fake: &fakeS3{},
|
||||
Reader: &errReader{},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Invalid content type": {
|
||||
Fake: &fakeS3{},
|
||||
Reader: strings.NewReader("plain text data"),
|
||||
Err: ErrInvalidContentType,
|
||||
},
|
||||
"Size limit exceeded": {
|
||||
Fake: &fakeS3{},
|
||||
Reader: io.MultiReader(
|
||||
bytes.NewReader(_testPNG),
|
||||
bytes.NewReader(bytes.Repeat([]byte{4}, _maxObjectSize)),
|
||||
),
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Error returned by PutObject": {
|
||||
Fake: &fakeS3{failUpload: true},
|
||||
Reader: bytes.NewReader(_testPNG),
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Successful PNG upload": {
|
||||
Fake: &fakeS3{},
|
||||
Reader: bytes.NewReader(_testPNG),
|
||||
Data: _testPNG,
|
||||
ContentType: "image/png",
|
||||
},
|
||||
"Successful JPEG upload": {
|
||||
Fake: &fakeS3{},
|
||||
Reader: bytes.NewReader(_testJPEG),
|
||||
Data: _testJPEG,
|
||||
ContentType: "image/jpeg",
|
||||
},
|
||||
"Successful WebP upload": {
|
||||
Fake: &fakeS3{},
|
||||
Reader: bytes.NewReader(_testWebP),
|
||||
Data: _testWebP,
|
||||
ContentType: "image/webp",
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := prepClient(t, c.Fake)
|
||||
|
||||
err := client.Upload(context.Background(), "folder", "object-id", c.Reader)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Data, c.Fake.objects["folder/object-id"])
|
||||
assert.Equal(t, c.ContentType, c.Fake.contentTypes["folder/object-id"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Client_Retrieve(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Fake *fakeS3
|
||||
Bucket string
|
||||
Found bool
|
||||
Err error
|
||||
}{
|
||||
"Error returned by GetObject": {
|
||||
Fake: &fakeS3{},
|
||||
Bucket: "..",
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Error returned by Stat": {
|
||||
Fake: &fakeS3{failStat: true},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Object not found": {
|
||||
Fake: &fakeS3{},
|
||||
},
|
||||
"Successful retrieval": {
|
||||
Fake: &fakeS3{
|
||||
objects: map[string][]byte{"folder/object-id": _testPNG},
|
||||
contentTypes: map[string]string{"folder/object-id": "image/png"},
|
||||
},
|
||||
Found: true,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := prepClient(t, c.Fake)
|
||||
|
||||
if c.Bucket != "" {
|
||||
client.bucket = c.Bucket
|
||||
}
|
||||
|
||||
info, found, err := client.Retrieve(context.Background(), "folder", "object-id")
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Found, found)
|
||||
|
||||
if !c.Found {
|
||||
assert.Nil(t, info)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NotNil(t, info)
|
||||
|
||||
body, err := io.ReadAll(info.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, info.Body.Close())
|
||||
|
||||
assert.Equal(t, _testPNG, body)
|
||||
assert.Equal(t, "test-etag", info.ETag)
|
||||
assert.Equal(t, "image/png", info.ContentType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Client_Delete(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Fake *fakeS3
|
||||
Err error
|
||||
}{
|
||||
"Error returned by RemoveObject": {
|
||||
Fake: &fakeS3{
|
||||
objects: map[string][]byte{"folder/object-id": _testPNG},
|
||||
failDelete: true,
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Successful deletion": {
|
||||
Fake: &fakeS3{
|
||||
objects: map[string][]byte{"folder/object-id": _testPNG},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := prepClient(t, c.Fake)
|
||||
|
||||
err := client.Delete(context.Background(), "folder", "object-id")
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotContains(t, c.Fake.objects, "folder/object-id")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_newLimitedReader(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := strings.NewReader("data")
|
||||
lr := newLimitedReader(r, 10)
|
||||
|
||||
require.NotNil(t, lr)
|
||||
assert.Equal(t, r, lr.r)
|
||||
assert.EqualValues(t, 10, lr.limit)
|
||||
assert.Zero(t, lr.read)
|
||||
}
|
||||
|
||||
func Test_limitedReader_Read(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Reader io.Reader
|
||||
Limit int64
|
||||
Result []byte
|
||||
Err error
|
||||
}{
|
||||
"Error returned by reader": {
|
||||
Reader: &errReader{},
|
||||
Limit: 10,
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Limit exceeded": {
|
||||
Reader: strings.NewReader("oversized data"),
|
||||
Limit: 4,
|
||||
Err: ErrSizeLimitExceeded,
|
||||
},
|
||||
"Read within limit": {
|
||||
Reader: strings.NewReader("data"),
|
||||
Limit: 10,
|
||||
Result: []byte("data"),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
lr := newLimitedReader(c.Reader, c.Limit)
|
||||
|
||||
buf := make([]byte, 64)
|
||||
|
||||
n, err := lr.Read(buf)
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Result, buf[:n])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// errReader is a reader that always fails.
|
||||
type errReader struct{}
|
||||
|
||||
// Read returns a read failure.
|
||||
func (er *errReader) Read(_ []byte) (int, error) {
|
||||
return 0, assert.AnError
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ func EncryptText(text string, key []byte) (string, error) {
|
|||
// https://en.wikipedia.org/wiki/Galois/Counter_Mode.
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
// NOCOV: cipher.NewGCM cannot fail with a valid AES block.
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +37,7 @@ func EncryptText(text string, key []byte) (string, error) {
|
|||
// Populates our nonce with a cryptographically secure
|
||||
// random sequence.
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
// NOCOV: crypto/rand failures cannot be simulated in tests.
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +73,7 @@ func DecryptText(encryptedText string, key []byte) (string, error) {
|
|||
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
// NOCOV: cipher.NewGCM cannot fail with a valid AES block.
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -248,6 +248,33 @@ func Test_DecodeForm(t *testing.T) {
|
|||
req.URL.RawQuery = q.Encode()
|
||||
assert.NoError(t, DecodeForm(req, &v2))
|
||||
assert.Equal(t, []xid.ID{id}, v2.IDs)
|
||||
|
||||
v3 := struct {
|
||||
ID xid.ID
|
||||
Time time.Time
|
||||
}{}
|
||||
tm := time.Date(2021, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
|
||||
req = httptest.NewRequest("GET", "http://test.com/", http.NoBody)
|
||||
q = req.URL.Query()
|
||||
q.Add("id", "invalid")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
assert.Equal(t, ErrInvalidForm, DecodeForm(req, &v3))
|
||||
|
||||
req = httptest.NewRequest("GET", "http://test.com/", http.NoBody)
|
||||
q = req.URL.Query()
|
||||
q.Add("time", "invalid")
|
||||
req.URL.RawQuery = q.Encode()
|
||||
assert.Equal(t, ErrInvalidForm, DecodeForm(req, &v3))
|
||||
|
||||
req = httptest.NewRequest("GET", "http://test.com/", http.NoBody)
|
||||
q = req.URL.Query()
|
||||
q.Add("id", id.String())
|
||||
q.Add("time", tm.Format(time.RFC3339Nano))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
assert.NoError(t, DecodeForm(req, &v3))
|
||||
assert.Equal(t, id, v3.ID)
|
||||
assert.Equal(t, tm, v3.Time)
|
||||
}
|
||||
|
||||
func Test_NotFound(t *testing.T) {
|
||||
|
|
@ -282,6 +309,32 @@ func Test_Recoverer(t *testing.T) {
|
|||
assert.Contains(t, rec.Body.String(), strings.ToLower(http.StatusText(http.StatusInternalServerError)))
|
||||
}
|
||||
|
||||
func Test_ExtractNamedID(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://test.com/", http.NoBody)
|
||||
id, err := ExtractNamedID(req, "id")
|
||||
assert.Zero(t, id)
|
||||
assert.Equal(t, errutil.ErrNotFound, err)
|
||||
|
||||
ctx := chi.NewRouteContext()
|
||||
ctx.URLParams.Add("id", "invalid")
|
||||
req = req.WithContext(context.WithValue(context.Background(),
|
||||
chi.RouteCtxKey, ctx))
|
||||
|
||||
id, err = ExtractNamedID(req, "id")
|
||||
assert.Zero(t, id)
|
||||
assert.Equal(t, errutil.ErrNotFound, err)
|
||||
|
||||
expID := xid.New()
|
||||
ctx = chi.NewRouteContext()
|
||||
ctx.URLParams.Add("id", expID.String())
|
||||
req = req.WithContext(context.WithValue(context.Background(),
|
||||
chi.RouteCtxKey, ctx))
|
||||
|
||||
id, err = ExtractNamedID(req, "id")
|
||||
assert.Equal(t, expID, id)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_ExtractParam(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://test.com/", http.NoBody)
|
||||
val, err := ExtractParam(req, "key")
|
||||
|
|
|
|||
|
|
@ -203,6 +203,31 @@ func Test_Recover(t *testing.T) {
|
|||
ShowCritical = false
|
||||
}
|
||||
|
||||
func Test_processRecoveryValue(t *testing.T) {
|
||||
// the nil plan branch inside the returned closure is reachable
|
||||
// only through a direct call, since Recover replaces a nil plan
|
||||
// before invoking this helper.
|
||||
out, b := testutil.NewBuffer()
|
||||
log := slog.New(slog.NewJSONHandler(out, &slog.HandlerOptions{
|
||||
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == "time" {
|
||||
return slog.Attr{}
|
||||
}
|
||||
|
||||
return a
|
||||
},
|
||||
}))
|
||||
|
||||
processRecoveryValue(log, nil)("error")
|
||||
|
||||
require.NoError(t, out.Flush())
|
||||
assert.JSONEq(t, `{
|
||||
"level":"ERROR",
|
||||
"error":"internal error",
|
||||
"msg":"recovered from panic"
|
||||
}`, b.String())
|
||||
}
|
||||
|
||||
func Test_NewRecoveryPlan(t *testing.T) {
|
||||
p := NewRecoveryPlan("hello")
|
||||
require.NotNil(t, p)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@ import (
|
|||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func Test_New(t *testing.T) {
|
||||
reg := prometheus.NewRegistry()
|
||||
f := NewFactory("test", reg)
|
||||
|
|
|
|||
|
|
@ -236,26 +236,7 @@ func (f *factory) CollectRuntimeMetrics(ctx context.Context, dur time.Duration)
|
|||
metrics.memory.NextGC.Set(float64(memStats.NextGC))
|
||||
metrics.memory.NumGC.Set(float64(memStats.NumGC - numGC))
|
||||
|
||||
i := numGC % uint32(len(memStats.PauseNs))
|
||||
ii := memStats.NumGC % uint32(len(memStats.PauseNs))
|
||||
|
||||
if memStats.NumGC-numGC >= uint32(len(memStats.PauseNs)) {
|
||||
for i = range uint32(len(memStats.PauseNs)) {
|
||||
metrics.memory.Pause.Observe(float64(memStats.PauseNs[i] * _nanosecondsPerSecond))
|
||||
}
|
||||
} else {
|
||||
if i > ii {
|
||||
for ; i < uint32(len(memStats.PauseNs)); i++ {
|
||||
metrics.memory.Pause.Observe(float64(memStats.PauseNs[i] * _nanosecondsPerSecond))
|
||||
}
|
||||
|
||||
i = 0
|
||||
}
|
||||
|
||||
for ; i < ii; i++ {
|
||||
metrics.memory.Pause.Observe(float64(memStats.PauseNs[i] * _nanosecondsPerSecond))
|
||||
}
|
||||
}
|
||||
observeGCPauses(metrics.memory.Pause, &memStats, numGC)
|
||||
|
||||
frees = memStats.Frees
|
||||
lookups = memStats.Lookups
|
||||
|
|
@ -271,3 +252,31 @@ func (f *factory) CollectRuntimeMetrics(ctx context.Context, dur time.Duration)
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// observeGCPauses observes the garbage collection pause durations
|
||||
// recorded since the previous collection cycle, unwinding the
|
||||
// runtime's circular pause buffer.
|
||||
func observeGCPauses(pause Observer, memStats *runtime.MemStats, prevNumGC uint32) {
|
||||
i := prevNumGC % uint32(len(memStats.PauseNs))
|
||||
ii := memStats.NumGC % uint32(len(memStats.PauseNs))
|
||||
|
||||
if memStats.NumGC-prevNumGC >= uint32(len(memStats.PauseNs)) {
|
||||
for i = range uint32(len(memStats.PauseNs)) {
|
||||
pause.Observe(float64(memStats.PauseNs[i] * _nanosecondsPerSecond))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if i > ii {
|
||||
for ; i < uint32(len(memStats.PauseNs)); i++ {
|
||||
pause.Observe(float64(memStats.PauseNs[i] * _nanosecondsPerSecond))
|
||||
}
|
||||
|
||||
i = 0
|
||||
}
|
||||
|
||||
for ; i < ii; i++ {
|
||||
pause.Observe(float64(memStats.PauseNs[i] * _nanosecondsPerSecond))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package metricutil
|
|||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -46,3 +47,76 @@ func Test_CollectRuntimeMetrics(t *testing.T) {
|
|||
f.CollectRuntimeMetrics(ctx, time.Millisecond*50)
|
||||
})
|
||||
}
|
||||
|
||||
func Test_observeGCPauses(t *testing.T) {
|
||||
// the runtime records pauses in a 256-entry circular buffer, so
|
||||
// the case data is expressed through buffer indexes.
|
||||
buffer := func(vals map[int]uint64) [256]uint64 {
|
||||
var pp [256]uint64
|
||||
|
||||
for i, v := range vals {
|
||||
pp[i] = v
|
||||
}
|
||||
|
||||
return pp
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
PauseNs [256]uint64
|
||||
NumGC uint32
|
||||
PrevNumGC uint32
|
||||
Result []float64
|
||||
}{
|
||||
"No new garbage collections": {
|
||||
PauseNs: buffer(map[int]uint64{3: 1}),
|
||||
NumGC: 3,
|
||||
PrevNumGC: 3,
|
||||
},
|
||||
"Pauses within a single window": {
|
||||
PauseNs: buffer(map[int]uint64{2: 1, 3: 2, 4: 3}),
|
||||
NumGC: 5,
|
||||
PrevNumGC: 2,
|
||||
Result: []float64{1e9, 2e9, 3e9},
|
||||
},
|
||||
"Pauses wrapping the buffer": {
|
||||
PauseNs: buffer(map[int]uint64{254: 1, 255: 2, 0: 3, 1: 4}),
|
||||
NumGC: 258,
|
||||
PrevNumGC: 254,
|
||||
Result: []float64{1e9, 2e9, 3e9, 4e9},
|
||||
},
|
||||
"Full buffer of pauses": {
|
||||
PauseNs: buffer(map[int]uint64{0: 1}),
|
||||
NumGC: 300,
|
||||
PrevNumGC: 0,
|
||||
Result: append(
|
||||
[]float64{1e9},
|
||||
make([]float64, 255)...,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ob := &observerStub{}
|
||||
memStats := &runtime.MemStats{
|
||||
NumGC: c.NumGC,
|
||||
PauseNs: c.PauseNs,
|
||||
}
|
||||
|
||||
observeGCPauses(ob, memStats, c.PrevNumGC)
|
||||
assert.Equal(t, c.Result, ob.values)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// observerStub records observed values for assertions.
|
||||
type observerStub struct {
|
||||
values []float64
|
||||
}
|
||||
|
||||
// Observe records the observed value.
|
||||
func (ob *observerStub) Observe(value float64) {
|
||||
ob.values = append(ob.values, value)
|
||||
}
|
||||
|
|
|
|||
15
server/core/pkg/metricutil/util_test.go
Normal file
15
server/core/pkg/metricutil/util_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package metricutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_histogramBuckets(t *testing.T) {
|
||||
// custom buckets take precedence
|
||||
assert.Equal(t, []float64{1, 2}, histogramBuckets([]float64{1, 2}))
|
||||
|
||||
// package default is used when no custom buckets are given
|
||||
assert.Equal(t, _defaultHistogramBuckets, histogramBuckets(nil))
|
||||
}
|
||||
|
|
@ -249,23 +249,88 @@ func Test_ValueStore_Get(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_ValueStore_Delete(t *testing.T) {
|
||||
conn := redigomock.NewConn()
|
||||
conn.Command("DEL", "test").Expect(int64(1))
|
||||
cc := map[string]struct {
|
||||
Cancelled bool
|
||||
Conn func() (*redigomock.Conn, func(*testing.T))
|
||||
Err error
|
||||
}{
|
||||
"Cancelled context": {
|
||||
Cancelled: true,
|
||||
Conn: func() (*redigomock.Conn, func(*testing.T)) {
|
||||
conn := redigomock.NewConn()
|
||||
|
||||
vs := &ValueStore[any]{
|
||||
pool: &redis.Pool{
|
||||
Dial: func() (redis.Conn, error) {
|
||||
return conn, nil
|
||||
return conn, func(t *testing.T) {
|
||||
err := conn.ExpectationsWereMet()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"DEL returns an error": {
|
||||
Conn: func() (*redigomock.Conn, func(*testing.T)) {
|
||||
conn := redigomock.NewConn()
|
||||
conn.Command("DEL", "test").ExpectError(assert.AnError)
|
||||
|
||||
return conn, func(t *testing.T) {
|
||||
err := conn.ExpectationsWereMet()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
},
|
||||
Err: assert.AnError,
|
||||
},
|
||||
"Successfully deleted element": {
|
||||
Conn: func() (*redigomock.Conn, func(*testing.T)) {
|
||||
conn := redigomock.NewConn()
|
||||
conn.Command("DEL", "test").Expect(int64(1))
|
||||
|
||||
return conn, func(t *testing.T) {
|
||||
err := conn.ExpectationsWereMet()
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
},
|
||||
Wait: true,
|
||||
MaxActive: 10,
|
||||
},
|
||||
expireAfter: time.Hour * 168,
|
||||
}
|
||||
|
||||
err := vs.Delete(context.Background(), "test")
|
||||
require.NoError(t, err)
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err = conn.ExpectationsWereMet()
|
||||
require.NoError(t, err)
|
||||
conn, check := c.Conn()
|
||||
|
||||
vs := &ValueStore[any]{
|
||||
pool: &redis.Pool{
|
||||
Dial: func() (redis.Conn, error) {
|
||||
return conn, nil
|
||||
},
|
||||
Wait: true,
|
||||
MaxActive: 10,
|
||||
},
|
||||
expireAfter: time.Hour * 168,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if c.Cancelled {
|
||||
cancel()
|
||||
}
|
||||
|
||||
err := vs.Delete(ctx, "test")
|
||||
|
||||
check(t)
|
||||
|
||||
if c.Err != nil {
|
||||
if errors.Is(c.Err, assert.AnError) {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.Err, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ func NanoID() string {
|
|||
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
// NOCOV: crypto/rand failures cannot be simulated in tests.
|
||||
panic(err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/goleak"
|
||||
)
|
||||
|
||||
|
|
@ -12,6 +16,95 @@ func TestMain(m *testing.M) {
|
|||
goleak.VerifyTestMain(m)
|
||||
}
|
||||
|
||||
func Test_AddChiCtx(t *testing.T) {
|
||||
// fresh context - a chi route context is created
|
||||
ctx := AddChiCtx(context.Background(), "key", "value")
|
||||
|
||||
rctx, ok := ctx.Value(chi.RouteCtxKey).(*chi.Context)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "value", rctx.URLParam("key"))
|
||||
|
||||
// existing context - the chi route context is reused
|
||||
ctx2 := AddChiCtx(ctx, "key2", "value2")
|
||||
|
||||
assert.Equal(t, ctx, ctx2)
|
||||
assert.Equal(t, "value", rctx.URLParam("key"))
|
||||
assert.Equal(t, "value2", rctx.URLParam("key2"))
|
||||
}
|
||||
|
||||
func Test_AssertEqualError(t *testing.T) {
|
||||
exp := errors.New("expected error")
|
||||
|
||||
// success - no error expected and none occurred
|
||||
nt := &testing.T{}
|
||||
AssertEqualError(nt, nil, nil)
|
||||
assert.False(t, nt.Failed())
|
||||
|
||||
// error - no error expected but one occurred
|
||||
nt = &testing.T{}
|
||||
AssertEqualError(nt, nil, assert.AnError)
|
||||
assert.True(t, nt.Failed())
|
||||
|
||||
// success - generic error expected and one occurred
|
||||
nt = &testing.T{}
|
||||
AssertEqualError(nt, assert.AnError, errors.New("error"))
|
||||
assert.False(t, nt.Failed())
|
||||
|
||||
// error - generic error expected but none occurred
|
||||
nt = &testing.T{}
|
||||
AssertEqualError(nt, assert.AnError, nil)
|
||||
assert.True(t, nt.Failed())
|
||||
|
||||
// success - concrete error expected and matched
|
||||
nt = &testing.T{}
|
||||
AssertEqualError(nt, exp, exp)
|
||||
assert.False(t, nt.Failed())
|
||||
|
||||
// error - concrete error expected but a different one occurred
|
||||
nt = &testing.T{}
|
||||
AssertEqualError(nt, exp, errors.New("other error"))
|
||||
assert.True(t, nt.Failed())
|
||||
}
|
||||
|
||||
func Test_RequireEqualError(t *testing.T) {
|
||||
// require assertions stop the calling goroutine via FailNow on
|
||||
// failure, so every call runs in its own goroutine.
|
||||
run := func(exp, err error) *testing.T {
|
||||
nt := &testing.T{}
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
|
||||
RequireEqualError(nt, exp, err) //nolint:testifylint // the helper's require behavior is under test
|
||||
}()
|
||||
|
||||
<-done
|
||||
|
||||
return nt
|
||||
}
|
||||
|
||||
exp := errors.New("expected error")
|
||||
|
||||
// success - no error expected and none occurred
|
||||
assert.False(t, run(nil, nil).Failed())
|
||||
|
||||
// error - no error expected but one occurred
|
||||
assert.True(t, run(nil, assert.AnError).Failed())
|
||||
|
||||
// success - generic error expected and one occurred
|
||||
assert.False(t, run(assert.AnError, errors.New("error")).Failed())
|
||||
|
||||
// error - generic error expected but none occurred
|
||||
assert.True(t, run(assert.AnError, nil).Failed())
|
||||
|
||||
// success - concrete error expected and matched
|
||||
assert.False(t, run(exp, exp).Failed())
|
||||
|
||||
// error - concrete error expected but a different one occurred
|
||||
assert.True(t, run(exp, errors.New("other error")).Failed())
|
||||
}
|
||||
|
||||
func Test_FilterEqual(t *testing.T) {
|
||||
type object struct {
|
||||
Name string
|
||||
|
|
@ -36,6 +129,14 @@ func Test_FilterEqual(t *testing.T) {
|
|||
object{CreatedAt: time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC).Local()},
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// success - unexported fields are compared as well
|
||||
type hidden struct {
|
||||
name string
|
||||
}
|
||||
|
||||
err = FilterEqual(hidden{name: "a"}, hidden{name: "a"})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_AssertFilterEqual(t *testing.T) {
|
||||
|
|
@ -51,3 +152,38 @@ func Test_AssertFilterEqual(t *testing.T) {
|
|||
AssertFilterEqual(nt, 1, 1)
|
||||
assert.False(t, nt.Failed())
|
||||
}
|
||||
|
||||
func Test_MockHTTP(t *testing.T) {
|
||||
client, mt := MockHTTP()
|
||||
|
||||
require.NotNil(t, client)
|
||||
require.NotNil(t, mt)
|
||||
assert.Same(t, mt, client.Transport)
|
||||
}
|
||||
|
||||
func Test_NewBuffer(t *testing.T) {
|
||||
w, b := NewBuffer()
|
||||
|
||||
require.NotNil(t, w)
|
||||
require.NotNil(t, b)
|
||||
assert.NotNil(t, w.out)
|
||||
}
|
||||
|
||||
func Test_Writer_Write(t *testing.T) {
|
||||
w, _ := NewBuffer()
|
||||
|
||||
n, err := w.Write([]byte("data"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 4, n)
|
||||
}
|
||||
|
||||
func Test_Writer_Flush(t *testing.T) {
|
||||
w, b := NewBuffer()
|
||||
|
||||
_, err := w.Write([]byte("data"))
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, b.String())
|
||||
|
||||
require.NoError(t, w.Flush())
|
||||
assert.Equal(t, "data", b.String())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user