Files
remark42/backend/app/store/image/image_test.go
T
Dmitry VerkhoturovGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>umputun
ee782785f0 test: use testing/synctest to eliminate wall-clock sleeps (#2048)
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>
2026-04-18 02:44:21 -05:00

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())
}