haveapi 0.30.0 → 0.30.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/documentation.rb +277 -0
  5. data/benchmarks/endpoints.rb +730 -0
  6. data/benchmarks/performance_budgets.json +1354 -0
  7. data/benchmarks/performance_compare.rb +579 -0
  8. data/doc/client-examples.md +233 -24
  9. data/doc/performance.md +272 -0
  10. data/haveapi.gemspec +1 -1
  11. data/lib/haveapi/documentation_clients.rb +158 -35
  12. data/lib/haveapi/documentation_context.rb +42 -12
  13. data/lib/haveapi/documentation_schema.rb +73 -0
  14. data/lib/haveapi/server.rb +5 -0
  15. data/lib/haveapi/version.rb +1 -1
  16. data/lib/haveapi/views/version_page.erb +2 -2
  17. data/spec/client_examples/context_performance_spec.rb +72 -0
  18. data/spec/client_examples/context_spec.rb +206 -0
  19. data/spec/client_examples/dynamic_js_spec.rb +1 -0
  20. data/spec/client_examples/profile_mount_spec.rb +215 -0
  21. data/spec/client_examples/profiles_spec.rb +202 -32
  22. data/spec/client_examples/structural_schema_spec.rb +48 -0
  23. data/spec/documentation/client_examples_spec.rb +10 -0
  24. data/spec/documentation/performance_spec.rb +79 -0
  25. data/spec/performance/comparator_spec.rb +342 -0
  26. data/spec/performance/endpoint_contract_spec.rb +226 -0
  27. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  28. data/spec/performance/event_baseline_spec.rb +279 -0
  29. data/spec/performance/json_activation_spec.rb +151 -0
  30. data/spec/performance/release_lineage_spec.rb +431 -0
  31. data/spec/performance/runner_spec.rb +476 -0
  32. data/spec/performance/sqlite_spec.rb +194 -0
  33. data/spec/performance/support/live_worker.rb +44 -0
  34. data/spec/performance/support/samples.rb +72 -0
  35. data/test_support/documentation_performance_fixture.rb +106 -0
  36. data/test_support/performance_endpoint_fixture.rb +350 -0
  37. data/test_support/performance_sqlite_fixture.rb +384 -0
  38. metadata +26 -3
@@ -3,22 +3,46 @@ require 'tmpdir'
3
3
 
4
4
  describe HaveAPI::ClientExample::Profiles do
5
5
  let(:profiles) { described_class.new }
6
+ let(:adapter) do
7
+ Class.new do
8
+ def resolve(**); end
9
+ def render(**); end
10
+ end.new
11
+ end
6
12
  let(:directory) { Dir.mktmpdir('haveapi-doc-profiles-') }
7
- let(:description_path) { File.join(directory, 'description.json') }
8
- let(:go_config) do
9
- { import_path: 'example.test/widget/client', module_version: 'v1.2.3', description_path:, generator_version: '0.30.0' }
13
+ let(:description) { { resources: {}, authentication: {}, meta: { namespace: '_meta' } } }
14
+ let(:server) { instance_double(HaveAPI::Server, versions: [1], root: '/', describe_version: description) }
15
+
16
+ def description_path
17
+ File.join(directory, 'description.json')
18
+ end
19
+
20
+ def config_path
21
+ File.join(directory, 'generator.toml')
22
+ end
23
+
24
+ def go_config
25
+ { import_path: 'example.test/widget/client', module_version: 'v1.2.3', description_path:, generator_version: HaveAPI::VERSION }
26
+ end
27
+
28
+ def rust_config
29
+ { snapshot_path: description_path, generator_config_path: config_path, generator_version: HaveAPI::VERSION }
30
+ end
31
+
32
+ def generator_config
33
+ { 'crate_name' => 'widget-client', 'crate_version' => '1.2.3' }
10
34
  end
11
- let(:adapter) { Object.new }
12
35
 
13
36
  before do
