ee782785f0
Go 1.25's testing/synctest package (GA) provides a fake clock bubble
for deterministic goroutine and timer testing. Convert tests that
waited on real-time durations to use synctest, removing most wall-clock
time.Sleep workarounds.
Converted (11 tests, 9 files):
- notify/notify_test.go — all tests, replaced 17 time.Sleep(110ms) with synctest.Wait()
- store/service/service_test.go — VoteSameIPWithDuration, UserReplies, submitImages,
ResubmitStagingImages, deleteImagesOnCommentDelete
- store/image/{image,bolt_store}_test.go — Cleanup, Submit, SubmitDelay
- store/engine/bolt_test.go — FlagListBlocked
- providers/telegram_test.go — DispatchTelegramUpdates
- migrator/backup_test.go — TestBackup_Do
- _example/memory_store/accessor/data_test.go — FlagListBlocked
Simplifications along the way:
- notify/notify_mock.go: dropped the 10ms time.After delay and
ctx.Done select in MockDest — the artificial I/O simulation is
pointless and blocked synctest.Wait from draining the queue
- Removed three dead-code time.Sleep(1s) calls in EditCommentDurationFailed,
EditCommentAdmin, and Info tests: prepopulated comments from 2017
already exceed any EditDuration/ReadOnlyAge under real clock, making
the sleeps meaningless
- UserReplies: replaced the Eventually+Sleep+mutex polling with a
direct time.Sleep under fake clock
Skipped (incompatible with synctest):
- fs_store_test.go: relies on OS file mtime (real wall clock)
- rss_test.go: needs real wall-clock second boundary for pubDate
- admin/rest_private/rest_public tests: httptest network I/O
- cmd/server_test.go: real HTTP server startup polling
Notes on quirks encountered:
- synctest.Wait() does NOT advance fake time, contrary to what one
might expect. It only returns once all other bubble goroutines are
durably blocked. To advance the fake clock, the test goroutine must
itself call time.Sleep
- BoltDB keys the "last" bucket by comment.Timestamp nanosecond string.
Rapid b.Create calls under frozen fake time produce identical keys
and overwrite each other. TestService_UserReplies adds
time.Sleep(time.Nanosecond) between Creates to advance the clock
- Bolt image Cleanup uses strict age > ttl. Under fake time the
age-ttl delta is exactly zero at the boundary, so subtract 1ms from
the passed ttl to stay strictly under
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: umputun <535880+umputun@users.noreply.github.com>
294 lines
10 KiB
Go
294 lines
10 KiB
Go
package image
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"image"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestService_SaveAndLoad(t *testing.T) {
|
|
store := StoreMock{
|
|
SaveFunc: func(string, []byte) error {
|
|
return nil
|
|
},
|
|
LoadFunc: func(string) ([]byte, error) {
|
|
return nil, nil
|
|
},
|
|
}
|
|
svc := NewService(&store, ServiceParams{MaxSize: 1500, MaxWidth: 32, MaxHeight: 32})
|
|
|
|
err := svc.SaveWithID("test_id", gopherPNG())
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 1, len(store.SaveCalls()))
|
|
assert.Equal(t, "test_id", store.SaveCalls()[0].ID)
|
|
|
|
img, err := svc.Load("test_id")
|
|
assert.NoError(t, err)
|
|
assert.Nil(t, img)
|
|
assert.Equal(t, 1, len(store.LoadCalls()))
|
|
assert.Equal(t, "test_id", store.LoadCalls()[0].ID)
|
|
}
|
|
|
|
func TestService_Resize(t *testing.T) {
|
|
img, err := readAndValidateImage(gopherPNG(), 1500)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, 1462, len(img))
|
|
|
|
img = resize(img, 32, 32)
|
|
assert.Equal(t, 1135, len(img))
|
|
}
|
|
|
|
func TestService_ResizeJpeg(t *testing.T) {
|
|
fh, err := os.Open("testdata/circles.jpg")
|
|
defer func() { assert.NoError(t, fh.Close()) }()
|
|
assert.NoError(t, err)
|
|
|
|
img, err := readAndValidateImage(fh, 32000)
|
|
assert.NoError(t, err)
|
|
assert.InDelta(t, 16756, len(img), 100)
|
|
|
|
img = resize(img, 400, 300)
|
|
assert.InDelta(t, 10913, len(img), 100)
|
|
}
|
|
|
|
func TestService_SaveTooLarge(t *testing.T) {
|
|
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/"}}
|
|
svc.MaxSize = 2000
|
|
_, err := svc.Save("user2", io.MultiReader(gopherPNG(), gopherPNG()))
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "is too large")
|
|
err = svc.SaveWithID("test_id", io.MultiReader(gopherPNG(), gopherPNG()))
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "is too large")
|
|
}
|
|
|
|
func TestService_WrongFormat(t *testing.T) {
|
|
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/"}}
|
|
|
|
_, err := svc.Save("user1", strings.NewReader("blah blah bad image"))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestService_ExtractPictures(t *testing.T) {
|
|
svc := Service{ServiceParams: ServiceParams{ImageAPI: "/blah/", ProxyAPI: "/non_existent"}}
|
|
html := `blah <img src="/blah/user1/pic1.png"/> foo
|
|
<img src="/blah/user2/pic3.png"/> xyz <p>123</p> <img src="/pic3.png"/> <img src="https://i.ibb.co/0cqqqnD/ezgif-5-3b07b6b97610.png" alt="">`
|
|
ids := svc.ExtractPictures(html)
|
|
require.Equal(t, 2, len(ids), "two images")
|
|
assert.Equal(t, "user1/pic1.png", ids[0])
|
|
assert.Equal(t, "user2/pic3.png", ids[1])
|
|
|
|
svc = Service{ServiceParams: ServiceParams{ImageAPI: "https://remark42.radio-t.com/api/v1/picture/", ProxyAPI: "https://remark42.radio-t.com/api/v1/img"}}
|
|
html = `<p>TLDR: такое в go пока правильно посчитать трудно. То, что они считают это общее количество go packages в коде.
|
|
</p>\n\n<p>Пакеты в го это средство организации кода, они могут быть связанны друг с другом в рамках одной библиотеки (модуля).
|
|
Например одна из моих вот так выглядит на libraries.io:</p>\n\n
|
|
<p><img src="https://remark42.radio-t.com/api/v1/picture/github_ef0f706a79cc24b17bbbb374cd234a691d034128/bjttt8ahajfmrhsula10.png" alt="bjtr0-201906-08110846-i324c.png"/></p>\n\n<p>
|
|
По форме все верно, это все packages, но по сути это все одна библиотека организованная таким образом. При ее импорте, например посредством go mod, она выглядит как один модуль, т.е.
|
|
<code>github.com/go-pkgz/auth v0.5.2</code>.</p>\n`
|
|
ids = svc.ExtractPictures(html)
|
|
require.Equal(t, 1, len(ids), "one image in")
|
|
assert.Equal(t, "github_ef0f706a79cc24b17bbbb374cd234a691d034128/bjttt8ahajfmrhsula10.png", ids[0])
|
|
|
|
// proxied image
|
|
html = `<img src="https://remark42.radio-t.com/api/v1/img?src=aHR0cHM6Ly9ob21lcGFnZXMuY2FlLndpc2MuZWR1L35lY2U1MzMvaW1hZ2VzL2JvYXQucG5n" alt="cat.png">`
|
|
ids = svc.ExtractPictures(html)
|
|
require.Equal(t, 1, len(ids), "one image in")
|
|
assert.Equal(t, "cached_images/12318fbd4c55e9d177b8b5ae197bc89c5afd8e07-a41fcb00643f28d700504256ec81cbf2e1aac53e", ids[0])
|
|
require.Empty(t, svc.ExtractNonProxiedPictures(html), "no non-proxied images expected to be found")
|
|
|
|
// bad url
|
|
html = `<img src=" https://remark42.radio-t.com/api/v1/img">`
|
|
ids = svc.ExtractPictures(html)
|
|
require.Empty(t, ids)
|
|
|
|
// bad src
|
|
html = `<img src="https://remark42.radio-t.com/api/v1/img?src=bad">`
|
|
ids = svc.ExtractPictures(html)
|
|
require.Empty(t, ids)
|
|
|
|
// good src with bad content
|
|
badURL := base64.URLEncoding.EncodeToString([]byte(" http://foo.bar"))
|
|
html = fmt.Sprintf(`<img src="https://remark42.radio-t.com/api/v1/img?src=%s">`, badURL)
|
|
ids = svc.ExtractPictures(html)
|
|
require.Empty(t, ids)
|
|
}
|
|
|
|
func TestService_Cleanup(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
store := StoreMock{
|
|
CleanupFunc: func(context.Context, time.Duration) error {
|
|
return nil
|
|
},
|
|
}
|
|
|
|
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
|
|
// cancel context after 2.1 cleanup TTLs
|
|
ctx, cancel := context.WithTimeout(context.Background(), svc.EditDuration/100*15*21)
|
|
defer cancel()
|
|
svc.Cleanup(ctx)
|
|
assert.Equal(t, 2, len(store.CleanupCalls()))
|
|
})
|
|
}
|
|
|
|
func TestService_Submit(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
store := StoreMock{
|
|
CommitFunc: func(string) error { return nil },
|
|
ResetCleanupTimerFunc: func(string) error { return nil },
|
|
}
|
|
svc := NewService(&store, ServiceParams{ImageAPI: "/blah/", EditDuration: time.Millisecond * 100})
|
|
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
|
|
assert.Equal(t, 3, len(store.ResetCleanupTimerCalls()))
|
|
err := svc.Commit(func() []string { return []string{"id4", "id5"} })
|
|
assert.NoError(t, err)
|
|
svc.Submit(func() []string { return []string{"id6", "id7"} })
|
|
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
|
|
svc.Submit(nil)
|
|
assert.Equal(t, 2, len(store.CommitCalls()))
|
|
time.Sleep(time.Millisecond * 175)
|
|
assert.Equal(t, 7, len(store.CommitCalls()))
|
|
svc.Close(context.TODO())
|
|
})
|
|
}
|
|
|
|
func TestService_Close(t *testing.T) {
|
|
store := StoreMock{
|
|
CommitFunc: func(string) error { return nil },
|
|
ResetCleanupTimerFunc: func(string) error { return nil },
|
|
}
|
|
svc := Service{store: &store, ServiceParams: ServiceParams{ImageAPI: "/blah/", EditDuration: time.Hour * 24}}
|
|
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
|
|
svc.Submit(func() []string { return []string{"id4", "id5"} })
|
|
svc.Submit(nil)
|
|
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
|
|
svc.Close(context.TODO())
|
|
assert.Equal(t, 5, len(store.CommitCalls()))
|
|
}
|
|
|
|
func TestService_SubmitDelay(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
store := StoreMock{
|
|
CommitFunc: func(string) error { return nil },
|
|
ResetCleanupTimerFunc: func(string) error {
|
|
return nil
|
|
},
|
|
}
|
|
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
|
|
svc.Submit(func() []string { return []string{"id1", "id2", "id3"} })
|
|
time.Sleep(150 * time.Millisecond) // let first batch to pass TTL
|
|
svc.Submit(func() []string { return []string{"id4", "id5"} })
|
|
svc.Submit(nil)
|
|
assert.Equal(t, 5, len(store.ResetCleanupTimerCalls()))
|
|
assert.Equal(t, 3, len(store.CommitCalls()))
|
|
svc.Close(context.TODO())
|
|
assert.Equal(t, 5, len(store.CommitCalls()))
|
|
})
|
|
}
|
|
|
|
func TestService_Info(t *testing.T) {
|
|
store := StoreMock{InfoFunc: func() (StoreInfo, error) {
|
|
return StoreInfo{}, nil
|
|
}}
|
|
|
|
svc := Service{store: &store, ServiceParams: ServiceParams{}}
|
|
info, err := svc.Info()
|
|
assert.NoError(t, err)
|
|
assert.True(t, info.FirstStagingImageTS.IsZero())
|
|
assert.Equal(t, 1, len(store.InfoCalls()))
|
|
}
|
|
|
|
func TestService_resize(t *testing.T) {
|
|
// reader is nil
|
|
resized := resize(nil, 100, 100)
|
|
assert.Nil(t, resized)
|
|
|
|
// negative limit error
|
|
resized = resize([]byte("some picture bin data"), -1, -1)
|
|
require.NotNil(t, resized)
|
|
assert.Equal(t, resized, []byte("some picture bin data"))
|
|
|
|
// decode error
|
|
resized = resize([]byte("invalid image content"), 100, 100)
|
|
assert.NotNil(t, resized)
|
|
assert.Equal(t, resized, []byte("invalid image content"))
|
|
|
|
cases := []struct {
|
|
file string
|
|
wr, hr int
|
|
}{
|
|
{"testdata/circles.png", 400, 300}, // full size: 800x600 px
|
|
{"testdata/circles.jpg", 300, 400}, // full size: 600x800 px
|
|
}
|
|
|
|
for _, c := range cases {
|
|
img, err := os.ReadFile(c.file)
|
|
require.NoError(t, err, "can't open test file %s", c.file)
|
|
|
|
// no need for resize, image dimensions are smaller than resize limit
|
|
resized = resize(img, 800, 800)
|
|
assert.NotNil(t, resized, "file %s", c.file)
|
|
assert.Equal(t, resized, img)
|
|
|
|
// resizing to half of width
|
|
resized = resize(img, 400, 400)
|
|
assert.NotNil(t, resized, "file %s", c.file)
|
|
imgRz, format, err := image.Decode(bytes.NewBuffer(resized))
|
|
assert.NoError(t, err, "file %s", c.file)
|
|
assert.Equal(t, "png", format, "file %s", c.file)
|
|
bounds := imgRz.Bounds()
|
|
assert.Equal(t, c.wr, bounds.Dx(), "file %s", c.file)
|
|
assert.Equal(t, c.hr, bounds.Dy(), "file %s", c.file)
|
|
}
|
|
}
|
|
|
|
func TestGetProportionalSizes(t *testing.T) {
|
|
tbl := []struct {
|
|
inpW, inpH int
|
|
limitW, limitH int
|
|
resW, resH int
|
|
}{
|
|
{10, 20, 50, 25, 10, 20},
|
|
{400, 200, 50, 25, 50, 25},
|
|
{100, 100, 50, 25, 25, 25},
|
|
{100, 200, 50, 25, 12, 25},
|
|
}
|
|
|
|
for i, tt := range tbl {
|
|
t.Run(strconv.Itoa(i), func(t *testing.T) {
|
|
resW, resH := getProportionalSizes(tt.inpW, tt.inpH, tt.limitW, tt.limitH)
|
|
assert.Equal(t, tt.resW, resW, "width")
|
|
assert.Equal(t, tt.resH, resH, "height")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCachedImgID(t *testing.T) {
|
|
img, err := CachedImgID(" http://foo.com")
|
|
assert.Error(t, err)
|
|
assert.Empty(t, img)
|
|
imgURL := "http://example.org/img/1.png"
|
|
img, err = CachedImgID(imgURL)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, "cached_images/"+Sha1Str("example.org")+"-"+Sha1Str(imgURL), img)
|
|
}
|
|
|
|
func TestService_DoubleClose(*testing.T) {
|
|
store := StoreMock{}
|
|
svc := NewService(&store, ServiceParams{EditDuration: 20 * time.Millisecond})
|
|
svc.Close(context.TODO())
|
|
// second call should not result in panic
|
|
svc.Close(context.TODO())
|
|
}
|