From 21d6ab4e8f6b850ed0348837471faea604d3d1b4 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sun, 4 Jan 2026 15:21:22 +0100 Subject: [PATCH 1/6] introduce the module to spawn a postgres container to test against --- go.mod | 41 +++++++-- go.sum | 85 +++++++++++++++--- testcontainers/postgres/README.md | 63 +++++++++++++ testcontainers/postgres/postgres.go | 108 +++++++++++++++++++++++ testcontainers/postgres/postgres_test.go | 24 +++++ 5 files changed, 303 insertions(+), 18 deletions(-) create mode 100644 testcontainers/postgres/README.md create mode 100644 testcontainers/postgres/postgres.go create mode 100644 testcontainers/postgres/postgres_test.go diff --git a/go.mod b/go.mod index 9263a13..966c969 100644 --- a/go.mod +++ b/go.mod @@ -27,21 +27,27 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/mmcdole/gofeed v1.3.0 + github.com/ory/dockertest/v3 v3.12.0 github.com/pkg/errors v0.9.1 + github.com/rubenv/sql-migrate v1.8.1 github.com/samber/lo v1.47.0 github.com/samber/mo v1.13.0 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 github.com/teekennedy/goldmark-markdown v0.5.1 github.com/volatiletech/null/v8 v8.1.2 github.com/volatiletech/sqlboiler/v4 v4.18.0 github.com/volatiletech/strmangle v0.0.6 github.com/yuin/goldmark v1.7.13 github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 - golang.org/x/sync v0.10.0 + golang.org/x/sync v0.18.0 ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/JohannesKaufmann/dom v0.2.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect github.com/PuerkitoBio/goquery v1.9.2 // indirect github.com/alecthomas/repr v0.4.0 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect @@ -62,18 +68,28 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/containerd/continuity v0.4.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/docker/cli v27.4.1+incompatible // indirect + github.com/docker/docker v27.1.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/ericlagergren/decimal v0.0.0-20190420051523-6335edbaa640 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-viper/mapstructure/v2 v2.1.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/gofrs/uuid v4.2.0+incompatible // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -85,22 +101,33 @@ require ( github.com/mailjet/mailjet-apiv3-go/v3 v3.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/runc v1.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/volatiletech/inflect v0.0.1 // indirect github.com/volatiletech/randomize v0.0.1 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.32.0 // indirect + golang.org/x/crypto v0.45.0 // indirect golang.org/x/image v0.23.0 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect - google.golang.org/protobuf v1.34.1 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9b4b832..8ef781e 100644 --- a/go.sum +++ b/go.sum @@ -53,12 +53,16 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0/go.mod h1:+6sju8gk8FRmSajX3Oz4G5Gm7P+mbqE9FVaXXFYTkCM= github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= @@ -74,6 +78,10 @@ github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0 github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= @@ -155,6 +163,8 @@ github.com/can3p/anti-disposable-email v0.0.0-20230623054934-598d3044afb0 h1:lul github.com/can3p/anti-disposable-email v0.0.0-20230623054934-598d3044afb0/go.mod h1:fPyW2zSasYUwzd0PGsOPjPrDzb7jPg+ZIE53alPqXDI= github.com/can3p/gogo v0.0.0-20240724001046-388a9ef0ec1b h1:GahokPiuEVG+eSZeJlltJeUnRJk9E0crtJ4USto5VFQ= github.com/can3p/gogo v0.0.0-20240724001046-388a9ef0ec1b/go.mod h1:bZftBbI426unatsvsOQ/mquMzgsgijsJ/2sSBiWP6mI= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -178,10 +188,14 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4= +github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -191,6 +205,14 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI= +github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= +github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -233,6 +255,8 @@ github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -251,6 +275,8 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-viper/mapstructure/v2 v2.1.0 h1:gHnMa2Y/pIxElCH2GlZZ1lZSsn6XMtufpGyP1XxdC/w= +github.com/go-viper/mapstructure/v2 v2.1.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -258,6 +284,7 @@ github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= @@ -338,6 +365,8 @@ github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -495,6 +524,12 @@ github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk= github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -507,6 +542,14 @@ github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19oYB80= +github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM= +github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= +github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -525,6 +568,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= @@ -551,6 +596,8 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= @@ -571,6 +618,8 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo= github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= @@ -597,8 +646,9 @@ github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.3.0/go.mod h1:YzJjq/33h7nrwdY+iHMhEOEEbW0ovIz0tB6t6PwAXzs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/teekennedy/goldmark-markdown v0.5.1 h1:2lIlJ3AcIwaD1wFl4dflJSJFMhRTKEsEj+asVsu6M/0= @@ -620,6 +670,13 @@ github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni github.com/volatiletech/strmangle v0.0.6 h1:AdOYE3B2ygRDq4rXDij/MMwq6KVK/pWAYxpC7CLrkKQ= github.com/volatiletech/strmangle v0.0.6/go.mod h1:ycDvbDkjDvhC0NUU8w3fWwl5JEMTV56vTKXzR3GeR+0= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -672,8 +729,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -774,8 +831,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -813,8 +870,9 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -898,6 +956,7 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -910,8 +969,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -937,8 +996,9 @@ golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1179,8 +1239,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -1201,12 +1261,15 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/testcontainers/postgres/README.md b/testcontainers/postgres/README.md new file mode 100644 index 0000000..22627df --- /dev/null +++ b/testcontainers/postgres/README.md @@ -0,0 +1,63 @@ +# PostgreSQL Test Container Helper + +This package provides a simple test helper for spinning up PostgreSQL containers with migrations applied. + +## Features + +- Uses `github.com/ory/dockertest` for container management +- Automatically applies all migrations from the `migrations/` folder +- Provides a `*sqlx.DB` handle for testing +- Simple one-liner setup with cleanup + +## Usage + +```go +package mypackage_test + +import ( + "testing" + "github.com/can3p/pcom/testcontainers/postgres" + "github.com/stretchr/testify/require" +) + +func TestMyFunction(t *testing.T) { + // Get a test database with all migrations applied + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() // Cleanup container when done + + // Use testDB.DB (*sqlx.DB) for your tests + var count int + err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") + require.NoError(t, err) + + // Your test logic here... +} +``` + +## Requirements + +- Docker must be running on the host machine +- The test will automatically: + - Pull the `postgres:16-alpine` image if not present + - Start a container with a test database + - Apply all migrations from `migrations/` folder + - Set container to auto-remove after 120 seconds + +## API + +### `NewTestDB() (*TestDB, error)` + +Creates a new PostgreSQL test container with migrations applied. + +Returns: +- `*TestDB`: Container handle with DB connection +- `error`: Any error during setup + +### `TestDB.DB` + +A `*sqlx.DB` handle connected to the test database. + +### `TestDB.Close() error` + +Stops and removes the container. Should be called with `defer` after creating the test DB. diff --git a/testcontainers/postgres/postgres.go b/testcontainers/postgres/postgres.go new file mode 100644 index 0000000..a84f9ed --- /dev/null +++ b/testcontainers/postgres/postgres.go @@ -0,0 +1,108 @@ +package postgres + +import ( + "database/sql" + "fmt" + "log" + "path/filepath" + "runtime" + + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + migrate "github.com/rubenv/sql-migrate" +) + +type TestDB struct { + DB *sqlx.DB + pool *dockertest.Pool + resource *dockertest.Resource +} + +func (t *TestDB) Close() error { + if t.DB != nil { + if err := t.DB.Close(); err != nil { + return err + } + } + if t.pool != nil && t.resource != nil { + if err := t.pool.Purge(t.resource); err != nil { + return fmt.Errorf("failed to purge resource: %w", err) + } + } + return nil +} + +func NewTestDB() (*TestDB, error) { + pool, err := dockertest.NewPool("") + if err != nil { + return nil, fmt.Errorf("could not construct pool: %w", err) + } + + err = pool.Client.Ping() + if err != nil { + return nil, fmt.Errorf("could not connect to Docker: %w", err) + } + + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "postgres", + Tag: "16-alpine", + Env: []string{ + "POSTGRES_PASSWORD=secret", + "POSTGRES_USER=testuser", + "POSTGRES_DB=testdb", + "listen_addresses = '*'", + }, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + if err != nil { + return nil, fmt.Errorf("could not start resource: %w", err) + } + + hostAndPort := resource.GetHostPort("5432/tcp") + databaseUrl := fmt.Sprintf("postgres://testuser:secret@%s/testdb?sslmode=disable", hostAndPort) + + resource.Expire(120) + + var db *sql.DB + if err = pool.Retry(func() error { + db, err = sql.Open("postgres", databaseUrl) + if err != nil { + return err + } + return db.Ping() + }); err != nil { + pool.Purge(resource) + return nil, fmt.Errorf("could not connect to database: %w", err) + } + + sqlxDB := sqlx.NewDb(db, "postgres") + + _, filename, _, ok := runtime.Caller(0) + if !ok { + pool.Purge(resource) + return nil, fmt.Errorf("could not get caller information") + } + migrationsDir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations") + + migrations := &migrate.FileMigrationSource{ + Dir: migrationsDir, + } + + n, err := migrate.Exec(db, "postgres", migrations, migrate.Up) + if err != nil { + pool.Purge(resource) + return nil, fmt.Errorf("could not run migrations: %w", err) + } + + log.Printf("Applied %d migrations to test database", n) + + return &TestDB{ + DB: sqlxDB, + pool: pool, + resource: resource, + }, nil +} diff --git a/testcontainers/postgres/postgres_test.go b/testcontainers/postgres/postgres_test.go new file mode 100644 index 0000000..31ea44d --- /dev/null +++ b/testcontainers/postgres/postgres_test.go @@ -0,0 +1,24 @@ +package postgres + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTestDB(t *testing.T) { + testDB, err := NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + var result int + err = testDB.DB.Get(&result, "SELECT 1") + require.NoError(t, err) + assert.Equal(t, 1, result) + + var count int + err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") + require.NoError(t, err) + assert.Equal(t, 0, count) +} From e485f60cafa38ba26eb5587371f08266874c2ca3 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sun, 4 Jan 2026 15:29:01 +0100 Subject: [PATCH 2/6] share the container between the tests --- testcontainers/postgres/README.md | 64 +++++++- testcontainers/postgres/postgres.go | 179 +++++++++++++++++------ testcontainers/postgres/postgres_test.go | 54 +++++++ 3 files changed, 243 insertions(+), 54 deletions(-) diff --git a/testcontainers/postgres/README.md b/testcontainers/postgres/README.md index 22627df..461aa5b 100644 --- a/testcontainers/postgres/README.md +++ b/testcontainers/postgres/README.md @@ -4,6 +4,9 @@ This package provides a simple test helper for spinning up PostgreSQL containers ## Features +- **Singleton container pattern**: Starts one PostgreSQL container shared across all tests +- **Per-test database isolation**: Each test gets its own database with migrations applied +- **Parallel test support**: Tests can run in parallel without interfering with each other - Uses `github.com/ory/dockertest` for container management - Automatically applies all migrations from the `migrations/` folder - Provides a `*sqlx.DB` handle for testing @@ -11,6 +14,8 @@ This package provides a simple test helper for spinning up PostgreSQL containers ## Usage +### Basic Usage + ```go package mypackage_test @@ -20,11 +25,19 @@ import ( "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + code := m.Run() + postgres.Cleanup() // Cleanup container after all tests + if code != 0 { + panic(code) + } +} + func TestMyFunction(t *testing.T) { // Get a test database with all migrations applied testDB, err := postgres.NewTestDB() require.NoError(t, err) - defer testDB.Close() // Cleanup container when done + defer testDB.Close() // Drops the database (not the container) // Use testDB.DB (*sqlx.DB) for your tests var count int @@ -35,23 +48,56 @@ func TestMyFunction(t *testing.T) { } ``` +### Parallel Tests + +```go +func TestParallelOperations(t *testing.T) { + t.Run("test1", func(t *testing.T) { + t.Parallel() + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + // Each test gets its own isolated database + // Tests can run in parallel without conflicts + }) + + t.Run("test2", func(t *testing.T) { + t.Parallel() + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + // Completely isolated from test1 + }) +} +``` + +## How It Works + +1. **First call to `NewTestDB()`**: Starts a shared PostgreSQL container (happens once) +2. **Each `NewTestDB()` call**: Creates a new database with a unique name and applies all migrations +3. **`TestDB.Close()`**: Drops the test database (container keeps running) +4. **`Cleanup()`**: Stops and removes the container (call in `TestMain`) + ## Requirements - Docker must be running on the host machine -- The test will automatically: +- The helper will automatically: - Pull the `postgres:16-alpine` image if not present - - Start a container with a test database + - Start a single shared container on first use + - Create isolated databases for each test - Apply all migrations from `migrations/` folder - - Set container to auto-remove after 120 seconds + - Set container to auto-remove after 300 seconds ## API ### `NewTestDB() (*TestDB, error)` -Creates a new PostgreSQL test container with migrations applied. +Creates a new isolated database with migrations applied. Uses a singleton container that is created on first call. Returns: -- `*TestDB`: Container handle with DB connection +- `*TestDB`: Database handle with connection - `error`: Any error during setup ### `TestDB.DB` @@ -60,4 +106,8 @@ A `*sqlx.DB` handle connected to the test database. ### `TestDB.Close() error` -Stops and removes the container. Should be called with `defer` after creating the test DB. +Drops the test database and closes connections. The container continues running for other tests. Should be called with `defer` after creating the test DB. + +### `Cleanup() error` + +Stops and removes the shared PostgreSQL container. Should be called in `TestMain` after all tests complete. diff --git a/testcontainers/postgres/postgres.go b/testcontainers/postgres/postgres.go index a84f9ed..15902c3 100644 --- a/testcontainers/postgres/postgres.go +++ b/testcontainers/postgres/postgres.go @@ -6,7 +6,10 @@ import ( "log" "path/filepath" "runtime" + "sync" + "sync/atomic" + "github.com/google/uuid" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "github.com/ory/dockertest/v3" @@ -14,10 +17,23 @@ import ( migrate "github.com/rubenv/sql-migrate" ) +var ( + containerOnce sync.Once + sharedContainer *containerInstance + containerInitErr error + dbCounter atomic.Uint64 +) + +type containerInstance struct { + pool *dockertest.Pool + resource *dockertest.Resource + hostAndPort string +} + type TestDB struct { - DB *sqlx.DB - pool *dockertest.Pool - resource *dockertest.Resource + DB *sqlx.DB + dbName string + adminDB *sql.DB } func (t *TestDB) Close() error { @@ -26,64 +42,130 @@ func (t *TestDB) Close() error { return err } } - if t.pool != nil && t.resource != nil { - if err := t.pool.Purge(t.resource); err != nil { - return fmt.Errorf("failed to purge resource: %w", err) + + if t.adminDB != nil && t.dbName != "" { + _, err := t.adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", t.dbName)) + if err != nil { + return fmt.Errorf("failed to drop database %s: %w", t.dbName, err) + } + } + + if t.adminDB != nil { + if err := t.adminDB.Close(); err != nil { + return err } } + return nil } -func NewTestDB() (*TestDB, error) { - pool, err := dockertest.NewPool("") - if err != nil { - return nil, fmt.Errorf("could not construct pool: %w", err) +func Cleanup() error { + if sharedContainer != nil && sharedContainer.pool != nil && sharedContainer.resource != nil { + if err := sharedContainer.pool.Purge(sharedContainer.resource); err != nil { + return fmt.Errorf("failed to purge container: %w", err) + } + sharedContainer = nil } + return nil +} - err = pool.Client.Ping() - if err != nil { - return nil, fmt.Errorf("could not connect to Docker: %w", err) - } +func getOrCreateContainer() (*containerInstance, error) { + containerOnce.Do(func() { + pool, err := dockertest.NewPool("") + if err != nil { + containerInitErr = fmt.Errorf("could not construct pool: %w", err) + return + } + + err = pool.Client.Ping() + if err != nil { + containerInitErr = fmt.Errorf("could not connect to Docker: %w", err) + return + } - resource, err := pool.RunWithOptions(&dockertest.RunOptions{ - Repository: "postgres", - Tag: "16-alpine", - Env: []string{ - "POSTGRES_PASSWORD=secret", - "POSTGRES_USER=testuser", - "POSTGRES_DB=testdb", - "listen_addresses = '*'", - }, - }, func(config *docker.HostConfig) { - config.AutoRemove = true - config.RestartPolicy = docker.RestartPolicy{Name: "no"} + resource, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "postgres", + Tag: "16-alpine", + Env: []string{ + "POSTGRES_PASSWORD=secret", + "POSTGRES_USER=testuser", + "POSTGRES_DB=postgres", + "listen_addresses = '*'", + }, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + if err != nil { + containerInitErr = fmt.Errorf("could not start resource: %w", err) + return + } + + hostAndPort := resource.GetHostPort("5432/tcp") + resource.Expire(300) + + var testConn *sql.DB + if err = pool.Retry(func() error { + testConn, err = sql.Open("postgres", fmt.Sprintf("postgres://testuser:secret@%s/postgres?sslmode=disable", hostAndPort)) + if err != nil { + return err + } + defer testConn.Close() + return testConn.Ping() + }); err != nil { + pool.Purge(resource) + containerInitErr = fmt.Errorf("could not connect to database: %w", err) + return + } + + log.Printf("Started shared PostgreSQL container at %s", hostAndPort) + + sharedContainer = &containerInstance{ + pool: pool, + resource: resource, + hostAndPort: hostAndPort, + } }) + + if containerInitErr != nil { + return nil, containerInitErr + } + + return sharedContainer, nil +} + +func NewTestDB() (*TestDB, error) { + container, err := getOrCreateContainer() if err != nil { - return nil, fmt.Errorf("could not start resource: %w", err) + return nil, err } - hostAndPort := resource.GetHostPort("5432/tcp") - databaseUrl := fmt.Sprintf("postgres://testuser:secret@%s/testdb?sslmode=disable", hostAndPort) + dbNum := dbCounter.Add(1) + dbName := fmt.Sprintf("testdb_%s_%d", uuid.New().String()[:8], dbNum) - resource.Expire(120) + adminURL := fmt.Sprintf("postgres://testuser:secret@%s/postgres?sslmode=disable", container.hostAndPort) + adminDB, err := sql.Open("postgres", adminURL) + if err != nil { + return nil, fmt.Errorf("could not connect to admin database: %w", err) + } - var db *sql.DB - if err = pool.Retry(func() error { - db, err = sql.Open("postgres", databaseUrl) - if err != nil { - return err - } - return db.Ping() - }); err != nil { - pool.Purge(resource) - return nil, fmt.Errorf("could not connect to database: %w", err) + _, err = adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)) + if err != nil { + adminDB.Close() + return nil, fmt.Errorf("could not create database %s: %w", dbName, err) } - sqlxDB := sqlx.NewDb(db, "postgres") + dbURL := fmt.Sprintf("postgres://testuser:secret@%s/%s?sslmode=disable", container.hostAndPort, dbName) + db, err := sql.Open("postgres", dbURL) + if err != nil { + adminDB.Close() + return nil, fmt.Errorf("could not connect to test database: %w", err) + } _, filename, _, ok := runtime.Caller(0) if !ok { - pool.Purge(resource) + db.Close() + adminDB.Close() return nil, fmt.Errorf("could not get caller information") } migrationsDir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations") @@ -94,15 +176,18 @@ func NewTestDB() (*TestDB, error) { n, err := migrate.Exec(db, "postgres", migrations, migrate.Up) if err != nil { - pool.Purge(resource) + db.Close() + adminDB.Close() return nil, fmt.Errorf("could not run migrations: %w", err) } - log.Printf("Applied %d migrations to test database", n) + log.Printf("Created database %s and applied %d migrations", dbName, n) + + sqlxDB := sqlx.NewDb(db, "postgres") return &TestDB{ - DB: sqlxDB, - pool: pool, - resource: resource, + DB: sqlxDB, + dbName: dbName, + adminDB: adminDB, }, nil } diff --git a/testcontainers/postgres/postgres_test.go b/testcontainers/postgres/postgres_test.go index 31ea44d..100ee33 100644 --- a/testcontainers/postgres/postgres_test.go +++ b/testcontainers/postgres/postgres_test.go @@ -7,6 +7,14 @@ import ( "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + code := m.Run() + Cleanup() + if code != 0 { + panic(code) + } +} + func TestNewTestDB(t *testing.T) { testDB, err := NewTestDB() require.NoError(t, err) @@ -22,3 +30,49 @@ func TestNewTestDB(t *testing.T) { require.NoError(t, err) assert.Equal(t, 0, count) } + +func TestParallelDatabases(t *testing.T) { + t.Run("db1", func(t *testing.T) { + t.Parallel() + testDB, err := NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + _, err = testDB.DB.Exec("INSERT INTO users (id, email, timezone, username) VALUES ($1, $2, $3, $4)", + "00000000-0000-0000-0000-000000000001", "test1@example.com", "UTC", "user1") + require.NoError(t, err) + + var count int + err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") + require.NoError(t, err) + assert.Equal(t, 1, count) + }) + + t.Run("db2", func(t *testing.T) { + t.Parallel() + testDB, err := NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + var count int + err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") + require.NoError(t, err) + assert.Equal(t, 0, count) + }) + + t.Run("db3", func(t *testing.T) { + t.Parallel() + testDB, err := NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + _, err = testDB.DB.Exec("INSERT INTO users (id, email, timezone, username) VALUES ($1, $2, $3, $4)", + "00000000-0000-0000-0000-000000000002", "test2@example.com", "UTC", "user2") + require.NoError(t, err) + + var count int + err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") + require.NoError(t, err) + assert.Equal(t, 1, count) + }) +} From 094a858fc6c728f2bc95af36492c2c3478967f9c Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sun, 4 Jan 2026 18:20:44 +0100 Subject: [PATCH 3/6] add functional tests to test feeder behavior --- AGENTS.md | 43 +++++ go.mod | 4 + go.sum | 12 ++ pkg/feedops/feeder/feeder_test.go | 260 ++++++++++++++++++++++++++++++ pkg/feedops/feedops_test.go | 67 ++++++++ pkg/feedops/testutil/factory.go | 116 +++++++++++++ pkg/util/pointer.go | 5 + 7 files changed, 507 insertions(+) create mode 100644 pkg/feedops/feeder/feeder_test.go create mode 100644 pkg/feedops/feedops_test.go create mode 100644 pkg/feedops/testutil/factory.go create mode 100644 pkg/util/pointer.go diff --git a/AGENTS.md b/AGENTS.md index f027c91..9c527d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,49 @@ Failed downloads are replaced with readable error messages in markdown: - `media_uploads` table supports either `user_id` OR `rss_feed_id` (mutual exclusivity enforced) - Migration: `migrations/20251231005904-media_uploads_feeds.sql` +## Testing + +### Testing Packages +- **github.com/ovechkin-dm/mockio/v2** - Mock library for Go without code generation +- **github.com/stretchr/testify** - Assertion and testing utilities (require, assert) +- **testcontainers/postgres** - PostgreSQL test container helper + +### Test Container Usage +Located in `/Users/dima/code/pcom/testcontainers/postgres`: +- Provides `NewTestDB()` function that returns a `*TestDB` with a clean database instance +- Each test gets its own isolated database +- Migrations are automatically applied from `/Users/dima/code/pcom/migrations` +- Container is shared across tests in a package for efficiency +- Container cleanup happens automatically after tests complete (with 5-minute expiration as fallback) +- Use `defer testDB.Close()` to clean up the database after each test + +### Test Factory Pattern +Located in `/Users/dima/code/pcom/pkg/feedops/testutil/factory.go`: +- Factory functions for creating test entities: `CreateUser`, `CreateRSSFeed`, `CreateRSSItem`, etc. +- Helper functions for retrieving entities: `GetRSSFeed`, `GetRSSItemsByFeed`, `GetUserFeedItemsByUser` +- All factory functions accept `context.Context` and `boil.ContextExecutor` for transaction support + +### Mockio v2 Usage +```go +import . "github.com/ovechkin-dm/mockio/v2/mock" + +func TestExample(t *testing.T) { + ctrl := NewMockController(t) + mockObj := Mock[MyInterface](ctrl) + + // Single return value + WhenSingle(mockObj.Method(Any[string]())).ThenReturn("result") + + // Multiple return values + WhenDouble(mockObj.Method(Any[string]())).ThenReturn("result", nil) + + // Dynamic answers + WhenSingle(mockObj.Method(Any[string]())).ThenAnswer(func(args []any) string { + return "dynamic result" + }) +} +``` + ## File Locations - HTML Templates: `/Users/dima/code/pcom/cmd/web/client/html/` - JavaScript: `/Users/dima/code/pcom/cmd/web/client/js/` diff --git a/go.mod b/go.mod index 966c969..ff6ed43 100644 --- a/go.mod +++ b/go.mod @@ -109,7 +109,11 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/opencontainers/runc v1.2.3 // indirect + github.com/ovechkin-dm/go-dyno v0.5.3 // indirect + github.com/ovechkin-dm/mockio v1.0.2 // indirect + github.com/ovechkin-dm/mockio/v2 v2.0.4 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/cast v1.5.0 // indirect diff --git a/go.sum b/go.sum index 8ef781e..c7139d2 100644 --- a/go.sum +++ b/go.sum @@ -550,6 +550,14 @@ github.com/opencontainers/runc v1.2.3 h1:fxE7amCzfZflJO2lHXf4y/y8M1BoAqp+FVmG19o github.com/opencontainers/runc v1.2.3/go.mod h1:nSxcWUydXrsBZVYNSkTjoQ/N6rcyTtn+1SD5D4+kRIM= github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw= github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE= +github.com/ovechkin-dm/go-dyno v0.3.2 h1:jl0hE+o6M/egVVk1SljGw2GYkIEZFkzFWJ1nbqLyEbw= +github.com/ovechkin-dm/go-dyno v0.3.2/go.mod h1:CcJNuo7AbePMoRNpM3i1jC1Rp9kHEMyWozNdWzR+0ys= +github.com/ovechkin-dm/go-dyno v0.5.3 h1:/MrL26kFTxbLj/qPbEtR4piVeFYUqjSamAgWpuzeD/k= +github.com/ovechkin-dm/go-dyno v0.5.3/go.mod h1:CcJNuo7AbePMoRNpM3i1jC1Rp9kHEMyWozNdWzR+0ys= +github.com/ovechkin-dm/mockio v1.0.2 h1:AR31nVoWhZeMDe9FnfFfayof/y9ed3HASIVhqVKyl8M= +github.com/ovechkin-dm/mockio v1.0.2/go.mod h1:TAmLa+rztm8IKxrc44JPAviGEhRzNeIyF9oiFznMcCo= +github.com/ovechkin-dm/mockio/v2 v2.0.4 h1:miQxn7WOnRLsnqGgOl9Tbgpv0VxiMky0eAEPSm3ejUs= +github.com/ovechkin-dm/mockio/v2 v2.0.4/go.mod h1:NIkz06mKOotiaEiZtLgKOWgOPzgx3+6Pqg+x+6blucM= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -557,6 +565,10 @@ github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZO github.com/pelletier/go-toml/v2 v2.0.5/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e h1:D0bJD+4O3G4izvrQUmzCL80zazlN7EwJ0PPDhpJWC/I= +github.com/petermattis/goid v0.0.0-20250721140440-ea1c0173183e/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/pkg/feedops/feeder/feeder_test.go b/pkg/feedops/feeder/feeder_test.go new file mode 100644 index 0000000..77824e4 --- /dev/null +++ b/pkg/feedops/feeder/feeder_test.go @@ -0,0 +1,260 @@ +package feeder_test + +import ( + "context" + "io" + "strconv" + "testing" + "time" + + "github.com/can3p/pcom/pkg/feedops" + "github.com/can3p/pcom/pkg/feedops/feeder" + "github.com/can3p/pcom/pkg/feedops/reader" + "github.com/can3p/pcom/pkg/feedops/testutil" + "github.com/can3p/pcom/pkg/util" + "github.com/can3p/pcom/testcontainers/postgres" + . "github.com/ovechkin-dm/mockio/v2/mock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +func TestGetFeedsToRefresh(t *testing.T) { + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + ctx := context.Background() + + user, err := testutil.CreateUser(ctx, testDB.DB, "test@example.com") + require.NoError(t, err) + + pastTime := time.Now().Add(-1 * time.Hour) + futureTime := time.Now().Add(1 * time.Hour) + + feed1, err := testutil.CreateRSSFeed(ctx, testDB.DB, "https://example.com/feed1", "Feed 1") + require.NoError(t, err) + feed1.NextFetchAt = null.TimeFrom(pastTime) + _, err = feed1.Update(ctx, testDB.DB, boil.Infer()) + require.NoError(t, err) + + feed2, err := testutil.CreateRSSFeed(ctx, testDB.DB, "https://example.com/feed2", "Feed 2") + require.NoError(t, err) + feed2.NextFetchAt = null.TimeFrom(futureTime) + _, err = feed2.Update(ctx, testDB.DB, boil.Infer()) + require.NoError(t, err) + + feed3, err := testutil.CreateRSSFeed(ctx, testDB.DB, "https://example.com/feed3", "Feed 3") + require.NoError(t, err) + feed3.NextFetchAt = null.TimeFrom(pastTime) + _, err = feed3.Update(ctx, testDB.DB, boil.Infer()) + require.NoError(t, err) + + _, err = testutil.CreateUserFeedSubscription(ctx, testDB.DB, user.ID, feed1.ID) + require.NoError(t, err) + + _, err = testutil.CreateUserFeedSubscription(ctx, testDB.DB, user.ID, feed2.ID) + require.NoError(t, err) + + feeds, err := feeder.GetFeedsToRefresh(ctx, testDB.DB) + require.NoError(t, err) + + require.Len(t, feeds, 1, "Should only return feed1 (past time and has subscription)") + assert.Equal(t, feed1.ID, feeds[0].ID) +} + +func TestSaveFetchFailure(t *testing.T) { + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + ctx := context.Background() + + feed, err := testutil.CreateRSSFeed(ctx, testDB.DB, "https://example.com/feed", "Test Feed") + require.NoError(t, err) + + testError := assert.AnError + + err = feeder.SaveFetchFailure(ctx, testDB.DB, feed, testError) + require.NoError(t, err) + + updatedFeed, err := testutil.GetRSSFeed(ctx, testDB.DB, feed.ID) + require.NoError(t, err) + + assert.Equal(t, testError.Error(), updatedFeed.LastFetchError.String) + assert.Equal(t, 0, updatedFeed.LastItemsCount) + assert.False(t, updatedFeed.NextFetchAt.IsZero()) + assert.False(t, updatedFeed.LastFetchedAt.IsZero()) +} + +func TestLockFeed(t *testing.T) { + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + ctx := context.Background() + + feed, err := testutil.CreateRSSFeed(ctx, testDB.DB, "https://example.com/feed", "Test Feed") + require.NoError(t, err) + + lockedFeed, err := feeder.LockFeed(ctx, testDB.DB, feed.ID) + require.NoError(t, err) + assert.Equal(t, feed.ID, lockedFeed.ID) + assert.Equal(t, feed.URL, lockedFeed.URL) +} + +type fetcher interface { + Fetch(urL string) (*reader.Feed, error) + FetchMedia(ctx context.Context, mediaURL string) (io.ReadCloser, error) +} + +type cleaner interface { + CleanField(in string) string + HTMLToMarkdown(in string) (string, error) +} + +func createFeedItems(num int, startTime time.Time) []*reader.Item { + result := make([]*reader.Item, num) + for idx := range num { + n := strconv.Itoa(num - 1 - idx) + result[idx] = &reader.Item{ + URL: "https://example.com/post" + n, + Title: "Test Post " + n, + Summary: "Summary of test post " + n, + PublishedAt: util.Pointer(startTime.Add(-time.Duration(idx) * time.Hour)), + } + } + + return result +} + +func TestSaveFeed(t *testing.T) { + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + ctrl := NewMockController(t) + + ctx := context.Background() + + user, err := testutil.CreateUser(ctx, testDB.DB, "test@example.com") + require.NoError(t, err) + + pastTime := time.Now().Add(-1 * time.Hour) + + feed1, err := testutil.CreateRSSFeed(ctx, testDB.DB, "https://example.com/feed1", "Feed 1") + require.NoError(t, err) + feed1.NextFetchAt = null.TimeFrom(pastTime) + _, err = feed1.Update(ctx, testDB.DB, boil.Infer()) + require.NoError(t, err) + + _, err = testutil.CreateUserFeedSubscription(ctx, testDB.DB, user.ID, feed1.ID) + require.NoError(t, err) + + feedContent := &reader.Feed{ + Title: "test feed", + Description: "test feed description", + Items: createFeedItems(2, time.Now()), + } + + fetcher := Mock[fetcher](ctrl) + cleaner := Mock[cleaner](ctrl) + + WhenDouble(cleaner.HTMLToMarkdown(Any[string]())).ThenAnswer(func(args []any) (string, error) { + return args[0].(string), nil + }) + + err = feeder.SaveFeed(ctx, testDB.DB, feed1, feedContent, cleaner, fetcher, nil) + require.NoError(t, err) + + fetchedFeeds, err := feedops.GetRssFeedItems(ctx, testDB.DB, user.ID) + require.NoError(t, err) + require.Len(t, fetchedFeeds, 2) + + // Verify the order (newest first) + require.Equal(t, "https://example.com/post2", fetchedFeeds[0].URL) + require.Equal(t, "https://example.com/post1", fetchedFeeds[1].URL) +} + +func TestSaveFeedInitialAndFollowUp(t *testing.T) { + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + ctrl := NewMockController(t) + + ctx := context.Background() + + user, err := testutil.CreateUser(ctx, testDB.DB, "test@example.com") + require.NoError(t, err) + + pastTime := time.Now().Add(-1 * time.Hour) + + feed1, err := testutil.CreateRSSFeed(ctx, testDB.DB, "https://example.com/feed1", "Feed 1") + require.NoError(t, err) + feed1.NextFetchAt = null.TimeFrom(pastTime) + _, err = feed1.Update(ctx, testDB.DB, boil.Infer()) + require.NoError(t, err) + + _, err = testutil.CreateUserFeedSubscription(ctx, testDB.DB, user.ID, feed1.ID) + require.NoError(t, err) + + n := time.Now() + + feedContent := &reader.Feed{ + Title: "test feed", + Description: "test feed description", + Items: createFeedItems(10, n), + } + + fetcher := Mock[fetcher](ctrl) + cleaner := Mock[cleaner](ctrl) + + WhenDouble(cleaner.HTMLToMarkdown(Any[string]())).ThenAnswer(func(args []any) (string, error) { + return args[0].(string), nil + }) + + err = feeder.SaveFeed(ctx, testDB.DB, feed1, feedContent, cleaner, fetcher, nil) + require.NoError(t, err) + + fetchedFeeds, err := feedops.GetRssFeedItems(ctx, testDB.DB, user.ID) + require.NoError(t, err) + require.Len(t, fetchedFeeds, 5) + + // Verify the order (newest first) + require.Equal(t, "https://example.com/post9", fetchedFeeds[0].URL) + require.Equal(t, "https://example.com/post8", fetchedFeeds[1].URL) + require.Equal(t, "https://example.com/post7", fetchedFeeds[2].URL) + require.Equal(t, "https://example.com/post6", fetchedFeeds[3].URL) + require.Equal(t, "https://example.com/post5", fetchedFeeds[4].URL) + + newItems := []*reader.Item{ + { + Title: "fresh item", + URL: "https://example.com/post100", + Summary: "post 100", + }, + } + + newItems = append(newItems, feedContent.Items...) + feedContent = &reader.Feed{ + Title: "test feed", + Description: "test feed description", + Items: newItems, + } + + err = feeder.SaveFeed(ctx, testDB.DB, feed1, feedContent, cleaner, fetcher, nil) + require.NoError(t, err) + + fetchedFeeds, err = feedops.GetRssFeedItems(ctx, testDB.DB, user.ID) + require.NoError(t, err) + require.Len(t, fetchedFeeds, 6) + require.Equal(t, "https://example.com/post100", fetchedFeeds[0].URL) + require.Equal(t, "https://example.com/post9", fetchedFeeds[1].URL) + require.Equal(t, "https://example.com/post8", fetchedFeeds[2].URL) + require.Equal(t, "https://example.com/post7", fetchedFeeds[3].URL) + require.Equal(t, "https://example.com/post6", fetchedFeeds[4].URL) + require.Equal(t, "https://example.com/post5", fetchedFeeds[5].URL) + +} diff --git a/pkg/feedops/feedops_test.go b/pkg/feedops/feedops_test.go new file mode 100644 index 0000000..c076714 --- /dev/null +++ b/pkg/feedops/feedops_test.go @@ -0,0 +1,67 @@ +package feedops_test + +import ( + "context" + "testing" + "time" + + "github.com/can3p/pcom/pkg/feedops" + "github.com/can3p/pcom/pkg/feedops/testutil" + "github.com/can3p/pcom/testcontainers/postgres" + "github.com/stretchr/testify/require" +) + +func TestGetRssFeedItems_FiltersDismissedItems(t *testing.T) { + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + ctx := context.Background() + + user, err := testutil.CreateUser(ctx, testDB.DB, "test@example.com") + require.NoError(t, err) + + feed, err := testutil.CreateRSSFeed(ctx, testDB.DB, "https://example.com/feed", "Test Feed") + require.NoError(t, err) + + _, err = testutil.CreateUserFeedSubscription(ctx, testDB.DB, user.ID, feed.ID) + require.NoError(t, err) + + now := time.Now() + + url, err := testutil.CreateURL(ctx, testDB.DB, "https://example.com/item") + require.NoError(t, err) + rssItem, err := testutil.CreateRSSItem(ctx, testDB.DB, feed.ID, url.ID, "Active Item", now) + require.NoError(t, err) + _, err = testutil.CreateUserFeedItem(ctx, testDB.DB, user.ID, rssItem.ID, url.ID, now) + require.NoError(t, err) + + items, err := feedops.GetRssFeedItems(ctx, testDB.DB, user.ID) + require.NoError(t, err) + require.Len(t, items, 1, "Should return 1 active item") + + _, err = testDB.DB.Exec( + "UPDATE user_feed_items SET is_dismissed = true WHERE user_id = $1", + user.ID, + ) + require.NoError(t, err) + + items, err = feedops.GetRssFeedItems(ctx, testDB.DB, user.ID) + require.NoError(t, err) + require.Len(t, items, 0, "Should return 0 items after dismissing") +} + +func TestGetRssFeedItems_EmptyResult(t *testing.T) { + testDB, err := postgres.NewTestDB() + require.NoError(t, err) + defer testDB.Close() + + ctx := context.Background() + + user, err := testutil.CreateUser(ctx, testDB.DB, "test@example.com") + require.NoError(t, err) + + items, err := feedops.GetRssFeedItems(ctx, testDB.DB, user.ID) + require.NoError(t, err) + require.Len(t, items, 0, "Should return empty list for user with no items") +} diff --git a/pkg/feedops/testutil/factory.go b/pkg/feedops/testutil/factory.go new file mode 100644 index 0000000..bfeb8ac --- /dev/null +++ b/pkg/feedops/testutil/factory.go @@ -0,0 +1,116 @@ +package testutil + +import ( + "context" + "time" + + "github.com/can3p/pcom/pkg/model/core" + "github.com/google/uuid" + "github.com/volatiletech/null/v8" + "github.com/volatiletech/sqlboiler/v4/boil" +) + +func CreateUser(ctx context.Context, exec boil.ContextExecutor, email string) (*core.User, error) { + user := &core.User{ + ID: uuid.New().String(), + Email: email, + Username: email, + Timezone: "UTC", + } + + if err := user.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return user, nil +} + +func CreateRSSFeed(ctx context.Context, exec boil.ContextExecutor, url string, title string) (*core.RSSFeed, error) { + feed := &core.RSSFeed{ + ID: uuid.New().String(), + URL: url, + Title: null.StringFrom(title), + Description: null.StringFrom("Test feed description"), + } + + if err := feed.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return feed, nil +} + +func CreateUserFeedSubscription(ctx context.Context, exec boil.ContextExecutor, userID string, feedID string) (*core.UserFeedSubscription, error) { + subscription := &core.UserFeedSubscription{ + ID: uuid.New().String(), + UserID: userID, + FeedID: feedID, + } + + if err := subscription.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return subscription, nil +} + +func CreateRSSItem(ctx context.Context, exec boil.ContextExecutor, feedID string, urlID string, title string, publishedAt time.Time) (*core.RSSItem, error) { + item := &core.RSSItem{ + ID: uuid.New().String(), + FeedID: feedID, + URLID: urlID, + GUID: uuid.New().String(), + Title: title, + Description: "Test description", + SanitizedDescription: "Test description", + PublishedAt: publishedAt, + } + + if err := item.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return item, nil +} + +func CreateURL(ctx context.Context, exec boil.ContextExecutor, url string) (*core.NormalizedURL, error) { + urlRecord := &core.NormalizedURL{ + ID: uuid.New().String(), + URL: url, + } + + if err := urlRecord.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return urlRecord, nil +} + +func CreateUserFeedItem(ctx context.Context, exec boil.ContextExecutor, userID string, rssItemID string, urlID string, createdAt time.Time) (*core.UserFeedItem, error) { + item := &core.UserFeedItem{ + ID: uuid.New().String(), + UserID: userID, + RSSItemID: rssItemID, + URLID: urlID, + IsDismissed: false, + CreatedAt: createdAt, + } + + if err := item.Insert(ctx, exec, boil.Infer()); err != nil { + return nil, err + } + + return item, nil +} + +func GetRSSFeed(ctx context.Context, exec boil.ContextExecutor, feedID string) (*core.RSSFeed, error) { + return core.FindRSSFeed(ctx, exec, feedID) +} + +func GetRSSItemsByFeed(ctx context.Context, exec boil.ContextExecutor, feedID string) (core.RSSItemSlice, error) { + return core.RSSItems(core.RSSItemWhere.FeedID.EQ(feedID)).All(ctx, exec) +} + +func GetUserFeedItemsByUser(ctx context.Context, exec boil.ContextExecutor, userID string) (core.UserFeedItemSlice, error) { + return core.UserFeedItems(core.UserFeedItemWhere.UserID.EQ(userID)).All(ctx, exec) +} diff --git a/pkg/util/pointer.go b/pkg/util/pointer.go new file mode 100644 index 0000000..8d3e63c --- /dev/null +++ b/pkg/util/pointer.go @@ -0,0 +1,5 @@ +package util + +func Pointer[A any](a A) *A { + return &a +} From f53918820d7cb1ae65be95a1518f45256bdc50f5 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sun, 4 Jan 2026 18:30:02 +0100 Subject: [PATCH 4/6] add chronological test --- pkg/feedops/feeder/feeder_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/feedops/feeder/feeder_test.go b/pkg/feedops/feeder/feeder_test.go index 77824e4..4655b06 100644 --- a/pkg/feedops/feeder/feeder_test.go +++ b/pkg/feedops/feeder/feeder_test.go @@ -173,8 +173,11 @@ func TestSaveFeed(t *testing.T) { require.Len(t, fetchedFeeds, 2) // Verify the order (newest first) - require.Equal(t, "https://example.com/post2", fetchedFeeds[0].URL) - require.Equal(t, "https://example.com/post1", fetchedFeeds[1].URL) + require.Equal(t, "https://example.com/post1", fetchedFeeds[0].URL) + require.Equal(t, "https://example.com/post0", fetchedFeeds[1].URL) + + // feed items are actually sorted by AddedAt field + require.True(t, fetchedFeeds[0].AddedAt.After(fetchedFeeds[1].AddedAt)) } func TestSaveFeedInitialAndFollowUp(t *testing.T) { From cd5922129bd4838c5317a9ff96c95bcd499487e3 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sun, 4 Jan 2026 18:33:35 +0100 Subject: [PATCH 5/6] simplify the code by isnserting items in chronological order --- pkg/feedops/feeder/feeder.go | 78 ++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 34 deletions(-) diff --git a/pkg/feedops/feeder/feeder.go b/pkg/feedops/feeder/feeder.go index 54d9a49..dd2bc3a 100644 --- a/pkg/feedops/feeder/feeder.go +++ b/pkg/feedops/feeder/feeder.go @@ -186,29 +186,12 @@ func SaveFeed(ctx context.Context, exec boil.ContextExecutor, feed *core.RSSFeed } isInitialFetch = existingCount == 0 - newItems := 0 totalItemsToFetch := len(rssFeed.Items) if isInitialFetch && totalItemsToFetch > maxInitialFetchItems { // Only process the N most recent items (which are at the beginning of the slice) totalItemsToFetch = maxInitialFetchItems } - itemIDs := make([]uuid.UUID, totalItemsToFetch) - - // uuid.NewV7 guarantees ordering between subsequent calls. - // We generate IDs in reverse order to match the RSS item order (most recent first). - // Why the order is important: feed page fetches rss items by id desc, hence - // the newest items should have bigger uuid. - // pregeneration allows to keep this logic and traverse items in direct order at the same time - // stop at the first known item. - for idx := range totalItemsToFetch { - id, err := uuid.NewV7() - if err != nil { - return err - } - itemIDs[totalItemsToFetch-1-idx] = id - } - // Get subscribers once for all items subscribers, err := core.UserFeedSubscriptions( core.UserFeedSubscriptionWhere.FeedID.EQ(feed.ID), @@ -217,24 +200,44 @@ func SaveFeed(ctx context.Context, exec boil.ContextExecutor, feed *core.RSSFeed return err } - // Pre-generate user feed item IDs in descending order (one set per subscriber) - // This ensures user feed items are also ordered newest to oldest - userItemIDs := make(map[string][]uuid.UUID) // map[userID][]itemIDs - for _, s := range subscribers { - ids := make([]uuid.UUID, totalItemsToFetch) - for idx := range totalItemsToFetch { - id, err := uuid.NewV7() + // Prefetch to find the index of the first known item + // For initial fetch, all items (up to totalItemsToFetch) are new + // For subsequent fetches, find where new items end + firstKnownItemIdx := totalItemsToFetch + if !isInitialFetch { + for idx := 0; idx < totalItemsToFetch; idx++ { + item := rssFeed.Items[idx] + if item.URL == "" { + continue + } + + url, err := postops.StoreURL(ctx, exec, item.URL) + if err != nil { + return err + } + + exists, err := core.RSSItems( + core.RSSItemWhere.FeedID.EQ(feed.ID), + core.RSSItemWhere.URLID.EQ(url.ID), + ).Exists(ctx, exec) if err != nil { return err } - ids[totalItemsToFetch-1-idx] = id + + if exists { + // Found first known item, all items before this are new + firstKnownItemIdx = idx + break + } } - userItemIDs[s.UserID] = ids } - for idx, itemID := range itemIDs { + // Insert items in chronological order (from oldest new item to newest) + // This means iterating from firstKnownItemIdx-1 down to 0 + newItems := 0 + for idx := firstKnownItemIdx - 1; idx >= 0; idx-- { item := rssFeed.Items[idx] - isNew, err := SaveFeedItem(ctx, exec, feed.ID, itemID, item, subscribers, userItemIDs, idx, cleaner, fetcher, mediaStorage) + isNew, err := SaveFeedItem(ctx, exec, feed.ID, item, subscribers, cleaner, fetcher, mediaStorage) if err != nil { return err @@ -242,9 +245,6 @@ func SaveFeed(ctx context.Context, exec boil.ContextExecutor, feed *core.RSSFeed if isNew { newItems++ - } else if !isInitialFetch { - // On subsequent fetches, stop when we hit an item we've already seen - break } } @@ -282,7 +282,7 @@ func SaveFeed(ctx context.Context, exec boil.ContextExecutor, feed *core.RSSFeed return err } -func SaveFeedItem(ctx context.Context, exec boil.ContextExecutor, feedID string, itemID uuid.UUID, rssFeedItem *reader.Item, subscribers core.UserFeedSubscriptionSlice, userItemIDs map[string][]uuid.UUID, itemIdx int, cleaner cleaner, fetcher fetcher, mediaStorage server.MediaStorage) (bool, error) { +func SaveFeedItem(ctx context.Context, exec boil.ContextExecutor, feedID string, rssFeedItem *reader.Item, subscribers core.UserFeedSubscriptionSlice, cleaner cleaner, fetcher fetcher, mediaStorage server.MediaStorage) (bool, error) { if rssFeedItem.URL == "" { return false, fmt.Errorf("refuse to save an rss item without URL") } @@ -293,6 +293,11 @@ func SaveFeedItem(ctx context.Context, exec boil.ContextExecutor, feedID string, return false, err } + // Generate a new UUID v7 for this item (chronological ordering) + itemID, err := uuid.NewV7() + if err != nil { + return false, err + } feedItemID := itemID.String() markdownContent, err := cleaner.HTMLToMarkdown(rssFeedItem.Summary) @@ -363,10 +368,15 @@ func SaveFeedItem(ctx context.Context, exec boil.ContextExecutor, feedID string, return false, nil } - // Create user feed items with pre-generated IDs (in descending order) + // Create user feed items with generated UUIDs (chronological ordering) for _, s := range subscribers { + userItemID, err := uuid.NewV7() + if err != nil { + return false, err + } + userItem := core.UserFeedItem{ - ID: userItemIDs[s.UserID][itemIdx].String(), + ID: userItemID.String(), UserID: s.UserID, RSSItemID: feedItem.ID, URLID: url.ID, From bab7edca53b8b8478b9bf30c67cacea22316b1e2 Mon Sep 17 00:00:00 2001 From: Dmitry Petrov Date: Sun, 4 Jan 2026 18:40:46 +0100 Subject: [PATCH 6/6] address lint issues --- pkg/feedops/feeder/feeder_test.go | 10 +++++----- pkg/feedops/feedops_test.go | 4 ++-- testcontainers/postgres/postgres.go | 18 +++++++++--------- testcontainers/postgres/postgres_test.go | 10 +++++----- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/pkg/feedops/feeder/feeder_test.go b/pkg/feedops/feeder/feeder_test.go index 4655b06..2d58ee5 100644 --- a/pkg/feedops/feeder/feeder_test.go +++ b/pkg/feedops/feeder/feeder_test.go @@ -23,7 +23,7 @@ import ( func TestGetFeedsToRefresh(t *testing.T) { testDB, err := postgres.NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() ctx := context.Background() @@ -67,7 +67,7 @@ func TestGetFeedsToRefresh(t *testing.T) { func TestSaveFetchFailure(t *testing.T) { testDB, err := postgres.NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() ctx := context.Background() @@ -91,7 +91,7 @@ func TestSaveFetchFailure(t *testing.T) { func TestLockFeed(t *testing.T) { testDB, err := postgres.NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() ctx := context.Background() @@ -132,7 +132,7 @@ func createFeedItems(num int, startTime time.Time) []*reader.Item { func TestSaveFeed(t *testing.T) { testDB, err := postgres.NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() ctrl := NewMockController(t) @@ -183,7 +183,7 @@ func TestSaveFeed(t *testing.T) { func TestSaveFeedInitialAndFollowUp(t *testing.T) { testDB, err := postgres.NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() ctrl := NewMockController(t) diff --git a/pkg/feedops/feedops_test.go b/pkg/feedops/feedops_test.go index c076714..79ac5b3 100644 --- a/pkg/feedops/feedops_test.go +++ b/pkg/feedops/feedops_test.go @@ -14,7 +14,7 @@ import ( func TestGetRssFeedItems_FiltersDismissedItems(t *testing.T) { testDB, err := postgres.NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() ctx := context.Background() @@ -54,7 +54,7 @@ func TestGetRssFeedItems_FiltersDismissedItems(t *testing.T) { func TestGetRssFeedItems_EmptyResult(t *testing.T) { testDB, err := postgres.NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() ctx := context.Background() diff --git a/testcontainers/postgres/postgres.go b/testcontainers/postgres/postgres.go index 15902c3..74d6fae 100644 --- a/testcontainers/postgres/postgres.go +++ b/testcontainers/postgres/postgres.go @@ -102,7 +102,7 @@ func getOrCreateContainer() (*containerInstance, error) { } hostAndPort := resource.GetHostPort("5432/tcp") - resource.Expire(300) + _ = resource.Expire(300) var testConn *sql.DB if err = pool.Retry(func() error { @@ -110,10 +110,10 @@ func getOrCreateContainer() (*containerInstance, error) { if err != nil { return err } - defer testConn.Close() + defer func() { _ = testConn.Close() }() return testConn.Ping() }); err != nil { - pool.Purge(resource) + _ = pool.Purge(resource) containerInitErr = fmt.Errorf("could not connect to database: %w", err) return } @@ -151,21 +151,21 @@ func NewTestDB() (*TestDB, error) { _, err = adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)) if err != nil { - adminDB.Close() + _ = adminDB.Close() return nil, fmt.Errorf("could not create database %s: %w", dbName, err) } dbURL := fmt.Sprintf("postgres://testuser:secret@%s/%s?sslmode=disable", container.hostAndPort, dbName) db, err := sql.Open("postgres", dbURL) if err != nil { - adminDB.Close() + _ = adminDB.Close() return nil, fmt.Errorf("could not connect to test database: %w", err) } _, filename, _, ok := runtime.Caller(0) if !ok { - db.Close() - adminDB.Close() + _ = db.Close() + _ = adminDB.Close() return nil, fmt.Errorf("could not get caller information") } migrationsDir := filepath.Join(filepath.Dir(filename), "..", "..", "migrations") @@ -176,8 +176,8 @@ func NewTestDB() (*TestDB, error) { n, err := migrate.Exec(db, "postgres", migrations, migrate.Up) if err != nil { - db.Close() - adminDB.Close() + _ = db.Close() + _ = adminDB.Close() return nil, fmt.Errorf("could not run migrations: %w", err) } diff --git a/testcontainers/postgres/postgres_test.go b/testcontainers/postgres/postgres_test.go index 100ee33..0b2eb66 100644 --- a/testcontainers/postgres/postgres_test.go +++ b/testcontainers/postgres/postgres_test.go @@ -9,7 +9,7 @@ import ( func TestMain(m *testing.M) { code := m.Run() - Cleanup() + _ = Cleanup() if code != 0 { panic(code) } @@ -18,7 +18,7 @@ func TestMain(m *testing.M) { func TestNewTestDB(t *testing.T) { testDB, err := NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() var result int err = testDB.DB.Get(&result, "SELECT 1") @@ -36,7 +36,7 @@ func TestParallelDatabases(t *testing.T) { t.Parallel() testDB, err := NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() _, err = testDB.DB.Exec("INSERT INTO users (id, email, timezone, username) VALUES ($1, $2, $3, $4)", "00000000-0000-0000-0000-000000000001", "test1@example.com", "UTC", "user1") @@ -52,7 +52,7 @@ func TestParallelDatabases(t *testing.T) { t.Parallel() testDB, err := NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() var count int err = testDB.DB.Get(&count, "SELECT COUNT(*) FROM users") @@ -64,7 +64,7 @@ func TestParallelDatabases(t *testing.T) { t.Parallel() testDB, err := NewTestDB() require.NoError(t, err) - defer testDB.Close() + defer func() { _ = testDB.Close() }() _, err = testDB.DB.Exec("INSERT INTO users (id, email, timezone, username) VALUES ($1, $2, $3, $4)", "00000000-0000-0000-0000-000000000002", "test2@example.com", "UTC", "user2")