diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 36e006f..62a7924 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,15 +1,29 @@ version: 2 updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + groups: + github-actions: + patterns: + - "*" + labels: + - "dependencies" + - "github-actions" + + # Maintain dependencies for Go modules - package-ecosystem: "gomod" directory: "/" schedule: interval: "weekly" - open-pull-requests-limit: 1 + day: "monday" groups: go-dependencies: patterns: - "*" - update-types: - - "major" - - "minor" - - "patch" + labels: + - "dependencies" + - "go" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f47a223 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,82 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: '1.24' + cache: true + cache-dependency-path: go.sum + + - name: Install golangci-lint + uses: golangci/golangci-lint-action@8564da7cb3c6866ed1da648ca8f00a258ef0c802 # v6.5.2 + with: + version: v2.11.4 + skip-install: true + + - name: Run linter + run: make lint + + vulncheck: + name: Vulnerability Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: '1.24' + cache: true + cache-dependency-path: go.sum + + - name: Run vulnerability check + run: make vulncheck + + test: + name: Test + runs-on: ubuntu-latest + permissions: + contents: read + actions: write + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version: '1.24' + cache: true + cache-dependency-path: go.sum + + - name: Run tests + run: make test + + - name: Upload coverage to artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: coverage-report + path: | + coverage.out + coverage.html + + - name: Display coverage summary + run: make coverage-func diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 85ff8fa..490cab2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,22 +15,22 @@ jobs: contents: write steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@f7ce87c1d6bead3e36075b2ce75da1f6cc28aaca # v3.9.0 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0 with: context: . push: true @@ -49,12 +49,12 @@ jobs: done - name: Upload Release Assets - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 with: files: built/* - + - name: Update repo description - uses: peter-evans/dockerhub-description@v4 + uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4.0.2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..048b405 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,23 @@ +version: "2" + +run: + timeout: 5m + tests: true + modules-download-mode: readonly + +linters: + enable: + - govet + - staticcheck + - errcheck + - ineffassign + - unused + - misspell + - gocritic + - gosec + - revive + +formatters: + enable: + - gofmt + - goimports diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e918f2d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Morteza Pourseif + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f4c46d3 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +.PHONY: lint fix clean vulncheck ci deps deps-update run help + +# Variables +BINARY_NAME=kmir +GO=go +GOFLAGS=-v +LINTER=golangci-lint + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +build: ## Build the binary + @echo "Building $(BINARY_NAME)..." + $(GO) build $(GOFLAGS) -o $(BINARY_NAME) . + +test: ## Run tests + @echo "Running tests..." + $(GO) test -v -race -coverprofile=coverage.out -covermode=atomic ./... + +test-short: ## Run short tests only + @echo "Running short tests..." + $(GO) test -v -short ./... + +lint: ## Run linters + @echo "Running linters..." + $(LINTER) run --timeout 5m ./...; \ + +fix: ## Auto-fix linting issues + @echo "Fixing linting issues..." + $(LINTER) run --fix --timeout 5m ./...; \ + +coverage: ## Generate coverage report + @echo "Generating coverage report..." + $(GO) test -coverprofile=coverage.out -covermode=atomic ./... + $(GO) tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +coverage-func: ## Show coverage by function + @echo "Coverage by function:" + $(GO) test -coverprofile=coverage.out -covermode=atomic ./... + $(GO) tool cover -func=coverage.out + +clean: ## Clean build artifacts + @echo "Cleaning..." + rm -f $(BINARY_NAME) + rm -f coverage.out coverage.html + rm -f *.test + +vulncheck: ## Check for security vulnerabilities + @echo "Checking for vulnerabilities..." + $(GO) tool govulncheck ./... + +ci: lint vulncheck test ## Run CI checks (lint + vulncheck + test) + +deps: ## Download dependencies + @echo "Downloading dependencies..." + $(GO) mod download + $(GO) mod tidy + +deps-update: ## Update dependencies + @echo "Updating dependencies..." + $(GO) get -u ./... + $(GO) mod tidy + +run: build ## Build and run the binary + @echo "Running $(BINARY_NAME)..." + ./$(BINARY_NAME) diff --git a/README.md b/README.md index b68afe4..9a4284c 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,64 @@ The positional arguments, which specify the topic information, can be in any of ```sh kmir --source-brokers=localhost:9092 --sink-brokers=localhost:9093 --client-id=my-client --kafka-version=2.7.0 topic1 topic2@-1 topic3@0:100,1:200 ``` + +## Development + +### Prerequisites + +- Go 1.24 or later +- golangci-lint v2.11.4 or later + +### Setup + +```bash +# Clone the repository +git clone https://github.com/mortezaPRK/kmir.git +cd kmir + +# Install dependencies +make deps +``` + +### Building + +```bash +# Build the binary +make build +``` + +### Testing + +```bash +# Run all tests +make test + +# Run short tests only +make test-short + +# Generate coverage report +make coverage + +# Run all CI checks +make ci +``` + +### Available Make Commands + +| Command | Description | +|---------|-------------| +| `make build` | Build the binary | +| `make test` | Run tests with race detection | +| `make test-short` | Run short tests only | +| `make lint` | Run linters (golangci-lint) | +| `make fix` | Auto-fix formatting issues | +| `make coverage` | Generate coverage report | +| `make clean` | Clean build artifacts | +| `make ci` | Run full CI checks (lint, vulncheck, test) | +| `make deps` | Download dependencies | +| `make deps-update` | Update dependencies | +| `make vulncheck` | Check for security vulnerabilities | + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..550d0af --- /dev/null +++ b/config_test.go @@ -0,0 +1,271 @@ +package main + +import ( + "strings" + "testing" +) + +func TestParseTopicOffset(t *testing.T) { + tests := []struct { + name string + value string + want TopicOption + wantErr bool + }{ + { + name: "single offset - positive", + value: "100", + want: TopicOption{Offset: 100}, + wantErr: false, + }, + { + name: "single offset - zero", + value: "0", + want: TopicOption{Offset: 0}, + wantErr: false, + }, + { + name: "single offset - negative", + value: "-1", + want: TopicOption{Offset: -1}, + wantErr: false, + }, + { + name: "single partition offset (comma required for multiple)", + value: "0:100", + want: TopicOption{}, + wantErr: true, + }, + { + name: "multiple partition offsets", + value: "0:100,1:200,2:300", + want: TopicOption{ + PerPartitionOffset: map[int32]int64{0: 100, 1: 200, 2: 300}, + }, + wantErr: false, + }, + { + name: "invalid offset - non-numeric", + value: "abc", + want: TopicOption{}, + wantErr: true, + }, + { + name: "invalid partition offset - missing colon", + value: "0-100", + want: TopicOption{ + PerPartitionOffset: map[int32]int64{}, + }, + wantErr: true, + }, + { + name: "invalid partition offset - invalid partition", + value: "abc:100", + want: TopicOption{ + PerPartitionOffset: map[int32]int64{}, + }, + wantErr: true, + }, + { + name: "invalid partition offset - invalid offset", + value: "0:abc", + want: TopicOption{ + PerPartitionOffset: map[int32]int64{}, + }, + wantErr: true, + }, + { + name: "invalid partition offset - three parts", + value: "0:100:200", + want: TopicOption{ + PerPartitionOffset: map[int32]int64{}, + }, + wantErr: true, + }, + { + name: "partition offsets with negative values", + value: "0:-1,1:0", + want: TopicOption{ + PerPartitionOffset: map[int32]int64{0: -1, 1: 0}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseTopicOffset(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("parseTopicOffset() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr { + if got.Offset != tt.want.Offset { + t.Errorf("parseTopicOffset() Offset = %v, want %v", got.Offset, tt.want.Offset) + } + if len(got.PerPartitionOffset) != len(tt.want.PerPartitionOffset) { + t.Errorf("parseTopicOffset() PerPartitionOffset length = %v, want %v", len(got.PerPartitionOffset), len(tt.want.PerPartitionOffset)) + } + for k, v := range tt.want.PerPartitionOffset { + if got.PerPartitionOffset[k] != v { + t.Errorf("parseTopicOffset() PerPartitionOffset[%d] = %v, want %v", k, got.PerPartitionOffset[k], v) + } + } + } + }) + } +} + +func TestToTopic(t *testing.T) { + tests := []struct { + name string + value string + want string + wantOpt TopicOption + wantErr bool + }{ + { + name: "topic only", + value: "my-topic", + want: "my-topic", + wantOpt: TopicOption{Offset: -1}, + wantErr: false, + }, + { + name: "topic with offset", + value: "my-topic@100", + want: "my-topic", + wantOpt: TopicOption{Offset: 100}, + wantErr: false, + }, + { + name: "topic with partition offsets", + value: "my-topic@0:100,1:200", + want: "my-topic", + wantOpt: TopicOption{PerPartitionOffset: map[int32]int64{0: 100, 1: 200}}, + wantErr: false, + }, + { + name: "topic with dash", + value: "my-topic-123", + want: "my-topic-123", + wantOpt: TopicOption{Offset: -1}, + wantErr: false, + }, + { + name: "topic with offset zero", + value: "my-topic@0", + want: "my-topic", + wantOpt: TopicOption{Offset: 0}, + wantErr: false, + }, + { + name: "invalid - multiple at signs", + value: "my-topic@100@200", + want: "my-topic", + wantOpt: TopicOption{}, + wantErr: true, + }, + { + name: "empty topic name with offset (valid by parsing logic)", + value: "@100", + want: "", + wantOpt: TopicOption{Offset: 100}, + wantErr: false, + }, + { + name: "invalid - bad offset format", + value: "my-topic@abc", + want: "my-topic", + wantOpt: TopicOption{}, + wantErr: true, + }, + { + name: "topic with underscores", + value: "my_topic_123", + want: "my_topic_123", + wantOpt: TopicOption{Offset: -1}, + wantErr: false, + }, + { + name: "topic with dots", + value: "my.topic.name", + want: "my.topic.name", + wantOpt: TopicOption{Offset: -1}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotOpt, err := toTopic(tt.value) + if (err != nil) != tt.wantErr { + t.Errorf("toTopic() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("toTopic() got = %v, want %v", got, tt.want) + } + if !tt.wantErr { + if gotOpt.Offset != tt.wantOpt.Offset { + t.Errorf("toTopic() Offset = %v, want %v", gotOpt.Offset, tt.wantOpt.Offset) + } + if len(gotOpt.PerPartitionOffset) != len(tt.wantOpt.PerPartitionOffset) { + t.Errorf("toTopic() PerPartitionOffset length = %v, want %v", len(gotOpt.PerPartitionOffset), len(tt.wantOpt.PerPartitionOffset)) + } + for k, v := range tt.wantOpt.PerPartitionOffset { + if gotOpt.PerPartitionOffset[k] != v { + t.Errorf("toTopic() PerPartitionOffset[%d] = %v, want %v", k, gotOpt.PerPartitionOffset[k], v) + } + } + } + }) + } +} + +func TestToTopic_LongTopicNames(t *testing.T) { + longTopic := strings.Repeat("a", 249) + "@100" + got, gotOpt, err := toTopic(longTopic) + + if err != nil { + t.Errorf("toTopic() unexpected error: %v", err) + } + if len(got) != 249 { + t.Errorf("toTopic() got topic length = %v, want 249", len(got)) + } + if gotOpt.Offset != 100 { + t.Errorf("toTopic() Offset = %v, want 100", gotOpt.Offset) + } +} + +func BenchmarkParseTopicOffset(b *testing.B) { + tests := []string{ + "100", + "0:100,1:200,2:300", + "0:100,1:200,2:300,3:400,4:500,5:600,6:700,7:800", + } + + for _, tt := range tests { + b.Run(tt, func(b *testing.B) { + for i := 0; i < b.N; i++ { + parseTopicOffset(tt) + } + }) + } +} + +func BenchmarkToTopic(b *testing.B) { + tests := []string{ + "my-topic", + "my-topic@100", + "my-topic@0:100,1:200,2:300", + } + + for _, tt := range tests { + b.Run(tt, func(b *testing.B) { + for i := 0; i < b.N; i++ { + toTopic(tt) + } + }) + } +} diff --git a/go.mod b/go.mod index 65318f2..b9e1fce 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/mortezaPRK/kmir go 1.24.0 +tool golang.org/x/vuln/cmd/govulncheck + require ( github.com/jessevdk/go-flags v1.6.1 github.com/twmb/franz-go v1.20.7 @@ -13,5 +15,10 @@ require ( github.com/pierrec/lz4/v4 v4.1.25 // indirect github.com/twmb/franz-go/pkg/kmsg v1.12.0 // indirect golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.22.0 // indirect + golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 // indirect + golang.org/x/tools v0.29.0 // indirect + golang.org/x/vuln v1.1.4 // indirect ) diff --git a/go.sum b/go.sum index f39d49c..c9c8442 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786 h1:rcv+Ippz6RAtvaGgKxc+8FQIpxHgsF+HBzPyYL2cyVU= +github.com/google/go-cmdtest v0.4.1-0.20220921163831-55ab3332a786/go.mod h1:apVn/GCasLZUVpAJ6oWAuyP7Ne7CEsQbTnc0plM3m+o= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/jessevdk/go-flags v1.6.1 h1:Cvu5U8UGrLay1rZfv/zP7iLpSHGUZ/Ou68T0iX1bBK4= github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -12,5 +18,15 @@ github.com/twmb/franz-go/pkg/kmsg v1.12.0 h1:CbatD7ers1KzDNgJqPbKOq0Bz/WLBdsTH75 github.com/twmb/franz-go/pkg/kmsg v1.12.0/go.mod h1:+DPt4NC8RmI6hqb8G09+3giKObE6uD2Eya6CfqBpeJY= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +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/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7 h1:FemxDzfMUcK2f3YY4H+05K9CDzbSVr2+q/JKN45pey0= +golang.org/x/telemetry v0.0.0-20240522233618-39ace7a40ae7/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= +golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= +golang.org/x/vuln v1.1.4 h1:Ju8QsuyhX3Hk8ma3CesTbO8vfJD9EvUBgHvkxHBzj0I= +golang.org/x/vuln v1.1.4/go.mod h1:F+45wmU18ym/ca5PLTPLsSzr2KppzswxPP603ldA67s= diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..57218b7 --- /dev/null +++ b/main_test.go @@ -0,0 +1,104 @@ +package main + +import ( + "testing" + "time" +) + +func TestWait_Success(t *testing.T) { + started := time.Now() + err := wait(100*time.Millisecond, func() bool { + return true + }) + + if err != nil { + t.Errorf("wait() returned error: %v", err) + } + + elapsed := time.Since(started) + if elapsed > 50*time.Millisecond { + t.Errorf("wait() took too long: %v, expected < 50ms", elapsed) + } +} + +func TestWait_Timeout(t *testing.T) { + started := time.Now() + err := wait(100*time.Millisecond, func() bool { + return false + }) + + if err == nil { + t.Error("wait() expected timeout error, got nil") + } + + elapsed := time.Since(started) + // With 1 second sleep intervals and 100ms timeout, it will check once then timeout + // The actual time will be slightly over 100ms due to the first sleep + if elapsed < 100*time.Millisecond { + t.Logf("wait() returned quickly: %v (expected >= 100ms)", elapsed) + } + // Allow up to 1.5 seconds since the sleep interval is 1 second + if elapsed > 1500*time.Millisecond { + t.Errorf("wait() took too long: %v, expected < 1500ms", elapsed) + } +} + +func TestWait_DelayedSuccess(t *testing.T) { + count := 0 + started := time.Now() + + err := wait(200*time.Millisecond, func() bool { + count++ + return count >= 2 + }) + + if err != nil { + t.Errorf("wait() returned error: %v", err) + } + + if count < 2 { + t.Errorf("wait() condition not checked enough times, got %d", count) + } + + // Should have taken at least one sleep cycle + elapsed := time.Since(started) + if elapsed < time.Second { + t.Logf("wait() took %v for %d checks", elapsed, count) + } +} + +func TestWait_ImmediateSuccess(t *testing.T) { + callCount := 0 + err := wait(5*time.Second, func() bool { + callCount++ + return true + }) + + if err != nil { + t.Errorf("wait() returned error: %v", err) + } + + if callCount != 1 { + t.Errorf("wait() called function %d times, expected 1", callCount) + } +} + +func TestWait_NegativeTimeout(t *testing.T) { + err := wait(-1*time.Second, func() bool { + return false + }) + + // Negative timeout should cause immediate timeout + if err == nil { + t.Error("wait() with negative timeout expected error, got nil") + } +} + +func BenchmarkWait(b *testing.B) { + fn := func() bool { return true } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + wait(time.Second, fn) + } +} diff --git a/types_test.go b/types_test.go new file mode 100644 index 0000000..4c6995d --- /dev/null +++ b/types_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "testing" +) + +func TestTopicOption_OffsetOf(t *testing.T) { + tests := []struct { + name string + opt TopicOption + partition int32 + want int64 + wantOk bool + }{ + { + name: "returns offset when PerPartitionOffset is nil", + opt: TopicOption{Offset: 100}, + partition: 0, + want: 100, + wantOk: true, + }, + { + name: "returns offset when partition exists in PerPartitionOffset", + opt: TopicOption{ + Offset: 0, + PerPartitionOffset: map[int32]int64{0: 50, 1: 100}, + }, + partition: 1, + want: 100, + wantOk: true, + }, + { + name: "returns not found when partition doesn't exist in PerPartitionOffset", + opt: TopicOption{ + Offset: 0, + PerPartitionOffset: map[int32]int64{0: 50}, + }, + partition: 1, + want: 0, + wantOk: false, + }, + { + name: "returns zero offset with empty PerPartitionOffset", + opt: TopicOption{PerPartitionOffset: map[int32]int64{}}, + partition: 0, + want: 0, + wantOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, gotOk := tt.opt.OffsetOf(tt.partition) + if got != tt.want { + t.Errorf("OffsetOf() got = %v, want %v", got, tt.want) + } + if gotOk != tt.wantOk { + t.Errorf("OffsetOf() gotOk = %v, wantOk %v", gotOk, tt.wantOk) + } + }) + } +} + +func TestTopicOption_OffsetOf_MultiplePartitions(t *testing.T) { + opt := TopicOption{ + PerPartitionOffset: map[int32]int64{ + 0: 100, + 1: 200, + 2: 300, + }, + } + + tests := []struct { + partition int32 + want int64 + wantOk bool + }{ + {0, 100, true}, + {1, 200, true}, + {2, 300, true}, + {3, 0, false}, + } + + for _, tt := range tests { + t.Run("", func(t *testing.T) { + got, gotOk := opt.OffsetOf(tt.partition) + if got != tt.want { + t.Errorf("OffsetOf() partition %d got = %v, want %v", tt.partition, got, tt.want) + } + if gotOk != tt.wantOk { + t.Errorf("OffsetOf() partition %d gotOk = %v, wantOk %v", tt.partition, gotOk, tt.wantOk) + } + }) + } +} + +func TestTopicOption_OffsetOf_NegativeOffset(t *testing.T) { + opt := TopicOption{ + Offset: -1, // Represents "latest" or "end" + } + + got, gotOk := opt.OffsetOf(0) + if got != -1 { + t.Errorf("OffsetOf() got = %v, want -1", got) + } + if !gotOk { + t.Errorf("OffsetOf() gotOk = false, want true") + } +}