Skip to content

Commit 6abce32

Browse files
fix: address PR review feedback and add missing test coverage
1 parent 4dbae93 commit 6abce32

File tree

7 files changed

+257
-4
lines changed

7 files changed

+257
-4
lines changed

cmd/openapi/commands/cmdutil/cmdutil.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ func StdinIsPiped() bool {
2626
return (fi.Mode() & os.ModeCharDevice) == 0
2727
}
2828

29-
// InputFileFromArgs returns the input file from args, or "-" if stdin should
30-
// be used. It extracts the first positional arg or detects piped stdin.
29+
// InputFileFromArgs returns the first positional arg as the input file path,
30+
// or the stdin indicator "-" when no args are provided.
3131
func InputFileFromArgs(args []string) string {
3232
if len(args) > 0 {
3333
return args[0]
@@ -54,7 +54,7 @@ func StdinOrFileArgs(minArgs, maxArgs int) cobra.PositionalArgs {
5454

5555
// ArgAt returns args[index] if it exists, or defaultVal otherwise.
5656
func ArgAt(args []string, index int, defaultVal string) string {
57-
if index < len(args) {
57+
if index >= 0 && index < len(args) {
5858
return args[index]
5959
}
6060
return defaultVal

cmd/openapi/commands/cmdutil/cmdutil_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ func TestArgAt(t *testing.T) {
6868
{name: "returns default for empty args", args: []string{}, index: 0, defaultVal: "default", expected: "default"},
6969
{name: "returns default for nil args", args: nil, index: 0, defaultVal: "default", expected: "default"},
7070
{name: "returns empty default", args: []string{}, index: 0, defaultVal: "", expected: ""},
71+
{name: "returns default for negative index", args: []string{"a", "b"}, index: -1, defaultVal: "default", expected: "default"},
72+
{name: "returns default for large negative index", args: []string{"a"}, index: -100, defaultVal: "fallback", expected: "fallback"},
7173
}
7274

7375
for _, tt := range tests {
@@ -101,4 +103,27 @@ func TestStdinOrFileArgs(t *testing.T) {
101103
assert.Error(t, err)
102104
assert.Contains(t, err.Error(), "accepts at most 2 arg(s)")
103105
})
106+
107+
t.Run("unbounded max accepts many args", func(t *testing.T) {
108+
t.Parallel()
109+
unbounded := StdinOrFileArgs(1, -1)
110+
err := unbounded(nil, []string{"a", "b", "c", "d", "e"})
111+
assert.NoError(t, err, "negative maxArgs should allow unlimited args")
112+
})
113+
114+
t.Run("zero minArgs accepts any arg count", func(t *testing.T) {
115+
t.Parallel()
116+
noMin := StdinOrFileArgs(0, 2)
117+
err := noMin(nil, []string{})
118+
assert.NoError(t, err, "zero minArgs should accept empty args")
119+
})
120+
121+
t.Run("error message includes min arg count", func(t *testing.T) {
122+
t.Parallel()
123+
min3 := StdinOrFileArgs(3, 5)
124+
err := min3(nil, []string{"a", "b"})
125+
if err != nil {
126+
assert.Contains(t, err.Error(), "requires at least 3 arg(s)")
127+
}
128+
})
104129
}

cmd/openapi/commands/openapi/shared_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package openapi
22

33
import (
4+
"bytes"
5+
"os"
46
"testing"
57

68
"github.com/stretchr/testify/assert"
@@ -140,6 +142,49 @@ func TestOutputFileFromArgs(t *testing.T) {
140142
}
141143
}
142144

145+
func TestOpenAPIProcessor_IOAccessors(t *testing.T) {
146+
t.Parallel()
147+
148+
t.Run("stdin falls back to os.Stdin when nil", func(t *testing.T) {
149+
t.Parallel()
150+
p := &OpenAPIProcessor{}
151+
assert.Equal(t, os.Stdin, p.stdin(), "nil Stdin should fall back to os.Stdin")
152+
})
153+
154+
t.Run("stdout falls back to os.Stdout when nil", func(t *testing.T) {
155+
t.Parallel()
156+
p := &OpenAPIProcessor{}
157+
assert.Equal(t, os.Stdout, p.stdout(), "nil Stdout should fall back to os.Stdout")
158+
})
159+
160+
t.Run("stderr falls back to os.Stderr when nil", func(t *testing.T) {
161+
t.Parallel()
162+
p := &OpenAPIProcessor{}
163+
assert.Equal(t, os.Stderr, p.stderr(), "nil Stderr should fall back to os.Stderr")
164+
})
165+
166+
t.Run("stdin uses override when set", func(t *testing.T) {
167+
t.Parallel()
168+
custom := &bytes.Buffer{}
169+
p := &OpenAPIProcessor{Stdin: custom}
170+
assert.Equal(t, custom, p.stdin(), "should use custom Stdin")
171+
})
172+
173+
t.Run("stdout uses override when set", func(t *testing.T) {
174+
t.Parallel()
175+
custom := &bytes.Buffer{}
176+
p := &OpenAPIProcessor{Stdout: custom}
177+
assert.Equal(t, custom, p.stdout(), "should use custom Stdout")
178+
})
179+
180+
t.Run("stderr uses override when set", func(t *testing.T) {
181+
t.Parallel()
182+
custom := &bytes.Buffer{}
183+
p := &OpenAPIProcessor{Stderr: custom}
184+
assert.Equal(t, custom, p.stderr(), "should use custom Stderr")
185+
})
186+
}
187+
143188
func TestStdinOrFileArgs(t *testing.T) {
144189
t.Parallel()
145190

internal/utils/string_builder_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,70 @@
11
package utils
22

33
import (
4+
"fmt"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
78
)
89

10+
func TestAnyToString_Success(t *testing.T) {
11+
t.Parallel()
12+
13+
tests := []struct {
14+
name string
15+
input any
16+
expected string
17+
}{
18+
{name: "string value", input: "hello", expected: "hello"},
19+
{name: "empty string", input: "", expected: ""},
20+
{name: "int value", input: 42, expected: "42"},
21+
{name: "int zero", input: 0, expected: "0"},
22+
{name: "int negative", input: -7, expected: "-7"},
23+
{name: "int64 value", input: int64(9999999999), expected: "9999999999"},
24+
{name: "int32 value", input: int32(123), expected: "123"},
25+
{name: "float64 integer", input: float64(3), expected: "3"},
26+
{name: "float64 decimal", input: 3.14, expected: "3.14"},
27+
{name: "float64 large", input: 1e18, expected: "1E+18"},
28+
{name: "float64 small", input: 0.000123, expected: "0.000123"},
29+
{name: "float32 value", input: float32(2.5), expected: "2.5"},
30+
{name: "bool true", input: true, expected: "true"},
31+
{name: "bool false", input: false, expected: "false"},
32+
{name: "uint64 value", input: uint64(100), expected: "100"},
33+
{name: "uint value", input: uint(50), expected: "50"},
34+
{name: "fallback type", input: []int{1, 2}, expected: fmt.Sprintf("%v", []int{1, 2})},
35+
}
36+
37+
for _, tt := range tests {
38+
t.Run(tt.name, func(t *testing.T) {
39+
t.Parallel()
40+
assert.Equal(t, tt.expected, AnyToString(tt.input), "AnyToString should match expected output")
41+
})
42+
}
43+
}
44+
45+
// TestAnyToString_MatchesFmtSprintf verifies that AnyToString matches
46+
// fmt.Sprintf("%v") for common types. Note: float scientific notation uses
47+
// uppercase 'E' (from 'G' format) vs fmt.Sprintf's lowercase 'e', so floats
48+
// that produce scientific notation are excluded from this exact-match test.
49+
func TestAnyToString_MatchesFmtSprintf(t *testing.T) {
50+
t.Parallel()
51+
52+
values := []any{
53+
"text", 42, int64(100), int32(10),
54+
3.14, float64(0.5),
55+
float32(1.5), true, false,
56+
uint64(99), uint(7),
57+
}
58+
59+
for _, v := range values {
60+
t.Run(fmt.Sprintf("%T(%v)", v, v), func(t *testing.T) {
61+
t.Parallel()
62+
assert.Equal(t, fmt.Sprintf("%v", v), AnyToString(v),
63+
"AnyToString should match fmt.Sprintf for %T", v)
64+
})
65+
}
66+
}
67+
968
func TestBuildAbsoluteReference(t *testing.T) {
1069
t.Parallel()
1170

openapi/reference.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,14 +685,24 @@ func unmarshaler[T any, V interfaces.Validator[T], C marshaller.CoreModeler](_ *
685685
}
686686
}
687687

688+
// referenceInitGlobalMutex serializes initialization of per-Reference mutexes
689+
// to avoid data races on r.initMutex when ensureMutex is called concurrently.
690+
var referenceInitGlobalMutex sync.Mutex
691+
688692
// ensureMutex initializes the mutex if it's nil (lazy initialization).
689693
// Uses sync.Once (pointer) to guarantee thread-safe single initialization
690694
// while keeping Reference safe to copy before first use.
691695
func (r *Reference[T, V, C]) ensureMutex() {
696+
// Guard lazy initialization of r.initMutex with a global mutex to avoid
697+
// concurrent read/write races on the pointer field.
698+
referenceInitGlobalMutex.Lock()
692699
if r.initMutex == nil {
693700
r.initMutex = &sync.Once{}
694701
}
695-
r.initMutex.Do(func() {
702+
initOnce := r.initMutex
703+
referenceInitGlobalMutex.Unlock()
704+
705+
initOnce.Do(func() {
696706
r.cacheMutex = &sync.RWMutex{}
697707
})
698708
}

openapi/reference_mutex_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package openapi
2+
3+
import (
4+
"sync"
5+
"testing"
6+
7+
"github.com/speakeasy-api/openapi/openapi/core"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
// TestEnsureMutex_ConcurrentAccess verifies that ensureMutex is safe to call
12+
// concurrently from multiple goroutines on a Reference with nil initMutex.
13+
func TestEnsureMutex_ConcurrentAccess(t *testing.T) {
14+
t.Parallel()
15+
16+
ref := &Reference[PathItem, *PathItem, *core.PathItem]{}
17+
18+
const goroutines = 100
19+
var wg sync.WaitGroup
20+
wg.Add(goroutines)
21+
22+
for range goroutines {
23+
go func() {
24+
defer wg.Done()
25+
ref.ensureMutex()
26+
}()
27+
}
28+
29+
wg.Wait()
30+
31+
assert.NotNil(t, ref.initMutex, "initMutex should be initialized")
32+
assert.NotNil(t, ref.cacheMutex, "cacheMutex should be initialized")
33+
}
34+
35+
// TestEnsureMutex_CopiedReference verifies that a copied Reference
36+
// with nil mutexes can safely initialize its own mutexes independently.
37+
func TestEnsureMutex_CopiedReference(t *testing.T) {
38+
t.Parallel()
39+
40+
original := &Reference[PathItem, *PathItem, *core.PathItem]{}
41+
original.ensureMutex()
42+
43+
// Simulate a copy by creating a new Reference with nil mutexes
44+
copied := &Reference[PathItem, *PathItem, *core.PathItem]{}
45+
46+
assert.Nil(t, copied.initMutex, "copied reference should have nil initMutex")
47+
assert.Nil(t, copied.cacheMutex, "copied reference should have nil cacheMutex")
48+
49+
copied.ensureMutex()
50+
51+
assert.NotNil(t, copied.initMutex, "copied reference should initialize its own initMutex")
52+
assert.NotNil(t, copied.cacheMutex, "copied reference should initialize its own cacheMutex")
53+
54+
// Original should be unaffected
55+
assert.NotNil(t, original.initMutex, "original initMutex should still be set")
56+
assert.NotNil(t, original.cacheMutex, "original cacheMutex should still be set")
57+
}

validation/utils_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,63 @@ func TestSortValidationErrors_EdgeCases_Success(t *testing.T) {
440440
assert.NotErrorAs(t, errors[6], &notValidation, "index 6 should be regular error")
441441
})
442442

443+
t.Run("validation errors with nil underlying errors", func(t *testing.T) {
444+
t.Parallel()
445+
446+
errs := []error{
447+
&Error{
448+
UnderlyingError: nil,
449+
Node: &yaml.Node{Line: 5, Column: 10},
450+
},
451+
&Error{
452+
UnderlyingError: errors.New("has message"),
453+
Node: &yaml.Node{Line: 5, Column: 10},
454+
},
455+
&Error{
456+
UnderlyingError: nil,
457+
Node: &yaml.Node{Line: 5, Column: 10},
458+
},
459+
}
460+
461+
assert.NotPanics(t, func() {
462+
SortValidationErrors(errs)
463+
}, "sorting with nil UnderlyingError should not panic")
464+
465+
// Error with message should sort after nil-message errors (empty string < "has message")
466+
var err0, err1, err2 *Error
467+
require.ErrorAs(t, errs[0], &err0)
468+
require.ErrorAs(t, errs[1], &err1)
469+
require.ErrorAs(t, errs[2], &err2)
470+
assert.NoError(t, err0.UnderlyingError, "nil error should sort first")
471+
assert.NoError(t, err1.UnderlyingError, "nil error should sort first")
472+
assert.Error(t, err2.UnderlyingError, "non-nil error should sort last")
473+
})
474+
475+
t.Run("one nil and one non-nil underlying error", func(t *testing.T) {
476+
t.Parallel()
477+
478+
errs := []error{
479+
&Error{
480+
UnderlyingError: errors.New("error B"),
481+
Node: &yaml.Node{Line: 1, Column: 1},
482+
},
483+
&Error{
484+
UnderlyingError: nil,
485+
Node: &yaml.Node{Line: 1, Column: 1},
486+
},
487+
}
488+
489+
assert.NotPanics(t, func() {
490+
SortValidationErrors(errs)
491+
}, "sorting with one nil UnderlyingError should not panic")
492+
493+
var err0, err1 *Error
494+
require.ErrorAs(t, errs[0], &err0)
495+
require.ErrorAs(t, errs[1], &err1)
496+
require.NoError(t, err0.UnderlyingError, "nil underlying error sorts before non-nil")
497+
require.Error(t, err1.UnderlyingError)
498+
})
499+
443500
t.Run("validation errors first then regular errors forces bIsValidationErr", func(t *testing.T) {
444501
t.Parallel()
445502

0 commit comments

Comments
 (0)