14
- File.write(description_path, JSON.generate({ resources: {} }))
37
+ File.write(description_path, JSON.generate(description))
38
+ File.write(config_path, 'fixture config')
15
39
  end
16
40
 
17
41
  after { FileUtils.remove_entry(directory) }
18
42
 
19
- def install_go_adapter
43
+ def install_go_adapter(version: HaveAPI::VERSION)
20
44
  generator = Module.new
21
- generator.const_set(:VERSION, '0.30.0')
45
+ generator.const_set(:VERSION, version)
22
46
  documentation = Class.new
23
47
  generator.const_set(:Documentation, documentation)
24
48
  stub_const('HaveAPI::GoClient', generator)
@@ -27,32 +51,89 @@ describe HaveAPI::ClientExample::Profiles do
27
51
  documentation
28
52
  end
29
53
 
30
- it 'needs no optional generator when profiles are absent' do
31
- allow(profiles).to receive(:require).and_raise('Unexpected optional dependency')
54
+ def install_rust_adapter(factories: true)
55
+ generator = Module.new
56
+ generator.const_set(:VERSION, HaveAPI::VERSION)
57
+ documentation = Class.new
58
+ generator.const_set(:Documentation, documentation)
59
+ generator.const_set(:Generator, Class.new)
60
+ generator.const_set(:Model, Module.new)
61
+ generator.const_set(:Snapshot, Class.new)
62
+ stub_const('HaveAPI::RustClient', generator)
63
+ stub_const('Tomlrb', Module.new)
64
+ allow(Tomlrb).to receive(:load_file).with(config_path).and_return(generator_config)
65
+ allow(profiles).to receive(:require).with('haveapi/rust_client/documentation').and_return(true)
66
+ allow(documentation).to receive(:new).and_return(adapter)
67
+ if factories
68
+ allow(documentation).to receive_messages(from_description: adapter, from_snapshot: adapter)
69
+ allow(documentation).to receive(:validate_source) { |source| source }
70
+ allow(HaveAPI::RustClient::Generator).to receive(:validate_config)
71
+ allow(HaveAPI::RustClient::Model).to receive(:validate_package)
72
+ end
73
+ documentation
74
+ end
75
+
76
+ def finalize
32
77
  profiles.mount([1])
78
+ profiles.finalize(server)
79
+ end
80
+
81
+ it 'needs no optional generator or schema when profiles are absent' do
82
+ allow(profiles).to receive(:require).and_raise('Unexpected optional dependency')
83
+ finalize
33
84
  expect(profiles).not_to have_received(:require)
85
+ expect(server).not_to have_received(:describe_version)
34
86
  expect(profiles.for_version(1)).to eq({})
35
87
  expect(profiles.for_version(1)).to be_frozen
36
88
  end
37
89
 
38
- it 'loads a matching configured adapter once and keeps schema paths out of the public profile' do
90
+ it 'loads an explicit saved adapter once without rereading it during finalization' do
39
91
  documentation = install_go_adapter
40
92
  profiles.register(1, go: go_config)
41
- profiles.mount([1])
93
+ expect(profiles.for_version(1)).to be_empty
42
94
  File.unlink(description_path)
95
+ finalize
43
96
  profile = profiles.for_version(1).fetch(:go)
44
97
  expect(profile).to have_attributes(language: :go, package_name: 'example.test/widget/client', package_version: 'v1.2.3')
45
98
  expect(profile).to be_frozen
46
99
  expect(profile.adapter).to equal(adapter)
