iknow_view_models 2.8.4
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.
- checksums.yaml +7 -0
- data/.circleci/config.yml +115 -0
- data/.gitignore +36 -0
- data/.travis.yml +31 -0
- data/Appraisals +9 -0
- data/Gemfile +19 -0
- data/LICENSE.txt +22 -0
- data/README.md +19 -0
- data/Rakefile +21 -0
- data/appveyor.yml +22 -0
- data/gemfiles/rails_5_2.gemfile +15 -0
- data/gemfiles/rails_6_0_beta.gemfile +15 -0
- data/iknow_view_models.gemspec +49 -0
- data/lib/iknow_view_models.rb +12 -0
- data/lib/iknow_view_models/railtie.rb +8 -0
- data/lib/iknow_view_models/version.rb +5 -0
- data/lib/view_model.rb +333 -0
- data/lib/view_model/access_control.rb +154 -0
- data/lib/view_model/access_control/composed.rb +216 -0
- data/lib/view_model/access_control/open.rb +13 -0
- data/lib/view_model/access_control/read_only.rb +13 -0
- data/lib/view_model/access_control/tree.rb +264 -0
- data/lib/view_model/access_control_error.rb +10 -0
- data/lib/view_model/active_record.rb +383 -0
- data/lib/view_model/active_record/association_data.rb +178 -0
- data/lib/view_model/active_record/association_manipulation.rb +389 -0
- data/lib/view_model/active_record/cache.rb +265 -0
- data/lib/view_model/active_record/cache/cacheable_view.rb +51 -0
- data/lib/view_model/active_record/cloner.rb +113 -0
- data/lib/view_model/active_record/collection_nested_controller.rb +100 -0
- data/lib/view_model/active_record/controller.rb +77 -0
- data/lib/view_model/active_record/controller_base.rb +185 -0
- data/lib/view_model/active_record/nested_controller_base.rb +93 -0
- data/lib/view_model/active_record/singular_nested_controller.rb +34 -0
- data/lib/view_model/active_record/update_context.rb +252 -0
- data/lib/view_model/active_record/update_data.rb +749 -0
- data/lib/view_model/active_record/update_operation.rb +810 -0
- data/lib/view_model/active_record/visitor.rb +77 -0
- data/lib/view_model/after_transaction_runner.rb +29 -0
- data/lib/view_model/callbacks.rb +219 -0
- data/lib/view_model/changes.rb +62 -0
- data/lib/view_model/config.rb +29 -0
- data/lib/view_model/controller.rb +142 -0
- data/lib/view_model/deserialization_error.rb +437 -0
- data/lib/view_model/deserialize_context.rb +16 -0
- data/lib/view_model/error.rb +191 -0
- data/lib/view_model/error_view.rb +35 -0
- data/lib/view_model/record.rb +367 -0
- data/lib/view_model/record/attribute_data.rb +48 -0
- data/lib/view_model/reference.rb +31 -0
- data/lib/view_model/references.rb +48 -0
- data/lib/view_model/registry.rb +73 -0
- data/lib/view_model/schemas.rb +45 -0
- data/lib/view_model/serialization_error.rb +10 -0
- data/lib/view_model/serialize_context.rb +118 -0
- data/lib/view_model/test_helpers.rb +103 -0
- data/lib/view_model/test_helpers/arvm_builder.rb +111 -0
- data/lib/view_model/traversal_context.rb +126 -0
- data/lib/view_model/utils.rb +24 -0
- data/lib/view_model/utils/collections.rb +49 -0
- data/test/helpers/arvm_test_models.rb +59 -0
- data/test/helpers/arvm_test_utilities.rb +187 -0
- data/test/helpers/callback_tracer.rb +27 -0
- data/test/helpers/controller_test_helpers.rb +270 -0
- data/test/helpers/match_enumerator.rb +58 -0
- data/test/helpers/query_logging.rb +71 -0
- data/test/helpers/test_access_control.rb +56 -0
- data/test/helpers/viewmodel_spec_helpers.rb +326 -0
- data/test/unit/view_model/access_control_test.rb +769 -0
- data/test/unit/view_model/active_record/alias_test.rb +35 -0
- data/test/unit/view_model/active_record/belongs_to_test.rb +376 -0
- data/test/unit/view_model/active_record/cache_test.rb +351 -0
- data/test/unit/view_model/active_record/cloner_test.rb +313 -0
- data/test/unit/view_model/active_record/controller_test.rb +561 -0
- data/test/unit/view_model/active_record/counter_test.rb +80 -0
- data/test/unit/view_model/active_record/customization_test.rb +388 -0
- data/test/unit/view_model/active_record/has_many_test.rb +957 -0
- data/test/unit/view_model/active_record/has_many_through_poly_test.rb +269 -0
- data/test/unit/view_model/active_record/has_many_through_test.rb +736 -0
- data/test/unit/view_model/active_record/has_one_test.rb +334 -0
- data/test/unit/view_model/active_record/namespacing_test.rb +75 -0
- data/test/unit/view_model/active_record/optional_attribute_view_test.rb +58 -0
- data/test/unit/view_model/active_record/poly_test.rb +320 -0
- data/test/unit/view_model/active_record/shared_test.rb +285 -0
- data/test/unit/view_model/active_record/version_test.rb +121 -0
- data/test/unit/view_model/active_record_test.rb +542 -0
- data/test/unit/view_model/callbacks_test.rb +582 -0
- data/test/unit/view_model/deserialization_error/unique_violation_test.rb +73 -0
- data/test/unit/view_model/record_test.rb +524 -0
- data/test/unit/view_model/traversal_context_test.rb +371 -0
- data/test/unit/view_model_test.rb +62 -0
- metadata +490 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minitest/autorun'
|
|
4
|
+
require 'minitest/unit'
|
|
5
|
+
require 'rspec/expectations/minitest_integration'
|
|
6
|
+
|
|
7
|
+
require 'view_model'
|
|
8
|
+
require 'view_model/deserialization_error'
|
|
9
|
+
|
|
10
|
+
class ViewModel::DeserializationError::UniqueViolationTest < ActiveSupport::TestCase
|
|
11
|
+
extend Minitest::Spec::DSL
|
|
12
|
+
|
|
13
|
+
# Test error parser
|
|
14
|
+
describe 'message parser' do
|
|
15
|
+
let(:parse) { ViewModel::DeserializationError::UniqueViolation.parse_message_detail(detail_message) }
|
|
16
|
+
|
|
17
|
+
describe 'with a bad message prefix' do
|
|
18
|
+
let(:detail_message) { 'Unexpected (x)=(y) already exists.' }
|
|
19
|
+
|
|
20
|
+
it 'refuses to parse' do
|
|
21
|
+
expect(parse).to be_nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe 'with a bad message suffix' do
|
|
26
|
+
let(:detail_message) { 'Key (x)=(y) is already present.' }
|
|
27
|
+
|
|
28
|
+
it 'refuses to parse' do
|
|
29
|
+
expect(parse).to be_nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe 'with a single key and value' do
|
|
34
|
+
let(:detail_message) { 'Key (x)=(a) already exists.' }
|
|
35
|
+
|
|
36
|
+
it 'parses the key and value' do
|
|
37
|
+
expect(parse).to eq([['x'], 'a'])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe 'with multiple keys and values' do
|
|
42
|
+
let(:detail_message) { 'Key (x, y)=(a, b) already exists.' }
|
|
43
|
+
|
|
44
|
+
it 'parses the keys and value' do
|
|
45
|
+
expect(parse).to eq([['x', 'y'], 'a, b'])
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe 'with quoted keys and values' do
|
|
50
|
+
let(:detail_message) { 'Key ("x, y", z)=(a, b, c) already exists.' }
|
|
51
|
+
|
|
52
|
+
it 'parses the keys and value' do
|
|
53
|
+
expect(parse).to eq([['x, y', 'z'], 'a, b, c'])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe 'with nested quoted keys and values' do
|
|
58
|
+
let(:detail_message) { 'Key ("""x"", ""y""", z)=(a, b, c) already exists.' }
|
|
59
|
+
|
|
60
|
+
it 'parses the keys and value' do
|
|
61
|
+
expect(parse).to eq([['"x", "y"', 'z'], 'a, b, c'])
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe 'with unescaped values' do
|
|
66
|
+
let(:detail_message) { 'Key (a, b)=(a, b)=(c, d) already exists.' }
|
|
67
|
+
|
|
68
|
+
it 'parses the keys and value' do
|
|
69
|
+
expect(parse).to eq([['a', 'b'], 'a, b)=(c, d'])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../helpers/test_access_control.rb"
|
|
4
|
+
|
|
5
|
+
require "minitest/autorun"
|
|
6
|
+
require 'minitest/unit'
|
|
7
|
+
|
|
8
|
+
require "view_model"
|
|
9
|
+
require "view_model/record"
|
|
10
|
+
|
|
11
|
+
class ViewModel::RecordTest < ActiveSupport::TestCase
|
|
12
|
+
using ViewModel::Utils::Collections
|
|
13
|
+
extend Minitest::Spec::DSL
|
|
14
|
+
|
|
15
|
+
class TestDeserializeContext < ViewModel::DeserializeContext
|
|
16
|
+
class SharedContext < ViewModel::DeserializeContext::SharedContext
|
|
17
|
+
attr_reader :targets
|
|
18
|
+
def initialize(targets: [], **rest)
|
|
19
|
+
super(**rest)
|
|
20
|
+
@targets = targets
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.shared_context_class
|
|
25
|
+
SharedContext
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
delegate :targets, to: :shared_context
|
|
29
|
+
|
|
30
|
+
def initialize(**rest)
|
|
31
|
+
super(**rest)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class TestSerializeContext < ViewModel::SerializeContext
|
|
36
|
+
def initialize(**rest)
|
|
37
|
+
super(**rest)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class TestViewModel < ViewModel::Record
|
|
42
|
+
self.unregistered = true
|
|
43
|
+
|
|
44
|
+
def self.deserialize_context_class
|
|
45
|
+
TestDeserializeContext
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.serialize_context_class
|
|
49
|
+
TestSerializeContext
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.resolve_viewmodel(_metadata, _view_hash, deserialize_context:)
|
|
53
|
+
if (target_model = deserialize_context.targets.shift)
|
|
54
|
+
self.new(target_model)
|
|
55
|
+
else
|
|
56
|
+
self.for_new_model
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe 'VM::Record' do
|
|
62
|
+
let(:attributes) { {} }
|
|
63
|
+
let(:model_body) { nil }
|
|
64
|
+
let(:viewmodel_body) { nil }
|
|
65
|
+
|
|
66
|
+
let(:model_class) do
|
|
67
|
+
mb = model_body
|
|
68
|
+
Struct.new(*attributes.keys) do
|
|
69
|
+
class_eval(&mb) if mb
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
let(:viewmodel_class) do
|
|
74
|
+
mc = model_class
|
|
75
|
+
attrs = attributes
|
|
76
|
+
vmb = viewmodel_body
|
|
77
|
+
Class.new(TestViewModel) do
|
|
78
|
+
# Avoid the need for teardown. Registration is only necessary for
|
|
79
|
+
# associations.
|
|
80
|
+
self.unregistered = true
|
|
81
|
+
|
|
82
|
+
self.view_name = "Model"
|
|
83
|
+
self.model_class = mc
|
|
84
|
+
|
|
85
|
+
attrs.each { |a, opts| attribute(a, **opts) }
|
|
86
|
+
|
|
87
|
+
class_eval(&vmb) if vmb
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
let(:view_base) do
|
|
92
|
+
{
|
|
93
|
+
"_type" => "Model",
|
|
94
|
+
"_version" => 1,
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
let(:attribute_names) do
|
|
99
|
+
attributes.map do |model_attr_name, opts|
|
|
100
|
+
vm_attr_name = (opts[:as] || model_attr_name).to_s
|
|
101
|
+
[model_attr_name.to_s, vm_attr_name]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
let(:default_values) { {} }
|
|
106
|
+
let(:default_view_values) { default_values }
|
|
107
|
+
let(:default_model_values) { default_values }
|
|
108
|
+
|
|
109
|
+
let(:default_view) do
|
|
110
|
+
attribute_names.each_with_object(view_base.dup) do |(model_attr_name, vm_attr_name), view|
|
|
111
|
+
view[vm_attr_name] = default_view_values.fetch(vm_attr_name.to_sym, model_attr_name)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
let(:default_model) do
|
|
116
|
+
attr_values = attribute_names.map do |model_attr_name, _vm_attr_name|
|
|
117
|
+
default_model_values.fetch(model_attr_name.to_sym, model_attr_name)
|
|
118
|
+
end
|
|
119
|
+
model_class.new(*attr_values)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
let(:access_control) { TestAccessControl.new(true, true, true) }
|
|
123
|
+
|
|
124
|
+
let(:create_context) { TestDeserializeContext.new(access_control: access_control) }
|
|
125
|
+
|
|
126
|
+
# Prime our simplistic `resolve_viewmodel` with the desired models to update
|
|
127
|
+
let(:update_context) { TestDeserializeContext.new(targets: [default_model], access_control: access_control) }
|
|
128
|
+
|
|
129
|
+
def assert_edited(vm, **changes)
|
|
130
|
+
ref = vm.to_reference
|
|
131
|
+
assert(access_control.visible_checks.include?(ref))
|
|
132
|
+
assert(access_control.editable_checks.include?(ref))
|
|
133
|
+
assert_equal([ViewModel::Changes.new(**changes)],
|
|
134
|
+
access_control.all_valid_edit_changes(ref))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def assert_unchanged(vm)
|
|
138
|
+
ref = vm.to_reference
|
|
139
|
+
assert(access_control.visible_checks.include?(ref))
|
|
140
|
+
assert(access_control.editable_checks.include?(ref))
|
|
141
|
+
assert_equal([], access_control.all_valid_edit_changes(ref))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
module CanDeserializeToNew
|
|
145
|
+
def self.included(base)
|
|
146
|
+
base.instance_eval do
|
|
147
|
+
it "can deserialize to a new model" do
|
|
148
|
+
vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: create_context)
|
|
149
|
+
assert_equal(default_model, vm.model)
|
|
150
|
+
refute(default_model.equal?(vm.model))
|
|
151
|
+
|
|
152
|
+
all_view_attrs = attribute_names.map { |_mname, vname| vname }
|
|
153
|
+
assert_edited(vm, new: true, changed_attributes: all_view_attrs)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
module CanDeserializeToExisting
|
|
160
|
+
def self.included(base)
|
|
161
|
+
base.instance_eval do
|
|
162
|
+
it "can deserialize to existing model with no changes" do
|
|
163
|
+
vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
|
|
164
|
+
assert(default_model.equal?(vm.model))
|
|
165
|
+
|
|
166
|
+
assert_unchanged(vm)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
module CanSerialize
|
|
173
|
+
def self.included(base)
|
|
174
|
+
base.instance_eval do
|
|
175
|
+
it "can serialize to the expected view" do
|
|
176
|
+
h = viewmodel_class.new(default_model).to_hash
|
|
177
|
+
assert_equal(default_view, h)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe "with simple attribute" do
|
|
184
|
+
let(:attributes) { { simple: {} } }
|
|
185
|
+
include CanSerialize
|
|
186
|
+
include CanDeserializeToNew
|
|
187
|
+
include CanDeserializeToExisting
|
|
188
|
+
|
|
189
|
+
it "can be updated" do
|
|
190
|
+
new_view = default_view.merge("simple" => "changed")
|
|
191
|
+
|
|
192
|
+
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
193
|
+
|
|
194
|
+
assert(default_model.equal?(vm.model), "returned model was not the same")
|
|
195
|
+
assert_equal("changed", default_model.simple)
|
|
196
|
+
assert_edited(vm, changed_attributes: [:simple])
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it "rejects unknown attributes" do
|
|
200
|
+
view = default_view.merge("unknown" => "illegal")
|
|
201
|
+
ex = assert_raises(ViewModel::DeserializationError::UnknownAttribute) do
|
|
202
|
+
viewmodel_class.deserialize_from_view(view, deserialize_context: create_context)
|
|
203
|
+
end
|
|
204
|
+
assert_equal("unknown", ex.attribute)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it "can prune an attribute" do
|
|
208
|
+
h = viewmodel_class.new(default_model).to_hash(serialize_context: TestSerializeContext.new(prune: [:simple]))
|
|
209
|
+
pruned_view = default_view.tap { |v| v.delete("simple") }
|
|
210
|
+
assert_equal(pruned_view, h)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it "edit checks when creating empty" do
|
|
214
|
+
vm = viewmodel_class.deserialize_from_view(view_base, deserialize_context: create_context)
|
|
215
|
+
refute(default_model.equal?(vm.model), "returned model was the same")
|
|
216
|
+
assert_edited(vm, new: true)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
describe "with validated simple attribute" do
|
|
221
|
+
let(:attributes) { { validated: {} } }
|
|
222
|
+
let(:viewmodel_body) do
|
|
223
|
+
->(_x) do
|
|
224
|
+
def validate!
|
|
225
|
+
if validated == "naughty"
|
|
226
|
+
raise ViewModel::DeserializationError::Validation.new("validated", "was naughty", nil, self.blame_reference)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
include CanSerialize
|
|
233
|
+
include CanDeserializeToNew
|
|
234
|
+
include CanDeserializeToExisting
|
|
235
|
+
|
|
236
|
+
it "rejects update when validation fails" do
|
|
237
|
+
new_view = default_view.merge("validated" => "naughty")
|
|
238
|
+
|
|
239
|
+
ex = assert_raises(ViewModel::DeserializationError::Validation) do
|
|
240
|
+
viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
241
|
+
end
|
|
242
|
+
assert_equal("validated", ex.attribute)
|
|
243
|
+
assert_equal("was naughty", ex.reason)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
describe "with renamed attribute" do
|
|
248
|
+
let(:attributes) { { modelname: { as: :viewname } } }
|
|
249
|
+
let(:default_model_values) { { modelname: "value" } }
|
|
250
|
+
let(:default_view_values) { { viewname: "value" } }
|
|
251
|
+
|
|
252
|
+
include CanSerialize
|
|
253
|
+
include CanDeserializeToNew
|
|
254
|
+
include CanDeserializeToExisting
|
|
255
|
+
|
|
256
|
+
it "makes attributes available on their new names" do
|
|
257
|
+
value(default_model.modelname).must_equal("value")
|
|
258
|
+
vm = viewmodel_class.new(default_model)
|
|
259
|
+
value(vm.viewname).must_equal("value")
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
describe "with formatted attribute" do
|
|
264
|
+
let(:attributes) { { moment: { format: IknowParams::Serializer::Time } } }
|
|
265
|
+
let(:moment) { 1.week.ago.change(usec: 0) }
|
|
266
|
+
let(:default_model_values) { { moment: moment } }
|
|
267
|
+
let(:default_view_values) { { moment: moment.iso8601 } }
|
|
268
|
+
|
|
269
|
+
include CanSerialize
|
|
270
|
+
include CanDeserializeToNew
|
|
271
|
+
include CanDeserializeToExisting
|
|
272
|
+
|
|
273
|
+
it "raises correctly on an unparseable value" do
|
|
274
|
+
bad_view = default_view.tap { |v| v["moment"] = "not a timestamp" }
|
|
275
|
+
ex = assert_raises(ViewModel::DeserializationError::Validation) do
|
|
276
|
+
viewmodel_class.deserialize_from_view(bad_view, deserialize_context: create_context)
|
|
277
|
+
end
|
|
278
|
+
assert_equal('moment', ex.attribute)
|
|
279
|
+
assert_match(/could not be deserialized because.*Time/, ex.detail)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "raises correctly on an undeserializable value" do
|
|
283
|
+
bad_model = default_model.tap { |m| m.moment = 2.7 }
|
|
284
|
+
ex = assert_raises(ViewModel::SerializationError) do
|
|
285
|
+
viewmodel_class.new(bad_model).to_hash
|
|
286
|
+
end
|
|
287
|
+
assert_match(/Could not serialize invalid value.*'moment'.*Incorrect type/, ex.detail)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
describe "with read-only attribute" do
|
|
292
|
+
let(:attributes) { { read_only: { read_only: true } } }
|
|
293
|
+
|
|
294
|
+
include CanSerialize
|
|
295
|
+
include CanDeserializeToExisting
|
|
296
|
+
|
|
297
|
+
it "deserializes to new without the attribute" do
|
|
298
|
+
new_view = default_view.tap { |v| v.delete("read_only") }
|
|
299
|
+
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: create_context)
|
|
300
|
+
refute(default_model.equal?(vm.model))
|
|
301
|
+
assert_nil(vm.model.read_only)
|
|
302
|
+
assert_edited(vm, new: true)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it "rejects deserialize from new" do
|
|
306
|
+
ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
|
|
307
|
+
viewmodel_class.deserialize_from_view(default_view, deserialize_context: create_context)
|
|
308
|
+
end
|
|
309
|
+
assert_equal("read_only", ex.attribute)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "rejects update if changed" do
|
|
313
|
+
new_view = default_view.merge("read_only" => "written")
|
|
314
|
+
ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
|
|
315
|
+
viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
316
|
+
end
|
|
317
|
+
assert_equal("read_only", ex.attribute)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
describe "with read-only write-once attribute" do
|
|
322
|
+
let(:attributes) { { write_once: { read_only: true, write_once: true } } }
|
|
323
|
+
let(:model_body) do
|
|
324
|
+
->(x) do
|
|
325
|
+
# For the purposes of testing, we assume a record is new and can be
|
|
326
|
+
# written once to if write_once is nil. We will never write a nil.
|
|
327
|
+
def new_record?
|
|
328
|
+
write_once.nil?
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
include CanSerialize
|
|
334
|
+
include CanDeserializeToNew
|
|
335
|
+
include CanDeserializeToExisting
|
|
336
|
+
|
|
337
|
+
it "rejects change to attribute" do
|
|
338
|
+
new_view = default_view.merge("write_once" => "written")
|
|
339
|
+
ex = assert_raises(ViewModel::DeserializationError::ReadOnlyAttribute) do
|
|
340
|
+
viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
341
|
+
end
|
|
342
|
+
assert_equal("write_once", ex.attribute)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
describe "with custom serialization" do
|
|
347
|
+
let(:attributes) { { overridden: {} } }
|
|
348
|
+
let(:default_view_values) { { overridden: 10 } }
|
|
349
|
+
let(:default_model_values) { { overridden: 5 } }
|
|
350
|
+
let(:viewmodel_body) do
|
|
351
|
+
->(x) do
|
|
352
|
+
def serialize_overridden(json, serialize_context:)
|
|
353
|
+
json.overridden model.overridden.try { |o| o * 2 }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def deserialize_overridden(value, references:, deserialize_context:)
|
|
357
|
+
before_value = model.overridden
|
|
358
|
+
model.overridden = value.try { |v| Integer(v) / 2 }
|
|
359
|
+
attribute_changed!(:overridden) unless before_value == model.overridden
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
include CanSerialize
|
|
365
|
+
include CanDeserializeToNew
|
|
366
|
+
include CanDeserializeToExisting
|
|
367
|
+
|
|
368
|
+
it "can be updated" do
|
|
369
|
+
new_view = default_view.merge("overridden" => "20")
|
|
370
|
+
|
|
371
|
+
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
372
|
+
|
|
373
|
+
assert(default_model.equal?(vm.model), "returned model was not the same")
|
|
374
|
+
assert_equal(10, default_model.overridden)
|
|
375
|
+
|
|
376
|
+
assert_edited(vm, changed_attributes: [:overridden])
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
describe "with optional attributes" do
|
|
381
|
+
let(:attributes) { { optional: { optional: true } } }
|
|
382
|
+
|
|
383
|
+
include CanDeserializeToNew
|
|
384
|
+
include CanDeserializeToExisting
|
|
385
|
+
|
|
386
|
+
it "can serialize with the optional attribute" do
|
|
387
|
+
h = viewmodel_class.new(default_model).to_hash(serialize_context: TestSerializeContext.new(include: [:optional]))
|
|
388
|
+
assert_equal(default_view, h)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it "can serialize without the optional attribute" do
|
|
392
|
+
h = viewmodel_class.new(default_model).to_hash
|
|
393
|
+
pruned_view = default_view.tap { |v| v.delete("optional") }
|
|
394
|
+
assert_equal(pruned_view, h)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
Nested = Struct.new(:member)
|
|
399
|
+
|
|
400
|
+
class NestedView < TestViewModel
|
|
401
|
+
self.view_name = "Nested"
|
|
402
|
+
self.model_class = Nested
|
|
403
|
+
attribute :member
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
describe "with nested viewmodel" do
|
|
407
|
+
let(:default_nested_model) { Nested.new("member") }
|
|
408
|
+
let(:default_nested_view) { view_base.merge("_type" => "Nested", "member" => "member") }
|
|
409
|
+
|
|
410
|
+
let(:attributes) {{ simple: {}, nested: { using: NestedView } }}
|
|
411
|
+
|
|
412
|
+
let(:default_view_values) { { nested: default_nested_view } }
|
|
413
|
+
let(:default_model_values) { { nested: default_nested_model } }
|
|
414
|
+
|
|
415
|
+
let(:update_context) { TestDeserializeContext.new(targets: [default_model, default_nested_model],
|
|
416
|
+
access_control: access_control) }
|
|
417
|
+
|
|
418
|
+
include CanSerialize
|
|
419
|
+
include CanDeserializeToNew
|
|
420
|
+
include CanDeserializeToExisting
|
|
421
|
+
|
|
422
|
+
it "can update the nested value" do
|
|
423
|
+
new_view = default_view.merge("nested" => default_nested_view.merge("member" => "changed"))
|
|
424
|
+
|
|
425
|
+
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
426
|
+
|
|
427
|
+
assert(default_model.equal?(vm.model), "returned model was not the same")
|
|
428
|
+
assert(default_nested_model.equal?(vm.model.nested), "returned nested model was not the same")
|
|
429
|
+
|
|
430
|
+
assert_equal("changed", default_model.nested.member)
|
|
431
|
+
|
|
432
|
+
assert_unchanged(vm)
|
|
433
|
+
assert_edited(vm.nested, changed_attributes: [:member])
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
it "can replace the nested value" do
|
|
437
|
+
# The value will be unified if it is different after deserialization
|
|
438
|
+
new_view = default_view.merge("nested" => default_nested_view.merge("member" => "changed"))
|
|
439
|
+
|
|
440
|
+
partial_update_context = TestDeserializeContext.new(targets: [default_model],
|
|
441
|
+
access_control: access_control)
|
|
442
|
+
|
|
443
|
+
vm = viewmodel_class.deserialize_from_view(new_view, deserialize_context: partial_update_context)
|
|
444
|
+
|
|
445
|
+
assert(default_model.equal?(vm.model), "returned model was not the same")
|
|
446
|
+
refute(default_nested_model.equal?(vm.model.nested), "returned nested model was the same")
|
|
447
|
+
|
|
448
|
+
assert_edited(vm, new: false, changed_attributes: [:nested])
|
|
449
|
+
assert_edited(vm.nested, new: true, changed_attributes: [:member])
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
it "can prune attributes in the nested value" do
|
|
453
|
+
h = viewmodel_class.new(default_model).to_hash(
|
|
454
|
+
serialize_context: TestSerializeContext.new(prune: { nested: [:member] }))
|
|
455
|
+
|
|
456
|
+
pruned_view = default_view.tap { |v| v["nested"].delete("member") }
|
|
457
|
+
assert_equal(pruned_view, h)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
describe "with array of nested viewmodel" do
|
|
462
|
+
let(:default_nested_model_1) { Nested.new("member1") }
|
|
463
|
+
let(:default_nested_view_1) { view_base.merge("_type" => "Nested", "member" => "member1") }
|
|
464
|
+
|
|
465
|
+
let(:default_nested_model_2) { Nested.new("member2") }
|
|
466
|
+
let(:default_nested_view_2) { view_base.merge("_type" => "Nested", "member" => "member2") }
|
|
467
|
+
|
|
468
|
+
let(:attributes) {{ simple: {}, nested: { using: NestedView, array: true } }}
|
|
469
|
+
|
|
470
|
+
let(:default_view_values) { { nested: [default_nested_view_1, default_nested_view_2] } }
|
|
471
|
+
let(:default_model_values) { { nested: [default_nested_model_1, default_nested_model_2] } }
|
|
472
|
+
|
|
473
|
+
let(:update_context) {
|
|
474
|
+
TestDeserializeContext.new(targets: [default_model, default_nested_model_1, default_nested_model_2],
|
|
475
|
+
access_control: access_control)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
include CanSerialize
|
|
479
|
+
include CanDeserializeToNew
|
|
480
|
+
include CanDeserializeToExisting
|
|
481
|
+
|
|
482
|
+
it "rejects change to attribute" do
|
|
483
|
+
new_view = default_view.merge("nested" => "terrible")
|
|
484
|
+
ex = assert_raises(ViewModel::DeserializationError::InvalidAttributeType) do
|
|
485
|
+
viewmodel_class.deserialize_from_view(new_view, deserialize_context: update_context)
|
|
486
|
+
end
|
|
487
|
+
assert_equal("nested", ex.attribute)
|
|
488
|
+
assert_equal("Array", ex.expected_type)
|
|
489
|
+
assert_equal("String", ex.provided_type)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
it "can edit a nested value" do
|
|
493
|
+
default_view["nested"][0]["member"] = "changed"
|
|
494
|
+
vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
|
|
495
|
+
assert(default_model.equal?(vm.model), "returned model was not the same")
|
|
496
|
+
assert_equal(2, vm.model.nested.size)
|
|
497
|
+
assert(default_nested_model_1.equal?(vm.model.nested[0]))
|
|
498
|
+
assert(default_nested_model_2.equal?(vm.model.nested[1]))
|
|
499
|
+
|
|
500
|
+
assert_unchanged(vm)
|
|
501
|
+
assert_edited(vm.nested[0], changed_attributes: [:member])
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
it "can append a nested value" do
|
|
505
|
+
default_view["nested"] << view_base.merge("_type" => "Nested", "member" => "member3")
|
|
506
|
+
|
|
507
|
+
vm = viewmodel_class.deserialize_from_view(default_view, deserialize_context: update_context)
|
|
508
|
+
|
|
509
|
+
assert(default_model.equal?(vm.model), "returned model was not the same")
|
|
510
|
+
assert_equal(3, vm.model.nested.size)
|
|
511
|
+
assert(default_nested_model_1.equal?(vm.model.nested[0]))
|
|
512
|
+
assert(default_nested_model_2.equal?(vm.model.nested[1]))
|
|
513
|
+
|
|
514
|
+
vm.model.nested.each_with_index do |nvm, i|
|
|
515
|
+
assert_equal("member#{i+1}", nvm.member)
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
assert_edited(vm, changed_attributes: [:nested])
|
|
519
|
+
assert_edited(vm.nested[2], new: true, changed_attributes: [:member])
|
|
520
|
+
end
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
end
|