Skip to content

Commit cb915a0

Browse files
authored
SWI-11043 Update Test Suite (#206)
* update current tests * add script * first batch * oneOf example * add new files * add md guide * rubocop * update tn lookup * update coverage * update tfv * fix missed tests * update rubocop * update simplecov * add oauth test * simplecov * spelling * more oauth tests
1 parent 77909a6 commit cb915a0

209 files changed

Lines changed: 18649 additions & 106 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.rubocop.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# This file is based on https://github.com/rails/rails/blob/master/.rubocop.yml (MIT license)
22
# Automatically generated by OpenAPI Generator (https://openapi-generator.tech)
33
AllCops:
4-
TargetRubyVersion: 2.4
4+
TargetRubyVersion: 3.3
55
# RuboCop has a bunch of cops enabled by default. This setting tells RuboCop
66
# to ignore them, so only the ones explicitly set in this file are enabled.
77
DisabledByDefault: true

Gemfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ gemspec
55
group :development, :test do
66
gem 'rake', '~> 13.2.1'
77
gem 'pry-byebug'
8-
gem 'rubocop', '~> 1.82.0'
9-
gem 'simplecov', '~> 0.21.2'
8+
gem 'rubocop', '~> 1.86.2'
9+
gem 'simplecov', '~> 0.22.0'
1010
end

UPDATING_MODEL_TESTS.md

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
# Updating Model Unit Tests
2+
3+
## Context
4+
5+
Model unit test files in `spec/unit/models/` are auto-generated by the `openapi-generator-cli` (`rake generate` or equivalent). The generator output is a *starting point* — every spec must be updated to match the template described here.
6+
7+
This doc is the source of truth for that update step. Follow it exactly when bringing a fresh-generated spec into compliance.
8+
9+
## Workflow
10+
11+
1. Auto-generate the SDK (writes specs to `spec/unit/models/`).
12+
2. For each spec under `spec/unit/models/`, pick the matching template:
13+
- [Standard Model Template](#standard-model-template) — regular models (the model file defines a `class`)
14+
- [Enum Model Template](#enum-model-template) — pure enum models (the model file defines a class with `all_vars` and frozen string constants only)
15+
- [oneOf Model Template](#oneof-model-template) — discriminator-based oneOf models (the model file defines a `module`, not a `class`)
16+
3. Run `rake unit` and confirm `0 failures`.
17+
4. Check coverage didn't regress meaningfully (see [Coverage notes](#coverage-notes)).
18+
19+
## Standard Model Template
20+
21+
Apply to any spec representing a regular model (object with attributes), e.g. `message_spec.rb`, `call_state_spec.rb`, `verify_code_request_spec.rb`.
22+
23+
### Required structure
24+
25+
```ruby
26+
describe Bandwidth::ModelName do
27+
let(:model_name_default) { Bandwidth::ModelName.new }
28+
let(:model_name_values) { Bandwidth::ModelName.new({ <all attrs populated> }) }
29+
30+
describe '#initialize' do
31+
it 'causes an ArgumentError by passing an Array to the initialize method'
32+
it 'causes an ArgumentError by passing an invalid attribute to the initialize method'
33+
end
34+
35+
describe '#acceptable_attributes' do
36+
it 'expects acceptable JSON attributes to be those in the attribute map'
37+
end
38+
39+
describe '#openapi_nullable' do
40+
# If model has nullable attrs, assert the exact Set. Otherwise assert empty Set.
41+
it 'expects nullable attributes to be ...'
42+
end
43+
44+
describe '#build_from_hash' do
45+
# camelCase keys in, snake_case attr reads out, instance check.
46+
it 'validates instance of ModelName created by the build_from_hash method'
47+
end
48+
49+
describe '#to_s' do
50+
# Use the *populated* values instance, not the empty default.
51+
it 'returns a string representation of the object'
52+
end
53+
54+
describe '#eq? #==' do
55+
# Assert both equality AND inequality.
56+
it 'returns true/false when comparing objects'
57+
end
58+
59+
describe '#to_body #to_hash' do
60+
it 'returns a hash representation of the object'
61+
end
62+
63+
# Only when the model has validated attrs (nil/pattern/min/max).
64+
describe 'custom attribute writers' do
65+
it '#attr=' do ... end
66+
end
67+
end
68+
```
69+
70+
### What to drop from the auto-generated spec
71+
72+
- **`#hash` block.** Asserting `obj.hash.is_a?(Integer)` is testing Ruby itself. No signal.
73+
- **Empty `enum validation` block** (e.g. `describe 'enum validation' do; it 'works' do; end; end`). Pure noise — passes while testing nothing.
74+
- **`EnumAttributeValidator` block.** It tests generator scaffolding with fake allow-lists (`['valid']`, `[1]`), not the model's actual enums. The customer-facing behavior — "does an invalid enum value raise when set on a model?" — belongs in custom attribute writer tests, where it actually exercises the model.
75+
76+
### What to add when missing
77+
78+
- **`_default` let.** Pair `_values` with a default instance so `#eq?` can assert both equal-to-equal and equal-to-different. For models with required (non-nil-validated) attrs, build `_default` with only the required attrs populated.
79+
- **`#openapi_nullable` block.** Always include, even if the nullable set is empty. Read the model's `self.openapi_nullable` to get the exact set.
80+
- **Custom attribute writer tests.** One `it` per validated attribute. Cover every validation the model performs:
81+
- `nil` check → `'<attr> cannot be nil'`
82+
- pattern → `'invalid value for "<attr>", must conform to the pattern ...'`
83+
- min/max length → `'invalid value for "<attr>", the character length must be ...'`
84+
- min/max value → `'invalid value for "<attr>", must be smaller/greater than or equal to ...'`
85+
- enum allow-list → `'invalid value for "<attr>", must be one of ...'`
86+
87+
### What to fix when incorrect
88+
89+
- **`#to_s` using the empty `_default` instance.** Switch to `_values` so the assertion is meaningful (asserts `'{}'` proves nothing).
90+
- **`#eq?` only checking equality.** Update to assert both `_default.eql?(equal_instance)` is true AND `_default.eql?(_values)` is false.
91+
92+
## Enum Model Template
93+
94+
Apply to pure enum models, e.g. `call_state_enum_spec.rb`.
95+
96+
```ruby
97+
describe Bandwidth::SomeEnum do
98+
describe 'constants' do
99+
it 'defines VALUE_NAME' do
100+
expect(Bandwidth::SomeEnum::VALUE_NAME).to eq('value_string')
101+
end
102+
# ...one per enum constant
103+
end
104+
105+
describe '.all_vars' do
106+
it 'returns every valid enum value' do
107+
expect(Bandwidth::SomeEnum.all_vars).to eq([<every value in order>])
108+
end
109+
end
110+
111+
describe '.build_from_hash' do
112+
it 'returns the value when it matches a valid enum value' do
113+
expect(Bandwidth::SomeEnum.build_from_hash('valid_value')).to eq('valid_value')
114+
# ...one per valid value
115+
end
116+
117+
it 'raises an error for an invalid enum value' do
118+
expect { Bandwidth::SomeEnum.build_from_hash('invalid') }.to raise_error(RuntimeError)
119+
end
120+
end
121+
end
122+
```
123+
124+
### What to drop
125+
126+
- Instance-construction tests (`Bandwidth::SomeEnum.new` then `be_instance_of`). The enum module isn't meant to be instantiated for use — only `build_from_hash` matters.
127+
128+
## oneOf Model Template
129+
130+
Apply to discriminator-based oneOf models, e.g. `callback_spec.rb`. These models are defined as Ruby `module`s (not classes) and act as routers — given a payload with a discriminator field, they dispatch to one of several target classes via `.build`.
131+
132+
You can identify a oneOf model by inspecting `lib/bandwidth-sdk/models/<name>.rb`: it will be a `module` exposing `openapi_one_of`, `openapi_discriminator_name`, `openapi_discriminator_mapping`, and `build` as class methods.
133+
134+
```ruby
135+
describe Bandwidth::SomeOneOf do
136+
describe '.openapi_one_of' do
137+
it 'lists the classes defined in oneOf' do
138+
expect(Bandwidth::SomeOneOf.openapi_one_of).to eq([
139+
:'ClassA',
140+
:'ClassB'
141+
])
142+
end
143+
end
144+
145+
describe '.openapi_discriminator_name' do
146+
it 'returns the discriminator property name' do
147+
expect(Bandwidth::SomeOneOf.openapi_discriminator_name).to eq(:'type')
148+
end
149+
end
150+
151+
describe '.openapi_discriminator_mapping' do
152+
it 'maps every discriminator value to a oneOf class' do
153+
expect(Bandwidth::SomeOneOf.openapi_discriminator_mapping).to eq({
154+
:'discriminator-value-1' => :'ClassA',
155+
:'discriminator-value-2' => :'ClassB'
156+
})
157+
end
158+
159+
it 'maps only to classes listed in openapi_one_of' do
160+
mapping_targets = Bandwidth::SomeOneOf.openapi_discriminator_mapping.values.uniq.sort
161+
expect(mapping_targets).to eq(Bandwidth::SomeOneOf.openapi_one_of.sort)
162+
end
163+
end
164+
165+
describe '.build' do
166+
# Stub build_from_hash on each target class so we test the routing without
167+
# depending on the target's construction logic (which is covered by its own spec).
168+
it 'routes <ClassA> discriminator values to ClassA.build_from_hash' do
169+
Bandwidth::SomeOneOf.openapi_discriminator_mapping.each do |discriminator, klass|
170+
next unless klass == :'ClassA'
171+
data = { type: discriminator.to_s }
172+
expect(Bandwidth::ClassA).to receive(:build_from_hash).with(data).and_return(:class_a_result)
173+
expect(Bandwidth::SomeOneOf.build(data)).to eq(:class_a_result)
174+
end
175+
end
176+
177+
# ...one block per oneOf target class
178+
179+
it 'returns nil when the discriminator value is missing' do
180+
expect(Bandwidth::SomeOneOf.build({})).to be_nil
181+
end
182+
183+
it 'returns nil when the discriminator value does not match any mapping' do
184+
expect(Bandwidth::SomeOneOf.build({ type: 'unknown' })).to be_nil
185+
end
186+
end
187+
end
188+
```
189+
190+
### Required structure
191+
192+
- `.openapi_one_of` — assert the exact list of target class symbols
193+
- `.openapi_discriminator_name` — assert the exact discriminator property symbol
194+
- `.openapi_discriminator_mapping` — assert the exact mapping AND assert it only maps to classes in `openapi_one_of`
195+
- `.build` — one block per target class verifying routing (via stubbed `build_from_hash`), plus the two nil cases (missing discriminator, unknown discriminator value)
196+
197+
### What to drop from the auto-generated spec
198+
199+
- **Weak `not_to be_empty` assertions.** Replace with exact equality checks against the model's actual values.
200+
- **`mapping.values.sort == one_of.sort` assertion.** This is buggy in the generator output — `mapping.values` has duplicates when multiple discriminator values route to the same class. Use `.uniq.sort` instead.
201+
- **Empty `.build` describe block.** Replace with the stub-based routing tests above.
202+
203+
### Why stub `build_from_hash`?
204+
205+
oneOf targets often have many required attributes (and nested required models), making it tedious to construct valid data for every discriminator value. Stubbing isolates the test to what `.build` is actually doing — routing based on a discriminator. The target class's `build_from_hash` is covered by that class's own spec.
206+
207+
### What to skip
208+
209+
- `#initialize`, `#openapi_nullable`, `#to_s`, `#eq?`, `#to_body`, custom attribute writers — these don't apply. The oneOf model is a `module`, not an instantiable class.
210+
211+
### Variant: discriminator-based `openapi_any_of`
212+
213+
Some modules expose `openapi_any_of` instead of `openapi_one_of` but otherwise have the identical shape (same `openapi_discriminator_name`, `openapi_discriminator_mapping`, and `build` implementation). Treat them exactly like the oneOf template above — just substitute `openapi_any_of` for `openapi_one_of` everywhere (describe block name, assertion target, the `uniq.sort` cross-check).
214+
215+
Example: `multi_channel_action_spec.rb` (the model is `lib/bandwidth-sdk/models/multi_channel_action.rb`).
216+
217+
### Variant: no-discriminator `openapi_one_of`
218+
219+
Some `oneOf` modules expose `openapi_one_of` but **no** discriminator (no `openapi_discriminator_name`/`openapi_discriminator_mapping`). Instead, `build(data)` iterates `openapi_one_of` and uses a private `find_and_cast_into_type` helper to attempt deserialization into each target class in order, returning the first match (or `nil` if none match).
220+
221+
You **cannot use the stubbed-routing pattern** for these — `find_and_cast_into_type` does a pre-flight check against the target's `acceptable_attributes` before calling `build_from_hash`, so a naked stub of `build_from_hash` is bypassed. Use real payloads instead, sized to match one target's attribute shape.
222+
223+
```ruby
224+
describe Bandwidth::SomeNoDiscriminatorOneOf do
225+
describe '.openapi_one_of' do
226+
it 'lists the classes defined in oneOf' do
227+
expect(Bandwidth::SomeNoDiscriminatorOneOf.openapi_one_of).to eq([
228+
:'ClassA',
229+
:'ClassB'
230+
])
231+
end
232+
end
233+
234+
describe '.build' do
235+
it 'routes payloads matching ClassA attributes to ClassA' do
236+
data = { someClassAAttr: 'value' }
237+
expect(Bandwidth::SomeNoDiscriminatorOneOf.build(data)).to be_instance_of(Bandwidth::ClassA)
238+
end
239+
240+
it 'routes payloads matching ClassB attributes to ClassB' do
241+
data = { someClassBAttr: 'value' }
242+
expect(Bandwidth::SomeNoDiscriminatorOneOf.build(data)).to be_instance_of(Bandwidth::ClassB)
243+
end
244+
245+
it 'returns nil when the payload does not match any oneOf schema' do
246+
expect(Bandwidth::SomeNoDiscriminatorOneOf.build({ unknown: 'value' })).to be_nil
247+
end
248+
end
249+
end
250+
```
251+
252+
Pick payload attributes that exist on only one target so `find_and_cast_into_type`'s `acceptable_attributes` check disambiguates correctly. Skip `.openapi_discriminator_name` and `.openapi_discriminator_mapping` blocks — those methods don't exist on these modules.
253+
254+
Example: `rbm_message_content_rich_card_spec.rb`.
255+
256+
## Gotchas
257+
258+
### Writers unreachable through `Model.new`
259+
260+
Some validated setters (notably `to=` and `media=` on `Bandwidth::Message`) only get invoked from `initialize` when the input is an Array. Passing `nil` to `Model.new` won't trigger the writer's nil check.
261+
262+
**Fix:** call the writer directly on an instance.
263+
264+
```ruby
265+
# Wrong — won't raise:
266+
expect { Bandwidth::Message.new({ to: nil }) }.to raise_error(ArgumentError, 'to cannot be nil')
267+
268+
# Right — exercises the writer:
269+
expect { message_values.to = nil }.to raise_error(ArgumentError, 'to cannot be nil')
270+
```
271+
272+
When in doubt, read the model's `initialize` and check whether `self.<attr> =` is gated behind a type check.
273+
274+
### `#build_from_hash` needs full required-attr data for nested models
275+
276+
When a model has an attribute typed as another model (e.g., `:'business_address' => :'Address'`), `build_from_hash` recursively deserializes that nested hash into a real `Address` instance. That instance's `initialize` runs all required-attr setters, so any missing required attr raises `ArgumentError`.
277+
278+
**This bites only in the `#build_from_hash` test, not `#to_s` / `#to_body` / `_values`** — when you pass a nested hash to `Model.new` directly (via `_values`), the parent's `attr_accessor` just stores the hash verbatim; no recursive deserialization happens.
279+
280+
```ruby
281+
# Wrong — raises ArgumentError: 'addr1 cannot be nil' inside Address.new:
282+
Bandwidth::VerificationRequest.build_from_hash({
283+
businessAddress: { name: 'Bandwidth' }, # Address requires addr1, city, state, zip, url
284+
# ...
285+
})
286+
287+
# Right — full required-attr data for the nested Address:
288+
Bandwidth::VerificationRequest.build_from_hash({
289+
businessAddress: { name: 'Bandwidth', addr1: '900 Main Campus Dr', city: 'Raleigh', state: 'NC', zip: '27606', url: 'https://www.bandwidth.com' },
290+
# ...
291+
})
292+
```
293+
294+
Apply this recursively — if the nested model itself has nested models with required attrs, fill those too. Check the nested model's setters (`def <attr>=` blocks raising `'<attr> cannot be nil'`) to know which attrs are required.
295+
296+
### `#to_s` is sensitive to attribute order
297+
298+
The expected string must match the exact order of attributes as serialized by `to_hash`. Generate the expected string by running the populated instance through `to_s` in a console and pasting the result. Don't hand-craft it.
299+
300+
### Nullable attribute symbols
301+
302+
`openapi_nullable` returns a `Set` of `Symbol`s using snake_case attribute names with a leading colon-quote (e.g. `:'parent_call_id'`). Mirror the model's `openapi_nullable` definition exactly.
303+
304+
## Coverage notes
305+
306+
Dropping `EnumAttributeValidator` blocks reduces line coverage slightly, because the nested `EnumAttributeValidator` class isn't exercised elsewhere. Two options:
307+
308+
1. **Accept the dip.** That code is generator scaffolding; coverage of it is low-value.
309+
2. **Add an enum-validated writer test** for at least one attribute per model with enums (e.g. `Bandwidth::Message.new(direction: 'invalid')` should raise). This exercises `EnumAttributeValidator` through a real path and recovers the coverage.
310+
311+
The coverage logic itself is being updated as part of expanding to all models — don't fight stale thresholds.
312+
313+
## Verification
314+
315+
```sh
316+
rake unit
317+
```
318+
319+
Confirm `0 failures` before considering a spec done.

custom_templates/Gemfile.mustache

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,6 @@ gemspec
55
group :development, :test do
66
gem 'rake', '~> 13.2.1'
77
gem 'pry-byebug'
8-
gem 'rubocop', '~> 1.82.0'
9-
gem 'simplecov', '~> 0.21.2'
8+
gem 'rubocop', '~> 1.86.2'
9+
gem 'simplecov', '~> 0.22.0'
1010
end

generate-model-tests.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/bin/bash
2+
3+
# Generates new test files for models. Run from the root.
4+
5+
# allow generator to write test files
6+
sed -i.bak 's|^/spec/\*\*|# /spec/**|' .openapi-generator-ignore && rm .openapi-generator-ignore.bak
7+
# remove current test files for models
8+
rm -f ./spec/unit/models/*
9+
# generate new test files for models
10+
openapi-generator-cli generate -i bandwidth.yml -o ./ -c openapi-config.yml -g ruby > /dev/null
11+
# move generated model test files to the correct location (exclude api tests)
12+
mv ./spec/models/* ./spec/unit/models/
13+
# remove remaining generated test files (api tests, etc.)
14+
rm -rf ./spec/api ./spec/models
15+
# discard changes to modified files only (leaves deletions and new test files intact)
16+
modified=$(git diff --name-only --diff-filter=M) && [ -n "$modified" ] && echo "$modified" | xargs git checkout --

lib/bandwidth-sdk/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ def initialize
217217
body = JSON.parse(response.body)
218218
@access_token = body['access_token']
219219
@access_token_expiration = Time.now + body['expires_in']
220+
@access_token
220221
}
221222

222223
yield(self) if block_given?

0 commit comments

Comments
 (0)