47
- expect(documentation).to have_received(:new).once.with(description: { 'resources' => {} }, import_path: 'example.test/widget/client', module_version: 'v1.2.3')
100
+ expect(documentation).to have_received(:new).once.with(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/widget/client', module_version: 'v1.2.3')
101
+ expect(server).not_to have_received(:describe_version)
48
102
  expect(profile).not_to respond_to(:description_path)
49
103
  end
50
104
 
105
+ it 'defaults the generator version and defers automatic Go without inventing an SDK version' do
106
+ documentation = install_go_adapter
107
+ config = { import_path: 'example.test/widget/client' }
108
+ profiles.register(1, go: config)
109
+ config[:import_path].replace('changed.test/client')
110
+ expect(documentation).not_to have_received(:new)
111
+ finalize
112
+ profile = profiles.for_version(1).fetch(:go)
113
+ expect(profile.package_name).to eq('example.test/widget/client')
114
+ expect(profile.package_version).to be_nil
115
+ expect(documentation).to have_received(:new).with(description: JSON.parse(JSON.generate(description)), import_path: 'example.test/widget/client', module_version: nil)
116
+ expect(server).to have_received(:describe_version).once
117
+ end
118
+
119
+ it 'permits nil SDK versions and retains explicitly pinned generator versions' do
120
+ documentation = install_go_adapter(version: '0.29.0')
121
+ profiles.register(1, go: go_config.merge(module_version: nil, generator_version: '0.29.0'))
122
+ finalize
123
+ expect(profiles.for_version(1).fetch(:go).package_version).to be_nil
124
+ expect(documentation).to have_received(:new).with(description: anything, import_path: anything, module_version: nil)
125
+ end
126
+
51
127
  it 'rejects unknown languages, malformed fields and unsafe package configuration' do
52
128
  expect { profiles.register(1, python: {}) }.to raise_error(described_class::Invalid, /Unknown/)
53
129
  expect { profiles.register(1, go: go_config.merge(unexpected: 'value')) }.to raise_error(described_class::Invalid, /fields/)
54
130
  expect { profiles.register(1, go: go_config.merge(import_path: "example.test/\nsource")) }.to raise_error(described_class::Invalid, /package/)
55
- expect { profiles.register(1, go: go_config.merge(module_version: 'latest')) }.to raise_error(described_class::Invalid, /package/)
131
+ ['latest', '', 1, false].each do |value|
132
+ expect { profiles.register(1, go: go_config.merge(module_version: value)) }.to raise_error(described_class::Invalid, /package/)
133
+ end
134
+ expect { profiles.register(1, go: go_config.merge(generator_version: nil)) }.to raise_error(described_class::Invalid)
135
+ expect { profiles.register(1, go: go_config.merge(generator_version: 'latest')) }.to raise_error(described_class::Invalid, /generator version/)
136
+ expect { profiles.register(1, go: {}) }.to raise_error(described_class::Invalid, /package/)
56
137
  end
57
138
 
58
139
  it 'rejects missing adapters, mismatched generator versions and missing files at registration' do
@@ -63,33 +144,122 @@ describe HaveAPI::ClientExample::Profiles do
63
144
  expect { profiles.register(1, go: go_config.merge(description_path: 'relative.json')) }.to raise_error(described_class::Invalid, /absolute local/)
64
145
  end
65
146
 
66
- it 'rejects duplicate profiles, unselected versions and registration after mount' do
147
+ it 'checks the configured adapter interface' do
148
+ documentation = install_go_adapter
149
+ allow(documentation).to receive(:new).and_return(Object.new)
150
+ expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /adapter interface/)
151
+ end
152
+
153
+ it 'does not leave a partially registered language set after an invalid second profile' do
154
+ install_go_adapter
155
+ expect { profiles.register(1, go: go_config, rust: { unknown: 'field' }) }.to raise_error(described_class::Invalid)
156
+ profiles.register(1, go: go_config)
157
+ finalize
158
+ expect(profiles.for_version(1).keys).to eq([:go])
159
+ end
160
+
161
+ it 'rejects duplicates and registration after mounting starts' do
67
162
  install_go_adapter
68
163
  profiles.register(1, go: go_config)
69
164
  expect { profiles.register('1', go: go_config) }.to raise_error(described_class::Invalid, /Duplicate/)
70
- expect { profiles.mount([2]) }.to raise_error(described_class::Invalid, /unavailable API version/)
71
165
  profiles.mount([1])
72
166
  expect { profiles.register(2, go: go_config) }.to raise_error(described_class::Invalid, /already mounted/)
