├── calculator ├── calculator.go └── calculator_test.go ├── database └── db.go ├── go.mod ├── go.sum ├── pubsub ├── pubsub.go └── pubsub_test.go ├── ratelimit ├── compose.yml ├── ratelimit.go └── ratelimit_test.go └── repository ├── compose.yml ├── create.go ├── create_test.go ├── delete.go ├── delete_test.go ├── main_test.go ├── migrations ├── 000001_initial.down.sql └── 000001_initial.up.sql ├── read.go ├── read_test.go ├── types.go ├── update.go └── update_test.go /calculator/calculator.go: -------------------------------------------------------------------------------- 1 | package calculator 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | ) 8 | 9 | type Calculator struct { 10 | db *sql.DB 11 | } 12 | 13 | func New(db *sql.DB) *Calculator { 14 | return &Calculator{ 15 | db: db, 16 | } 17 | } 18 | 19 | func (c *Calculator) Add(ctx context.Context, a int, b int) (int, error) { 20 | var res int 21 | err := c.db.QueryRowContext(ctx, fmt.Sprintf("SELECT %d + %d", a, b)).Scan(&res) 22 | return res, err 23 | } 24 | -------------------------------------------------------------------------------- /calculator/calculator_test.go: -------------------------------------------------------------------------------- 1 | package calculator_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/testcontainers/testcontainers-go" 11 | "github.com/testcontainers/testcontainers-go/wait" 12 | 13 | "github.com/dreamsofcode-io/testcontainers/calculator" 14 | "github.com/dreamsofcode-io/testcontainers/database" 15 | ) 16 | 17 | type input struct { 18 | a int 19 | b int 20 | } 21 | 22 | func TestAdd(t *testing.T) { 23 | ctx := context.Background() 24 | 25 | request := testcontainers.ContainerRequest{ 26 | Image: "postgres:16", 27 | Env: map[string]string{ 28 | "POSTGRES_USER": "user", 29 | "POSTGRES_PASSWORD": "secret", 30 | "POSTGRES_DB": "testdb", 31 | }, 32 | ExposedPorts: []string{"5432/tcp"}, 33 | WaitingFor: wait.ForLog("database system is ready to accept connections"). 34 | WithOccurrence(2), 35 | } 36 | 37 | container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ 38 | ContainerRequest: request, 39 | Started: true, 40 | }) 41 | if err != nil { 42 | t.Fatal("failed to start container:", err) 43 | } 44 | 45 | endpoint, err := container.Endpoint(ctx, "") 46 | if err != nil { 47 | t.Fatal("failed to get endpoint:", err) 48 | } 49 | 50 | connURI := fmt.Sprintf("postgres://user:secret@%s/testdb?sslmode=disable", endpoint) 51 | 52 | testCases := []struct { 53 | name string 54 | input input 55 | wants int 56 | }{ 57 | { 58 | name: "Simple 1 + 1", 59 | input: input{ 60 | a: 1, 61 | b: 1, 62 | }, 63 | wants: 2, 64 | }, 65 | { 66 | name: "Simple 5 + 6", 67 | input: input{ 68 | a: 5, 69 | b: 6, 70 | }, 71 | wants: 11, 72 | }, 73 | { 74 | name: "Simple 10 - 12", 75 | input: input{ 76 | a: 10, 77 | b: -12, 78 | }, 79 | wants: -2, 80 | }, 81 | } 82 | 83 | for _, tc := range testCases { 84 | t.Run(t.Name(), func(t *testing.T) { 85 | 86 | conn, err := database.Connect(connURI) 87 | assert.NoError(t, err) 88 | 89 | calc := calculator.New(conn) 90 | res, err := calc.Add(ctx, tc.input.a, tc.input.b) 91 | 92 | assert.NoError(t, err) 93 | assert.Equal(t, tc.wants, res) 94 | }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /database/db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/golang-migrate/migrate/v4" 9 | _ "github.com/golang-migrate/migrate/v4/database/postgres" 10 | _ "github.com/golang-migrate/migrate/v4/source/file" 11 | _ "github.com/jackc/pgx/v5/stdlib" 12 | ) 13 | 14 | func Connect(uri string) (*sql.DB, error) { 15 | db, err := sql.Open("pgx", uri) 16 | if err != nil { 17 | return nil, fmt.Errorf("failed to open db: %w", err) 18 | } 19 | 20 | return db, nil 21 | } 22 | 23 | func Migrate(uri string) (*migrate.Migrate, error) { 24 | path, exists := os.LookupEnv("MIGRATIONS_PATH") 25 | if !exists { 26 | path = "file://migrations" 27 | } 28 | 29 | m, err := migrate.New(path, uri) 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to connect migrator: %w", err) 32 | } 33 | 34 | // Migrate all the way up ... 35 | if err := m.Up(); err != nil && err != migrate.ErrNoChange { 36 | return nil, fmt.Errorf("failed to migrate up: %w", err) 37 | } 38 | return m, nil 39 | } 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dreamsofcode-io/testcontainers 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/golang-migrate/migrate/v4 v4.17.0 7 | github.com/google/uuid v1.6.0 8 | github.com/jackc/pgx/v5 v5.5.5 9 | github.com/redis/go-redis/v9 v9.5.1 10 | github.com/segmentio/kafka-go v0.4.47 11 | github.com/stretchr/testify v1.9.0 12 | github.com/testcontainers/testcontainers-go v0.30.0 13 | ) 14 | 15 | require ( 16 | dario.cat/mergo v1.0.0 // indirect 17 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 18 | github.com/Microsoft/go-winio v0.6.1 // indirect 19 | github.com/Microsoft/hcsshim v0.11.4 // indirect 20 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 22 | github.com/containerd/containerd v1.7.12 // indirect 23 | github.com/containerd/log v0.1.0 // indirect 24 | github.com/cpuguy83/dockercfg v0.3.1 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 27 | github.com/distribution/reference v0.5.0 // indirect 28 | github.com/docker/docker v25.0.5+incompatible // indirect 29 | github.com/docker/go-connections v0.5.0 // indirect 30 | github.com/docker/go-units v0.5.0 // indirect 31 | github.com/felixge/httpsnoop v1.0.4 // indirect 32 | github.com/go-logr/logr v1.4.1 // indirect 33 | github.com/go-logr/stdr v1.2.2 // indirect 34 | github.com/go-ole/go-ole v1.2.6 // indirect 35 | github.com/gogo/protobuf v1.3.2 // indirect 36 | github.com/golang/protobuf v1.5.3 // indirect 37 | github.com/hashicorp/errwrap v1.1.0 // indirect 38 | github.com/hashicorp/go-multierror v1.1.1 // indirect 39 | github.com/jackc/pgpassfile v1.0.0 // indirect 40 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 41 | github.com/jackc/puddle/v2 v2.2.1 // indirect 42 | github.com/klauspost/compress v1.16.7 // indirect 43 | github.com/kr/text v0.2.0 // indirect 44 | github.com/lib/pq v1.10.9 // indirect 45 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect 46 | github.com/magiconair/properties v1.8.7 // indirect 47 | github.com/moby/patternmatcher v0.6.0 // indirect 48 | github.com/moby/sys/sequential v0.5.0 // indirect 49 | github.com/moby/sys/user v0.1.0 // indirect 50 | github.com/moby/term v0.5.0 // indirect 51 | github.com/morikuni/aec v1.0.0 // indirect 52 | github.com/opencontainers/go-digest v1.0.0 // indirect 53 | github.com/opencontainers/image-spec v1.1.0 // indirect 54 | github.com/pierrec/lz4/v4 v4.1.18 // indirect 55 | github.com/pkg/errors v0.9.1 // indirect 56 | github.com/pmezard/go-difflib v1.0.0 // indirect 57 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect 58 | github.com/rogpeppe/go-internal v1.12.0 // indirect 59 | github.com/shirou/gopsutil/v3 v3.23.12 // indirect 60 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 61 | github.com/sirupsen/logrus v1.9.3 // indirect 62 | github.com/testcontainers/testcontainers-go/modules/kafka v0.30.0 // indirect 63 | github.com/tklauser/go-sysconf v0.3.12 // indirect 64 | github.com/tklauser/numcpus v0.6.1 // indirect 65 | github.com/yusufpapurcu/wmi v1.2.3 // indirect 66 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 67 | go.opentelemetry.io/otel v1.24.0 // indirect 68 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 69 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 70 | go.uber.org/atomic v1.7.0 // indirect 71 | golang.org/x/crypto v0.17.0 // indirect 72 | golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea // indirect 73 | golang.org/x/mod v0.16.0 // indirect 74 | golang.org/x/sync v0.5.0 // indirect 75 | golang.org/x/sys v0.16.0 // indirect 76 | golang.org/x/text v0.14.0 // indirect 77 | golang.org/x/tools v0.13.0 // indirect 78 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect 79 | google.golang.org/grpc v1.59.0 // indirect 80 | google.golang.org/protobuf v1.33.0 // indirect 81 | gopkg.in/yaml.v3 v3.0.1 // indirect 82 | ) 83 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= 2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= 4 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 5 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 6 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 7 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 8 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 9 | github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8= 10 | github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w= 11 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 12 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 13 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 14 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 15 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= 16 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 17 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 18 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 19 | github.com/containerd/containerd v1.7.12 h1:+KQsnv4VnzyxWcfO9mlxxELaoztsDEjOuCMPAuPqgU0= 20 | github.com/containerd/containerd v1.7.12/go.mod h1:/5OMpE1p0ylxtEUGY8kuCYkDRzJm9NO1TFMWjUpdevk= 21 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 22 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 23 | github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E= 24 | github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= 25 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 26 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= 27 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 28 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 30 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 31 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 32 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 33 | github.com/dhui/dktest v0.4.0 h1:z05UmuXZHO/bgj/ds2bGMBu8FI4WA+Ag/m3ghL+om7M= 34 | github.com/dhui/dktest v0.4.0/go.mod h1:v/Dbz1LgCBOi2Uki2nUqLBGa83hWBGFMu5MrgMDCc78= 35 | github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0= 36 | github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 37 | github.com/docker/docker v25.0.5+incompatible h1:UmQydMduGkrD5nQde1mecF/YnSbTOaPeFIeP5C4W+DE= 38 | github.com/docker/docker v25.0.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 39 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 40 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 41 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 42 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 43 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 44 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 45 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 46 | github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 47 | github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 48 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 49 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 50 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= 51 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 52 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 53 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 54 | github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU= 55 | github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM= 56 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 57 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 58 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 59 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 60 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 61 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 62 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 63 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 64 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 65 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 66 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= 67 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= 68 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 69 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 70 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 71 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 72 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 73 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 74 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 75 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= 76 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 77 | github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= 78 | github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 79 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 80 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 81 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 82 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 83 | github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= 84 | github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= 85 | github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 86 | github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= 87 | github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= 88 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 89 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 90 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 91 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 92 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 93 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 94 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= 95 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= 96 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 97 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 98 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= 99 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 100 | github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= 101 | github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= 102 | github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= 103 | github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= 104 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 105 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 106 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 107 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 108 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 109 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 110 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 111 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 112 | github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 113 | github.com/pierrec/lz4/v4 v4.1.16 h1:kQPfno+wyx6C5572ABwV+Uo3pDFzQ7yhyGchSyRda0c= 114 | github.com/pierrec/lz4/v4 v4.1.16/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 115 | github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ= 116 | github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 117 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 118 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 119 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 120 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 121 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= 122 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 123 | github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= 124 | github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 125 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 126 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 127 | github.com/segmentio/kafka-go v0.4.47 h1:IqziR4pA3vrZq7YdRxaT3w1/5fvIH5qpCwstUanQQB0= 128 | github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVOfURM5KMd8qg= 129 | github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= 130 | github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= 131 | github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= 132 | github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= 133 | github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= 134 | github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= 135 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 136 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 137 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 138 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 139 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 140 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 141 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 142 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 143 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 144 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 145 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 146 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 147 | github.com/testcontainers/testcontainers-go v0.30.0 h1:jmn/XS22q4YRrcMwWg0pAwlClzs/abopbsBzrepyc4E= 148 | github.com/testcontainers/testcontainers-go v0.30.0/go.mod h1:K+kHNGiM5zjklKjgTtcrEetF3uhWbMUyqAQoyoh8Pf0= 149 | github.com/testcontainers/testcontainers-go/modules/kafka v0.30.0 h1:lQx20102vAHRDbz/glmY7BjaydopeFgf6mIZjAl8J4Q= 150 | github.com/testcontainers/testcontainers-go/modules/kafka v0.30.0/go.mod h1:n3m3SH0ivwFZbehY8fgTLADfwSPK2ZC5za4r9nYYm4Q= 151 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= 152 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= 153 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= 154 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= 155 | github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= 156 | github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= 157 | github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= 158 | github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= 159 | github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= 160 | github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= 161 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 162 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 163 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 164 | github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= 165 | github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 166 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= 167 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= 168 | go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= 169 | go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= 170 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= 171 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= 172 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= 173 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= 174 | go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= 175 | go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= 176 | go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= 177 | go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= 178 | go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= 179 | go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= 180 | go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= 181 | go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= 182 | go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= 183 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 184 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 185 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 186 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 187 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 188 | golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= 189 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 190 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 191 | golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= 192 | golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= 193 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 194 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 195 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 196 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 197 | golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= 198 | golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 199 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 200 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 201 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 202 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 203 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 204 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 205 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 206 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 207 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 208 | golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= 209 | golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= 210 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 211 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 212 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 213 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 214 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 215 | golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= 216 | golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 217 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 218 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 219 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 220 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 221 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 222 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 223 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 224 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 226 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 227 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 228 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 229 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 230 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 231 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 232 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 233 | golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= 234 | golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 235 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 236 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 237 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 238 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 239 | golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= 240 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 241 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 242 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 243 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 244 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 245 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 246 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 247 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 248 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 249 | golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 250 | golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 251 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 252 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 253 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 254 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 255 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 256 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 257 | golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= 258 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 259 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 260 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 261 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 262 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 263 | google.golang.org/genproto v0.0.0-20231016165738-49dd2c1f3d0b h1:+YaDE2r2OG8t/z5qmsh7Y+XXwCbvadxxZ0YY6mTdrVA= 264 | google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b h1:CIC2YMXmIhYw6evmhPxBKJ4fmLbOFtXQN/GV3XOZR8k= 265 | google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= 266 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 h1:AB/lmRny7e2pLhFEYIbl5qkDAUt2h0ZRO4wGPhZf+ik= 267 | google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go.mod h1:67X1fPuzjcrkymZzZV1vvkFeTn2Rvc6lYF9MYFGCcwE= 268 | google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= 269 | google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= 270 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 271 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 272 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 273 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 274 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 275 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 276 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 277 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 278 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 279 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 280 | gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= 281 | gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 282 | -------------------------------------------------------------------------------- /pubsub/pubsub.go: -------------------------------------------------------------------------------- 1 | package pubsub 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/segmentio/kafka-go" 11 | ) 12 | 13 | var ErrNoMessage = errors.New("no message on event bus") 14 | 15 | type Message struct { 16 | Title string `json:"title"` 17 | Description string `json:"description"` 18 | } 19 | 20 | type PubSub struct { 21 | conn *kafka.Conn 22 | } 23 | 24 | func New(conn *kafka.Conn) *PubSub { 25 | return &PubSub{ 26 | conn: conn, 27 | } 28 | } 29 | 30 | func (p *PubSub) WriteMessage(msg Message) error { 31 | data, err := json.Marshal(msg) 32 | if err != nil { 33 | return fmt.Errorf("failed to marshal json: %w", err) 34 | } 35 | 36 | if _, err = p.conn.Write(data); err != nil { 37 | return fmt.Errorf("failed to write message %w", err) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func (p *PubSub) ReadMessage(ctx context.Context) (Message, error) { 44 | p.conn.SetReadDeadline(time.Now().Add(10 * time.Second)) 45 | 46 | msg, err := p.conn.ReadMessage(1e6) 47 | if err != nil { 48 | return Message{}, fmt.Errorf("failed to read from connection: %w", err) 49 | } 50 | 51 | if msg.Value == nil { 52 | return Message{}, ErrNoMessage 53 | } 54 | 55 | var message Message 56 | if err := json.Unmarshal(msg.Value, &message); err != nil { 57 | return Message{}, fmt.Errorf("failed to unmarshal json: %w", err) 58 | } 59 | 60 | return message, nil 61 | } 62 | -------------------------------------------------------------------------------- /pubsub/pubsub_test.go: -------------------------------------------------------------------------------- 1 | package pubsub_test 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "testing" 7 | 8 | "github.com/segmentio/kafka-go" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/testcontainers/testcontainers-go" 11 | kfka "github.com/testcontainers/testcontainers-go/modules/kafka" 12 | "github.com/testcontainers/testcontainers-go/wait" 13 | 14 | "github.com/dreamsofcode-io/testcontainers/pubsub" 15 | ) 16 | 17 | func TestPubSub(t *testing.T) { 18 | ctx := context.Background() 19 | 20 | container, err := kfka.RunContainer(ctx, 21 | kfka.WithClusterID("test-cluster"), 22 | testcontainers.WithImage("confluentinc/confluent-local:7.5.0"), 23 | testcontainers.WithEnv(map[string]string{ 24 | "KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE": "true", 25 | }), 26 | testcontainers.WithWaitStrategy( 27 | wait.ForListeningPort("9093/tcp"), 28 | ), 29 | ) 30 | if err != nil { 31 | log.Fatalf("failed to start container: %s", err) 32 | } 33 | 34 | endpoint, err := container.PortEndpoint(ctx, "9093/tcp", "") 35 | if err != nil { 36 | log.Fatalf("failed to get endpoint: %s", err) 37 | } 38 | 39 | conn, err := kafka.DialLeader(ctx, "tcp", endpoint, "test-topic", 0) 40 | if err != nil { 41 | log.Fatalf("failed to dial leader: %s", err) 42 | } 43 | 44 | t.Run("single message", func(t *testing.T) { 45 | ps := pubsub.New(conn) 46 | err = ps.WriteMessage(pubsub.Message{ 47 | Title: "Hello, world!", 48 | Description: "testcontainers are awesome", 49 | }) 50 | 51 | assert.NoError(t, err) 52 | 53 | msg, err := ps.ReadMessage(ctx) 54 | assert.NoError(t, err) 55 | 56 | assert.Equal(t, "Hello, world!", msg.Title) 57 | assert.Equal(t, "testcontainers are awesome", msg.Description) 58 | }) 59 | 60 | t.Run("multiple messages", func(t *testing.T) { 61 | ps := pubsub.New(conn) 62 | err = ps.WriteMessage(pubsub.Message{ 63 | Title: "Hello, world!", 64 | Description: "testcontainers are awesome", 65 | }) 66 | 67 | assert.NoError(t, err) 68 | 69 | err = ps.WriteMessage(pubsub.Message{ 70 | Title: "Another one", 71 | Description: "golang is neat too", 72 | }) 73 | 74 | assert.NoError(t, err) 75 | 76 | msg, err := ps.ReadMessage(ctx) 77 | assert.NoError(t, err) 78 | 79 | assert.Equal(t, "Hello, world!", msg.Title) 80 | assert.Equal(t, "testcontainers are awesome", msg.Description) 81 | 82 | msg, err = ps.ReadMessage(ctx) 83 | assert.NoError(t, err) 84 | 85 | assert.Equal(t, "Another one", msg.Title) 86 | assert.Equal(t, "golang is neat too", msg.Description) 87 | }) 88 | } 89 | -------------------------------------------------------------------------------- /ratelimit/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:7.2 4 | ports: 5 | - "6379:6379" 6 | -------------------------------------------------------------------------------- /ratelimit/ratelimit.go: -------------------------------------------------------------------------------- 1 | package ratelimit 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "time" 8 | 9 | "github.com/redis/go-redis/v9" 10 | ) 11 | 12 | type RateLimiter struct { 13 | client *redis.Client 14 | duration time.Duration 15 | rate int64 16 | } 17 | 18 | type Info struct { 19 | hits int64 20 | limit int64 21 | expires time.Time 22 | } 23 | 24 | func (i Info) IsExceeded() bool { 25 | return i.hits > i.limit 26 | } 27 | 28 | func (i Info) Remaining() int64 { 29 | return max(i.limit-i.hits, 0) 30 | } 31 | 32 | func (i Info) Resets() time.Duration { 33 | return i.expires.Sub(time.Now()) 34 | } 35 | 36 | func (i Info) Limit() int64 { 37 | return i.limit 38 | } 39 | 40 | func New(client *redis.Client, rate int64, duration time.Duration) *RateLimiter { 41 | return &RateLimiter{ 42 | client: client, 43 | duration: duration, 44 | rate: rate, 45 | } 46 | } 47 | 48 | func (r *RateLimiter) keyFunc(ip net.IP) string { 49 | return fmt.Sprintf("%s", ip.String()) 50 | } 51 | 52 | // AddAndCheckIfExceeds is used to determine whether or not the 53 | // rate limit has been exceeded, whilst also adding another hit to it. 54 | func (r *RateLimiter) AddAndCheckIfExceeds(ctx context.Context, ip net.IP) (Info, error) { 55 | p := r.client.Pipeline() 56 | 57 | incr := p.Incr(ctx, r.keyFunc(ip)) 58 | p.ExpireNX(ctx, r.keyFunc(ip), r.duration) 59 | expires := p.ExpireTime(ctx, r.keyFunc(ip)).Val() 60 | 61 | if _, err := p.Exec(ctx); err != nil { 62 | return Info{}, err 63 | } 64 | 65 | return Info{ 66 | hits: incr.Val(), 67 | limit: r.rate, 68 | expires: time.Unix(0, 0).Add(expires), 69 | }, nil 70 | } 71 | -------------------------------------------------------------------------------- /ratelimit/ratelimit_test.go: -------------------------------------------------------------------------------- 1 | package ratelimit_test 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "testing" 7 | "time" 8 | 9 | "github.com/redis/go-redis/v9" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/dreamsofcode-io/testcontainers/ratelimit" 13 | ) 14 | 15 | func loadClient() (*redis.Client, error) { 16 | opts, err := redis.ParseURL("redis://localhost:6379") 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | return redis.NewClient(opts), nil 22 | } 23 | 24 | func TestRateLimiter(t *testing.T) { 25 | ctx := context.Background() 26 | 27 | client, err := loadClient() 28 | assert.NoError(t, err) 29 | 30 | limiter := ratelimit.New(client, 3, time.Minute) 31 | 32 | ip := "192.168.1.54" 33 | 34 | t.Run("happy path flow", func(t *testing.T) { 35 | res, err := limiter.AddAndCheckIfExceeds(ctx, net.ParseIP(ip)) 36 | assert.NoError(t, err) 37 | 38 | // Rate should not be exceeded 39 | assert.False(t, res.IsExceeded()) 40 | 41 | // Check key exists 42 | assert.Equal(t, client.Get(ctx, ip).Val(), "1") 43 | 44 | client.FlushAll(ctx) 45 | }) 46 | 47 | t.Run("should expire after three times", func(t *testing.T) { 48 | client.Set(ctx, ip, "3", 0) 49 | 50 | res, err := limiter.AddAndCheckIfExceeds(ctx, net.ParseIP(ip)) 51 | assert.NoError(t, err) 52 | 53 | // Rate should be exceeded 54 | assert.True(t, res.IsExceeded()) 55 | 56 | // Check expire time is set 57 | assert.Greater(t, client.ExpireTime(ctx, ip).Val(), time.Duration(0)) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /repository/compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | postgres: 5 | image: postgres 6 | ports: 7 | - "5432:5432" 8 | environment: 9 | POSTGRES_DB: testdb 10 | POSTGRES_USER: user 11 | POSTGRES_PASSWORD: secret 12 | -------------------------------------------------------------------------------- /repository/create.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | const createQuery string = ` 12 | INSERT INTO spell (id, name, damage, mana, created_at, updated_at) 13 | VALUES ($1, $2, $3, $4, $5, $5) 14 | ` 15 | 16 | type CreateData struct { 17 | Name string 18 | Damage int 19 | Mana uint 20 | } 21 | 22 | func (r *Spells) Create(ctx context.Context, data CreateData) (Spell, error) { 23 | id, err := uuid.NewV7() 24 | if err != nil { 25 | return Spell{}, fmt.Errorf("failed to generate uuid v7") 26 | } 27 | 28 | timestamp := time.Now().Truncate(time.Millisecond) 29 | 30 | _, err = r.db.ExecContext( 31 | ctx, 32 | createQuery, 33 | id, 34 | data.Name, 35 | data.Damage, 36 | data.Mana, 37 | timestamp, 38 | ) 39 | if err != nil { 40 | return Spell{}, fmt.Errorf("failed to exec context: %w", err) 41 | } 42 | 43 | return Spell{ 44 | ID: id, 45 | Name: data.Name, 46 | Damage: data.Damage, 47 | Mana: data.Mana, 48 | CreatedAt: timestamp, 49 | UpdatedAt: timestamp, 50 | }, nil 51 | } 52 | -------------------------------------------------------------------------------- /repository/create_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/dreamsofcode-io/testcontainers/repository" 12 | ) 13 | 14 | func TestCreate(t *testing.T) { 15 | checkParallel(t) 16 | 17 | testCases := []struct { 18 | name string 19 | setup func(ctx context.Context, db *sql.DB) error 20 | input repository.CreateData 21 | errors bool 22 | }{ 23 | { 24 | name: "happy path", 25 | input: repository.CreateData{ 26 | Name: "firebolt", 27 | Damage: 100, 28 | Mana: 10, 29 | }, 30 | errors: false, 31 | }, 32 | { 33 | name: "empty name", 34 | input: repository.CreateData{ 35 | Name: "", 36 | Damage: 200, 37 | Mana: 20, 38 | }, 39 | errors: true, 40 | }, 41 | { 42 | name: "name collision", 43 | setup: func(ctx context.Context, conn *sql.DB) error { 44 | repo := repository.New(conn) 45 | _, err := repo.Create(ctx, repository.CreateData{ 46 | Name: "icewheel", 47 | Damage: 100, 48 | Mana: 15, 49 | }) 50 | return err 51 | }, 52 | input: repository.CreateData{ 53 | Name: "icewheel", 54 | Damage: 200, 55 | Mana: 20, 56 | }, 57 | errors: true, 58 | }, 59 | } 60 | 61 | for _, tc := range testCases { 62 | t.Run(tc.name, func(t *testing.T) { 63 | checkParallel(t) 64 | ctx := context.Background() 65 | 66 | conn, err := getConnection(ctx) 67 | assert.NoError(t, err) 68 | 69 | repo := repository.New(conn) 70 | 71 | t.Cleanup(cleanup) 72 | 73 | if tc.setup != nil { 74 | tc.setup(ctx, conn) 75 | } 76 | 77 | now := time.Now().Truncate(time.Millisecond) 78 | spell, err := repo.Create(ctx, tc.input) 79 | time.Sleep(sleepTime) 80 | after := time.Now().Truncate(time.Millisecond) 81 | 82 | if tc.errors { 83 | assert.Error(t, err) 84 | 85 | // Ensure nothing exists in database 86 | row := conn.QueryRowContext( 87 | ctx, 88 | "SELECT id FROM spell WHERE name = $1 AND damage = $2 AND mana = $3", 89 | tc.input.Name, 90 | tc.input.Damage, 91 | tc.input.Mana, 92 | ) 93 | 94 | var id string 95 | err := row.Scan(&id) 96 | 97 | assert.ErrorIs(t, err, sql.ErrNoRows) 98 | 99 | return 100 | } 101 | // Assert no error 102 | assert.NoError(t, err) 103 | 104 | // Check spell properties 105 | assert.Equal(t, spell.Name, tc.input.Name) 106 | assert.Equal(t, spell.Mana, tc.input.Mana) 107 | assert.Equal(t, spell.Damage, tc.input.Damage) 108 | assert.Equal(t, spell.CreatedAt, spell.UpdatedAt) 109 | assert.GreaterOrEqual(t, spell.CreatedAt, now) 110 | assert.LessOrEqual(t, spell.CreatedAt, after) 111 | 112 | // Ensure row exists in database 113 | row := conn.QueryRowContext( 114 | ctx, 115 | "SELECT id, name, mana, damage, created_at, updated_at FROM spell WHERE id = $1", 116 | spell.ID, 117 | ) 118 | 119 | var rowSpell repository.Spell 120 | err = row.Scan( 121 | &rowSpell.ID, 122 | &rowSpell.Name, 123 | &rowSpell.Mana, 124 | &rowSpell.Damage, 125 | &rowSpell.CreatedAt, 126 | &rowSpell.UpdatedAt, 127 | ) 128 | 129 | assert.NoError(t, err) 130 | assert.Equal(t, rowSpell, spell) 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /repository/delete.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | const deleteQuery = ` 11 | DELETE FROM spell WHERE id = $1 12 | ` 13 | 14 | func (r *Spells) Delete(ctx context.Context, id uuid.UUID) error { 15 | res, err := r.db.ExecContext(ctx, deleteQuery, id) 16 | if err != nil { 17 | return fmt.Errorf("failed to execute query: %w", err) 18 | } 19 | 20 | rows, _ := res.RowsAffected() 21 | if rows == 0 { 22 | return ErrNotFound 23 | } 24 | 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /repository/delete_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/dreamsofcode-io/testcontainers/repository" 13 | ) 14 | 15 | func TestDelete(t *testing.T) { 16 | now := time.Now() 17 | checkParallel(t) 18 | 19 | testCases := []struct { 20 | name string 21 | setup func(ctx context.Context, conn *sql.DB) error 22 | input uuid.UUID 23 | wants error 24 | }{ 25 | { 26 | name: "happy path", 27 | setup: func(ctx context.Context, conn *sql.DB) error { 28 | _, err := conn.ExecContext( 29 | ctx, 30 | "INSERT INTO spell (id, name, mana, damage, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)", 31 | "c856b3a1-31fe-46ce-8823-40059d48a27c", 32 | "foo", 33 | 10, 34 | 40, 35 | now.Truncate(time.Millisecond), 36 | ) 37 | return err 38 | }, 39 | input: uuid.MustParse("c856b3a1-31fe-46ce-8823-40059d48a27c"), 40 | wants: nil, 41 | }, 42 | { 43 | name: "non existant", 44 | input: uuid.MustParse("cea24ef2-c52c-45ed-a848-a8512012a830"), 45 | wants: repository.ErrNotFound, 46 | }, 47 | } 48 | 49 | for _, tc := range testCases { 50 | t.Run(tc.name, func(t *testing.T) { 51 | checkParallel(t) 52 | 53 | ctx := context.Background() 54 | 55 | conn, err := getConnection(ctx) 56 | assert.NoError(t, err) 57 | 58 | repo := repository.New(conn) 59 | t.Cleanup(cleanup) 60 | 61 | if tc.setup != nil { 62 | assert.NoError(t, tc.setup(ctx, conn)) 63 | } 64 | 65 | err = repo.Delete(ctx, tc.input) 66 | 67 | time.Sleep(sleepTime) 68 | 69 | if tc.wants != nil { 70 | assert.ErrorIs(t, err, tc.wants) 71 | } else { 72 | assert.NoError(t, err) 73 | // ensure spell no longer exists 74 | _, err = repo.FindByID(ctx, tc.input) 75 | assert.ErrorIs(t, err, repository.ErrNotFound) 76 | } 77 | }) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /repository/main_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "log" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/testcontainers/testcontainers-go" 12 | "github.com/testcontainers/testcontainers-go/modules/postgres" 13 | "github.com/testcontainers/testcontainers-go/wait" 14 | 15 | "github.com/dreamsofcode-io/testcontainers/database" 16 | ) 17 | 18 | var connURL = "" 19 | var parrallel = false 20 | var sleepTime = time.Millisecond * 500 21 | 22 | func TestMain(m *testing.M) { 23 | ctx := context.Background() 24 | 25 | container, err := postgres.RunContainer(ctx, 26 | testcontainers.WithImage("postgres:16"), 27 | postgres.WithDatabase("testdb"), 28 | postgres.WithUsername("user"), 29 | postgres.WithPassword("foobar"), 30 | testcontainers.WithWaitStrategy( 31 | wait.ForLog("database system is ready to accept connections"). 32 | WithOccurrence(2). 33 | WithStartupTimeout(5*time.Second), 34 | ), 35 | ) 36 | if err != nil { 37 | log.Fatalln("failed to load container:", err) 38 | } 39 | 40 | connURL, err = container.ConnectionString(ctx, "sslmode=disable") 41 | if err != nil { 42 | log.Fatalln("failed to get connection string:", err) 43 | } 44 | 45 | migrate, err := database.Migrate(connURL) 46 | if err != nil { 47 | log.Fatal("failed to migrate db: ", err) 48 | } 49 | 50 | res := m.Run() 51 | 52 | migrate.Drop() 53 | 54 | os.Exit(res) 55 | } 56 | 57 | func getConnection(ctx context.Context) (*sql.DB, error) { 58 | return database.Connect(connURL) 59 | } 60 | 61 | func cleanup() { 62 | conn, err := database.Connect(connURL) 63 | if err != nil { 64 | return 65 | } 66 | 67 | conn.Exec("DELETE FROM spell") 68 | } 69 | 70 | func checkParallel(t *testing.T) { 71 | if parrallel { 72 | t.Parallel() 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /repository/migrations/000001_initial.down.sql: -------------------------------------------------------------------------------- 1 | drop table if exists spell; 2 | -------------------------------------------------------------------------------- /repository/migrations/000001_initial.up.sql: -------------------------------------------------------------------------------- 1 | create table spell ( 2 | id uuid primary key, 3 | name varchar not null, 4 | mana int not null, 5 | damage int not null, 6 | created_at timestamptz not null, 7 | updated_at timestamptz not null, 8 | CHECK (name <> '') 9 | ); 10 | 11 | create unique index on spell(name); 12 | -------------------------------------------------------------------------------- /repository/read.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | 9 | "github.com/google/uuid" 10 | ) 11 | 12 | const baseFindQuery = ` 13 | SELECT id, name, damage, mana, created_at, updated_at 14 | FROM spell 15 | ` 16 | 17 | var findByIDQuery = fmt.Sprintf( 18 | "%s WHERE id = $1", baseFindQuery, 19 | ) 20 | 21 | func (r *Spells) FindByID(ctx context.Context, id uuid.UUID) (Spell, error) { 22 | row := r.db.QueryRowContext(ctx, findByIDQuery, id) 23 | 24 | res := Spell{} 25 | err := row.Scan(&res.ID, &res.Name, &res.Damage, &res.Mana, &res.CreatedAt, &res.UpdatedAt) 26 | 27 | if errors.Is(err, sql.ErrNoRows) { 28 | return Spell{}, ErrNotFound 29 | } else if err != nil { 30 | return Spell{}, ErrNotFound 31 | } 32 | 33 | return res, nil 34 | } 35 | 36 | func (r *Spells) FindAll(ctx context.Context) ([]Spell, error) { 37 | var spells []Spell 38 | 39 | rows, err := r.db.QueryContext(ctx, baseFindQuery) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to query: %w", err) 42 | } 43 | 44 | for rows.Next() { 45 | spell := Spell{} 46 | err = rows.Scan( 47 | &spell.ID, 48 | &spell.Name, 49 | &spell.Damage, 50 | &spell.Mana, 51 | &spell.CreatedAt, 52 | &spell.UpdatedAt, 53 | ) 54 | 55 | if err != nil { 56 | return nil, fmt.Errorf("failed to scan: %w", err) 57 | } 58 | 59 | spells = append(spells, spell) 60 | } 61 | 62 | return spells, nil 63 | } 64 | -------------------------------------------------------------------------------- /repository/read_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/dreamsofcode-io/testcontainers/repository" 13 | ) 14 | 15 | func TestFindByID(t *testing.T) { 16 | checkParallel(t) 17 | 18 | type want struct { 19 | err error 20 | spell repository.Spell 21 | } 22 | 23 | now := time.Now() 24 | 25 | testCases := []struct { 26 | name string 27 | setup func(ctx context.Context, conn *sql.DB) error 28 | input uuid.UUID 29 | wants want 30 | }{ 31 | { 32 | name: "happy path", 33 | setup: func(ctx context.Context, conn *sql.DB) error { 34 | _, err := conn.ExecContext( 35 | ctx, 36 | "INSERT INTO spell (id, name, mana, damage, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)", 37 | "c856b3a1-31fe-46ce-8823-40059d48a27c", 38 | "foo", 39 | 10, 40 | 40, 41 | now.Truncate(time.Millisecond), 42 | ) 43 | return err 44 | }, 45 | input: uuid.MustParse("c856b3a1-31fe-46ce-8823-40059d48a27c"), 46 | wants: want{ 47 | err: nil, 48 | spell: repository.Spell{ 49 | ID: uuid.MustParse("c856b3a1-31fe-46ce-8823-40059d48a27c"), 50 | Name: "foo", 51 | Mana: 10, 52 | Damage: 40, 53 | CreatedAt: now.Truncate(time.Millisecond), 54 | UpdatedAt: now.Truncate(time.Millisecond), 55 | }, 56 | }, 57 | }, 58 | { 59 | name: "non existant", 60 | input: uuid.MustParse("cea24ef2-c52c-45ed-a848-a8512012a830"), 61 | wants: want{ 62 | err: repository.ErrNotFound, 63 | }, 64 | }, 65 | } 66 | 67 | for _, tc := range testCases { 68 | t.Run(tc.name, func(t *testing.T) { 69 | checkParallel(t) 70 | ctx := context.Background() 71 | 72 | conn, err := getConnection(ctx) 73 | assert.NoError(t, err) 74 | 75 | repo := repository.New(conn) 76 | t.Cleanup(cleanup) 77 | 78 | if tc.setup != nil { 79 | assert.NoError(t, tc.setup(ctx, conn)) 80 | } 81 | 82 | spell, err := repo.FindByID(ctx, tc.input) 83 | time.Sleep(sleepTime) 84 | 85 | if tc.wants.err != nil { 86 | assert.ErrorIs(t, err, tc.wants.err) 87 | } else { 88 | assert.NoError(t, err) 89 | } 90 | 91 | assert.Equal(t, spell, tc.wants.spell) 92 | }) 93 | } 94 | } 95 | 96 | func TestFindAll(t *testing.T) { 97 | checkParallel(t) 98 | 99 | now := time.Now().Truncate(time.Millisecond) 100 | 101 | testCases := []struct { 102 | name string 103 | setup func(ctx context.Context, conn *sql.DB) error 104 | wants []repository.Spell 105 | }{ 106 | { 107 | name: "empty repository", 108 | }, 109 | { 110 | name: "some spells repository", 111 | setup: func(ctx context.Context, conn *sql.DB) error { 112 | spells := []repository.Spell{ 113 | { 114 | ID: uuid.MustParse("f3a88af4-bfb0-4981-b2c1-da45752148c9"), 115 | Name: "firebolt", 116 | Mana: 10, 117 | Damage: 200, 118 | CreatedAt: now, 119 | UpdatedAt: now, 120 | }, 121 | { 122 | ID: uuid.MustParse("1a738d7d-1b7d-429d-927b-f16547570625"), 123 | Name: "magmalake", 124 | Mana: 90, 125 | Damage: 500, 126 | CreatedAt: now.Add(-time.Hour), 127 | UpdatedAt: now, 128 | }, 129 | } 130 | 131 | for _, spell := range spells { 132 | _, err := conn.ExecContext( 133 | ctx, 134 | "INSERT INTO spell (id, name, mana, damage, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6)", 135 | spell.ID, 136 | spell.Name, 137 | spell.Mana, 138 | spell.Damage, 139 | spell.CreatedAt, 140 | spell.UpdatedAt, 141 | ) 142 | 143 | if err != nil { 144 | return err 145 | } 146 | } 147 | 148 | return nil 149 | }, 150 | wants: []repository.Spell{ 151 | { 152 | ID: uuid.MustParse("f3a88af4-bfb0-4981-b2c1-da45752148c9"), 153 | Name: "firebolt", 154 | Mana: 10, 155 | Damage: 200, 156 | CreatedAt: now, 157 | UpdatedAt: now, 158 | }, 159 | { 160 | ID: uuid.MustParse("1a738d7d-1b7d-429d-927b-f16547570625"), 161 | Name: "magmalake", 162 | Mana: 90, 163 | Damage: 500, 164 | CreatedAt: now.Add(-time.Hour), 165 | UpdatedAt: now, 166 | }, 167 | }, 168 | }, 169 | } 170 | 171 | for _, tc := range testCases { 172 | t.Run(tc.name, func(t *testing.T) { 173 | checkParallel(t) 174 | 175 | ctx := context.Background() 176 | conn, err := getConnection(ctx) 177 | assert.NoError(t, err) 178 | 179 | repo := repository.New(conn) 180 | t.Cleanup(cleanup) 181 | 182 | if tc.setup != nil { 183 | assert.NoError(t, tc.setup(ctx, conn)) 184 | } 185 | 186 | time.Sleep(sleepTime) 187 | 188 | spells, err := repo.FindAll(ctx) 189 | assert.NoError(t, err) 190 | 191 | assert.Equal(t, spells, tc.wants) 192 | }) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /repository/types.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "time" 7 | 8 | "github.com/google/uuid" 9 | ) 10 | 11 | type Spells struct { 12 | db *sql.DB 13 | } 14 | 15 | func New(db *sql.DB) *Spells { 16 | return &Spells{ 17 | db: db, 18 | } 19 | } 20 | 21 | type Spell struct { 22 | ID uuid.UUID `json:"id"` 23 | Name string `json:"name"` 24 | Damage int `json:"damage"` 25 | Mana uint `json:"mana"` 26 | CreatedAt time.Time `json:"created_at"` 27 | UpdatedAt time.Time `json:"updated_at"` 28 | } 29 | 30 | var ErrNotFound = errors.New("spell not found for id") 31 | -------------------------------------------------------------------------------- /repository/update.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | type UpdateData CreateData 14 | 15 | const updateQuery string = ` 16 | UPDATE spell SET name = $1, damage = $2, mana = $3, updated_at = $4 17 | WHERE id = $5 18 | RETURNING id, name, damage, mana, created_at, updated_at 19 | ` 20 | 21 | func (r *Spells) Update(ctx context.Context, id uuid.UUID, updateData UpdateData) (Spell, error) { 22 | now := time.Now() 23 | 24 | res := r.db.QueryRowContext( 25 | ctx, 26 | updateQuery, 27 | updateData.Name, 28 | updateData.Damage, 29 | updateData.Mana, 30 | now, 31 | id, 32 | ) 33 | 34 | spell := Spell{} 35 | 36 | err := res.Scan( 37 | &spell.ID, 38 | &spell.Name, 39 | &spell.Damage, 40 | &spell.Mana, 41 | &spell.CreatedAt, 42 | &spell.UpdatedAt, 43 | ) 44 | if errors.Is(err, sql.ErrNoRows) { 45 | return Spell{}, ErrNotFound 46 | } 47 | if err != nil { 48 | return Spell{}, fmt.Errorf("failed to scan row: %w", err) 49 | } 50 | 51 | return spell, nil 52 | } 53 | -------------------------------------------------------------------------------- /repository/update_test.go: -------------------------------------------------------------------------------- 1 | package repository_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | "time" 8 | 9 | "github.com/google/uuid" 10 | "github.com/stretchr/testify/assert" 11 | 12 | "github.com/dreamsofcode-io/testcontainers/repository" 13 | ) 14 | 15 | func TestUpdate(t *testing.T) { 16 | checkParallel(t) 17 | 18 | now := time.Now() 19 | 20 | setup := func(ctx context.Context, conn *sql.DB) error { 21 | _, err := conn.ExecContext( 22 | ctx, 23 | "INSERT INTO spell (id, name, mana, damage, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)", 24 | "c856b3a1-31fe-46ce-8823-40059d48a27c", 25 | "foo", 26 | 10, 27 | 40, 28 | now.Truncate(time.Millisecond), 29 | ) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | _, err = conn.ExecContext( 35 | ctx, 36 | "INSERT INTO spell (id, name, mana, damage, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)", 37 | "41fca60e-9b26-4196-9b18-d4a79b350523kd", 38 | "bar", 39 | 70, 40 | 800, 41 | now.Truncate(time.Millisecond), 42 | ) 43 | return err 44 | } 45 | 46 | type input struct { 47 | data repository.UpdateData 48 | id uuid.UUID 49 | } 50 | 51 | type want struct { 52 | spell repository.Spell 53 | err error 54 | } 55 | 56 | testCases := []struct { 57 | name string 58 | setup func(ctx context.Context, db *sql.DB) error 59 | input input 60 | wants want 61 | }{ 62 | { 63 | name: "happy path", 64 | setup: setup, 65 | input: input{ 66 | id: uuid.MustParse("c856b3a1-31fe-46ce-8823-40059d48a27c"), 67 | data: repository.UpdateData{ 68 | Name: "firebolt", 69 | Damage: 100, 70 | Mana: 10, 71 | }, 72 | }, 73 | wants: want{ 74 | spell: repository.Spell{ 75 | ID: uuid.MustParse("c856b3a1-31fe-46ce-8823-40059d48a27c"), 76 | Name: "firebolt", 77 | Damage: 100, 78 | Mana: 10, 79 | }, 80 | }, 81 | }, 82 | { 83 | name: "missing spell", 84 | setup: setup, 85 | input: input{ 86 | id: uuid.MustParse("0980dd52-bcc2-4019-9710-7816fc8c50bf"), 87 | data: repository.UpdateData{ 88 | Name: "firebolt", 89 | Damage: 100, 90 | Mana: 10, 91 | }, 92 | }, 93 | wants: want{ 94 | err: repository.ErrNotFound, 95 | }, 96 | }, 97 | } 98 | 99 | for _, tc := range testCases { 100 | t.Run(tc.name, func(t *testing.T) { 101 | checkParallel(t) 102 | 103 | ctx := context.Background() 104 | conn, err := getConnection(ctx) 105 | 106 | assert.NoError(t, err) 107 | 108 | repo := repository.New(conn) 109 | 110 | t.Cleanup(cleanup) 111 | 112 | if tc.setup != nil { 113 | tc.setup(ctx, conn) 114 | } 115 | 116 | time.Sleep(sleepTime) 117 | 118 | spell, err := repo.Update(ctx, tc.input.id, tc.input.data) 119 | 120 | if tc.wants.err != nil { 121 | assert.ErrorIs(t, err, tc.wants.err) 122 | return 123 | } else { 124 | // Assert no error 125 | assert.NoError(t, err) 126 | } 127 | 128 | assert.Equal(t, tc.wants.spell.Name, spell.Name) 129 | assert.Equal(t, tc.wants.spell.Damage, spell.Damage) 130 | assert.Equal(t, tc.wants.spell.Mana, spell.Mana) 131 | 132 | assert.Greater(t, spell.UpdatedAt, spell.CreatedAt) 133 | 134 | repoSpell, err := repo.FindByID(ctx, spell.ID) 135 | assert.NoError(t, err) 136 | 137 | assert.Equal(t, spell, repoSpell) 138 | }) 139 | } 140 | } 141 | --------------------------------------------------------------------------------