167
+ profiles.finalize(server)
168
+ expect { profiles.finalize(server) }.to raise_error(described_class::Invalid, /not ready/)
73
169
  end
74
170
 
75
- it 'loads Rust TOML configuration and rejects a snapshot bound to another API version at mount' do
76
- generator = Module.new
77
- generator.const_set(:VERSION, '0.30.0')
78
- documentation = Class.new
79
- generator.const_set(:Documentation, documentation)
80
- stub_const('HaveAPI::RustClient', generator)
81
- toml = Module.new
82
- stub_const('Tomlrb', toml)
83
- config_path = File.join(directory, 'generator.toml')
84
- File.write(config_path, 'fixture config')
85
- File.write(description_path, JSON.generate({ api_version: '2' }))
86
- config = { 'crate_name' => 'widget-client', 'crate_version' => '1.2.3' }
87
- allow(toml).to receive(:load_file).with(config_path).and_return(config)
88
- allow(profiles).to receive(:require).with('haveapi/rust_client/documentation').and_return(true)
89
- allow(documentation).to receive(:new).and_return(adapter)
90
- profiles.register(1, rust: { snapshot_path: description_path, generator_config_path: config_path, generator_version: '0.30.0' })
91
- expect(documentation).to have_received(:new).with(snapshot: { 'api_version' => '2' }, config:)
171
+ it 'rejects profiles for an unavailable version' do
172
+ install_go_adapter
173
+ profiles.register(1, go: go_config)
174
+ expect { profiles.mount([2]) }.to raise_error(described_class::Invalid, /unavailable API version/)
175
+ expect(profiles.for_version(1)).to be_empty
176
+ end
177
+
178
+ it 'preserves legacy Rust construction even without the new factories' do
179
+ documentation = install_rust_adapter(factories: false)
180
+ File.write(description_path, JSON.generate({ api_version: '1' }))
181
+ profiles.register(1, rust: rust_config)
182
+ finalize
183
+ expect(documentation).to have_received(:new).with(snapshot: { 'api_version' => '1' }, config: generator_config)
92
184
  expect(profiles.for_version(1).fetch(:rust)).to have_attributes(package_name: 'widget-client', package_version: '1.2.3')
185
+ end
186
+
187
+ it 'rejects a saved Rust snapshot for a different selected API version at mount' do
188
+ install_rust_adapter
189
+ File.write(description_path, JSON.generate({ api_version: '2' }))
190
+ profiles.register(1, rust: rust_config)
93
191
  expect { profiles.mount([1]) }.to raise_error(described_class::Invalid, /snapshot API version mismatch/)
192
+ expect(profiles.for_version(1)).to be_empty
193
+ end
194
+
195
+ it 'validates inline Rust identity and source before building from the mounted description' do
196
+ documentation = install_rust_adapter
197
+ source = { git: 'https://example.test/widget-client', rev: 'a' * 40 }
198
+ profiles.register(1, rust: { crate_name: 'widget-client', source: })
199
+ expect(HaveAPI::RustClient::Model).to have_received(:validate_package).with(crate_name: 'widget-client', crate_version: nil)
200
+ expect(documentation).to have_received(:validate_source).with(source)
201
+ expect(documentation).not_to have_received(:from_description)
202
+ finalize
203
+ expect(documentation).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: HaveAPI::PROTOCOL_VERSION,
204
+ mount_path: '/', crate_name: 'widget-client', crate_version: nil, source:)
205
+ expect(profiles.for_version(1).fetch(:rust).package_version).to be_nil
206
+ end
207
+
208
+ it 'supports an inline Rust identity with a saved snapshot and optional explicit SDK version' do
209
+ documentation = install_rust_adapter
210
+ snapshot = { 'api_version' => '1' }
211
+ File.write(description_path, JSON.generate(snapshot))
212
+ profiles.register(1, rust: { snapshot_path: description_path, crate_name: 'widget-client', crate_version: '1.2.3' })
213
+ finalize
214
+ expect(documentation).to have_received(:from_snapshot).with(snapshot:, crate_name: 'widget-client', crate_version: '1.2.3', source: nil)
215
+ expect(server).not_to have_received(:describe_version)
216
+ end
217
+
218
+ it 'validates full legacy TOML before using it with an automatic Rust description' do
219
+ documentation = install_rust_adapter
220
+ snapshot = { 'api_version' => '1' }
221
+ allow(HaveAPI::RustClient::Snapshot).to receive(:from_description).and_return(snapshot)
222
+ profiles.register(1, rust: { generator_config_path: config_path })
223
+ expect(HaveAPI::RustClient::Generator).to have_received(:validate_config).with(generator_config)
224
+ finalize
225
+ expect(HaveAPI::RustClient::Snapshot).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: HaveAPI::PROTOCOL_VERSION,
226
+ mount_path: '/', source_url: 'https://haveapi.invalid/v1/', profile: 'server-documentation')
227
+ expect(documentation).to have_received(:new).with(snapshot:, config: generator_config)
228
+ end
229
+
230
+ it 'rejects automatic and inline modes when an older Rust generator lacks their factories' do
231
+ install_rust_adapter(factories: false)
232
+ expect { profiles.register(1, rust: { generator_config_path: config_path }) }.to raise_error(described_class::Invalid, /does not support/)
233
+ expect { profiles.register(1, rust: { crate_name: 'widget-client', snapshot_path: description_path }) }.to raise_error(described_class::Invalid, /does not support/)
234
+ end
235
+
236
+ it 'rejects conflicting Rust identities and unknown source profile fields' do
237
+ install_rust_adapter
238
+ expect { profiles.register(1, rust: rust_config.merge(crate_name: 'widget-client')) }.to raise_error(described_class::Invalid, /Conflicting/)
239
+ expect { profiles.register(1, rust: rust_config.merge(crate_version: nil)) }.to raise_error(described_class::Invalid, /Conflicting/)
240
+ expect { profiles.register(1, rust: { crate_name: 'widget-client', branch: 'main' }) }.to raise_error(described_class::Invalid, /fields/)
241
+ end
242
+
243
+ it 'captures one private structural schema per version shared by automatic profiles' do
244
+ go = install_go_adapter
245
+ rust = install_rust_adapter
246
+ profiles.register(1, go: { import_path: 'example.test/client' }, rust: { crate_name: 'widget-client' })
247
+ finalize
248
+ expect(server).to have_received(:describe_version).once do |context|
249
+ expect(context).to have_attributes(server:, version: 1, current_user: nil, endpoint: false, doc: true)
250
+ end
251
+ expect(go).to have_received(:new).with(description: anything, import_path: anything, module_version: nil)
252
+ expect(rust).to have_received(:from_description).with(description: anything, api_version: '1', protocol_version: anything, mount_path: '/', crate_name: anything, crate_version: nil, source: nil)
253
+ end
254
+
255
+ it 'publishes no partial profiles and hides private diagnostics when finalization fails' do
256
+ install_go_adapter
257
+ rust = install_rust_adapter
258
+ allow(rust).to receive(:from_description).and_raise('HIDDEN_SCHEMA_DEFAULT')
259
+ profiles.register(1, go: { import_path: 'example.test/client' }, rust: { crate_name: 'widget-client' })
260
+ profiles.mount([1])
261
+ expect { profiles.finalize(server) }.to raise_error(described_class::Invalid, 'Configured documentation client schema or configuration is invalid')
262
+ expect(profiles.for_version(1)).to be_empty
263
+ expect { profiles.register(1, go: go_config) }.to raise_error(described_class::Invalid, /already mounted/)
94
264
  end
95
265
  end
@@ -0,0 +1,48 @@
1
+ require 'spec_helper'
2
+
3
+ describe HaveAPI::ClientExample::Profiles do
4
+ let(:schema) { HaveAPI::ClientExample.const_get(:StructuralSchema) }
5
+ let(:data) { { description: 'data description', default: { examples: ['data example'] }, message: 'data message' } }
6
+ let(:parameter) do
7
+ { type: 'Custom', default: data, description: 'private prose', label: 'private label',
8
+ validators: { include: { values: [data], message: 'private validation message' }, custom: data }, format: data }
9
+ end
10
+ let(:description) do
11
+ action = { path: '/v1/widgets/', input: { layout: :hash, parameters: { default: parameter } },
12
+ output: { parameters: {} }, meta: { global: { input: { parameters: { example: parameter } } } },
13
+ description: 'private action prose', examples: [{ request: data }] }
14
+ resource = { description: 'private resource prose', actions: { index: action }, resources: {} }
15
+ { resources: { widget: resource }, authentication: { token: { description: 'private auth prose', resources: { token: resource } } } }
16
+ end
17
+
18
+ it 'copies structural locations and strips defaults and prose without changing the server description' do
19
+ before = JSON.generate(description)
20
+ result = schema.copy(description)
21
+ action = result.dig('resources', 'widget', 'actions', 'index')
22
+ expect(action).not_to have_key('description')
23
+ expect(action).not_to have_key('examples')
24
+ expect(result.dig('resources', 'widget')).not_to have_key('description')
25
+ expect(result.dig('authentication', 'token')).not_to have_key('description')
26
+ expect(action.dig('input', 'parameters', 'default')).not_to have_key('default')
27
+ expect(action.dig('meta', 'global', 'input', 'parameters', 'example')).not_to have_key('default')
28
+ expect(result.dig('authentication', 'token', 'resources', 'token', 'actions', 'index')).not_to have_key('examples')
29
+ expect(JSON.generate(description)).to eq(before)
30
+ expect(description).not_to be_frozen
31
+ expect(result).to be_frozen
32
+ expect(action.dig('input', 'parameters', 'default')).to be_frozen
33
+ end
34
+
35
+ it 'retains parameter names, layouts, associations and arbitrary validator/Custom data verbatim' do
36
+ parameter[:resource] = %w[widget]
37
+ parameter[:choices] = { path: '/v1/widgets/', method: 'GET' }
38
+ result = schema.copy(description)
39
+ copied = result.dig('resources', 'widget', 'actions', 'index', 'input', 'parameters', 'default')
40
+ expect(copied).to include('type' => 'Custom', 'resource' => ['widget'], 'choices' => { 'path' => '/v1/widgets/', 'method' => 'GET' })
41
+ expect(copied['format']).to eq(JSON.parse(JSON.generate(data)))
42
+ expect(copied.dig('validators', 'include', 'values')).to eq([JSON.parse(JSON.generate(data))])
43
+ expect(copied.dig('validators', 'custom')).to eq(JSON.parse(JSON.generate(data)))
44
+ expect(copied.dig('validators', 'include')).not_to have_key('message')
45
+ expect(result.dig('resources', 'widget', 'actions', 'index', 'input', 'layout')).to eq('hash')
46
+ expect(result.dig('resources', 'widget', 'actions', 'index', 'path')).to eq('/v1/widgets/')
47
+ end
48
+ end
@@ -79,4 +79,14 @@ describe HaveAPI::ClientExample do
79
79
  expect(selected.description[:resources]).to have_key('widget')
80
80
  expect(selected.sample[:response]).to eq(name: 'documented widget')
81
81
  end
82
+
83
+ it 'prints a package identity without inventing an omitted version and escapes it once' do
84
+ profile = HaveAPI::ClientExample::Profile.new(language: :go, package_name: 'example.test/<client>&', package_version: nil, adapter: Object.new)
85
+ allow(app.settings.api_server).to receive(:documentation_client_profiles).and_return({ go: profile })
86
+ allow(HaveAPI::ClientExamples::GoClient).to receive(:render).and_return({ status: :code, code: 'example' })
87
+ get '/v1/'
88
+ expect(last_response.status).to eq(200)
89
+ expect(last_response.body).to include('<code>example.test/&lt;client&gt;&amp;</code></li>')
90
+ expect(last_response.body).not_to include('example.test/<client>', '&amp;lt;', 'version = "latest"')
91
+ end
82
92
  end
@@ -0,0 +1,79 @@
1
+ require 'spec_helper'
2
+ require 'base64'
3
+ require_relative '../../test_support/documentation_performance_fixture'
4
+
5
+ describe HaveAPI::Server, '#mount' do
6
+ let(:server) do
7
+ HaveAPI::DocumentationPerformanceFixture.build(resource_count: 3, actions_per_resource: 2)
8
+ end
9
+ let(:contexts) { [] }
10
+ let(:help_inputs) { [] }
11
+
12
+ before do
13
+ HaveAPI::ClientExample.clients.each do |client|
14
+ allow(client).to receive(:render).and_wrap_original do |original, kind, context:, **arguments|
15
+ contexts << context
16
+ original.call(kind, context:, **arguments)
17
+ end
18
+ end
19
+ allow(server).to receive(:describe_version).and_wrap_original do |original, *arguments|
20
+ original.call(*arguments).tap do |help|
21
+ help_inputs << [help, Marshal.dump(help)]
22
+ end
23
+ end
24
+ end
25
+
26
+ def request_as(user)
27
+ contexts.clear
28
+ Rack::MockRequest.new(server.app).get(
29
+ '/benchmark/api/v1/', 'HTTP_HOST' => 'example.test',
30
+ 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:fixture-only")}"
31
+ )
32
+ end
33
+
34
+ def expect_shared_request(response)
35
+ expect(response.status).to eq(200)
36
+ expect(response.body).to include('PUBLIC_SAMPLE_0', 'example-resource0-action0-0',
37
+ 'init-javascript-callback', 'init-javascript-async',
38
+ 'init-typescript-callback', 'init-typescript-async',
39
+ 'init-python-sync', 'init-python-async')
40
+ expect(contexts.length).to be > 20
41
+ description = contexts.first.description
42
+ contexts.each { |context| expect(context.description).to equal(description) }
43
+ expect(description).to be_frozen
44
+ description
45
+ end
46
+
47
+ it 'shares one description across real panels and isolates sequential admin/member/admin requests' do
48
+ first_admin = request_as('admin')
49
+ admin_description = expect_shared_request(first_admin)
50
+ privileged_markers = %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE
51
+ ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only]
52
+ privileged_markers.each { |marker| expect(first_admin.body).to include(marker) }
53
+
54
+ member = request_as('member')
55
+ member_description = expect_shared_request(member)
56
+ expect(member_description).not_to equal(admin_description)
57
+ privileged_markers.each { |marker| expect(member.body).not_to include(marker) }
58
+ expect(JSON.generate(contexts.map { |context| [context.description, context.action, context.sample] }))
59
+ .not_to match(/ADMIN_ONLY_|admin_field|admin_only/)
60
+ expect(member_description[:resources]['resource0'][:actions]).not_to have_key('admin_only')
61
+ selected = contexts.find { |context| context.action_name == 'action0' }
62
+ expect(selected.action[:input][:parameters]).not_to have_key(:admin_field)
63
+ expect(selected.sample[:request]).to include(enabled: false, count: 0, nullable: nil, value0: '')
64
+
65
+ last_admin = request_as('admin')
66
+ restored_description = expect_shared_request(last_admin)
67
+ expect(restored_description).not_to equal(admin_description)
68
+ expect(restored_description).not_to equal(member_description)
69
+ expect(restored_description).to eq(admin_description)
70
+ privileged_markers.each { |marker| expect(last_admin.body).to include(marker) }
71
+
72
+ help_inputs.each do |help, original|
73
+ expect(Marshal.dump(help)).to eq(original)
74
+ expect(help).not_to be_frozen
75
+ expect(help[:resources]).not_to be_frozen
76
+ expect(help[:resources]['resource0'][:actions]['action0'][:examples].first[:request]).not_to be_frozen
77
+ end
78
+ end
79
+ end