1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
|
# frozen_string_literal: true
module QA
module Resource
#
# This module includes methods that allow resource classes to be reused safely. It should be prepended to a new
# reusable version of an existing resource class. See Resource::Project and ReusableResource::Project for an example.
# Reusable resource classes must also be registered with a resource collection that will manage cleanup.
#
# @example Register a resource class with a collection
# QA::Resource::ReusableCollection.register_resource_classes do |collection|
# QA::Resource::ReusableProject.register(collection)
# end
module Reusable
attr_accessor :reuse,
:reuse_as
ResourceReuseError = Class.new(RuntimeError)
def self.prepended(base)
base.extend(ClassMethods)
end
# Gets an existing resource if it exists and the specified attributes of the resource are valid.
# Creates a new instance of the resource if it does not exist.
#
# @return [String] The URL of the resource.
def fabricate_via_api!
validate_reuse_preconditions
resource_web_url(api_get)
rescue Errors::ResourceNotFoundError
super
ensure
self.class.resources[reuse_as] ||= {
tests: Set.new,
resource: self
}
self.class.resources[reuse_as][:attributes] ||= all_attributes.index_with do |attribute_name|
instance_variable_get("@#{attribute_name}")
end
self.class.resources[reuse_as][:tests] << Runtime::Example.location
end
# Overrides remove_via_api! to log a debug message stating that removal will happen after the suite completes.
#
# @return [nil]
def remove_via_api!
QA::Runtime::Logger.debug("#{self.class.name} - deferring removal until after suite")
end
# Object comparison
#
# @param [QA::Resource::Base] other
# @return [Boolean]
def ==(other)
self.class <= other.class && comparable == other.comparable
end
# Confirms that reuse of the resource did not change it in a way that breaks later reuse.
# For example, this should fail if a reusable resource should have a specific name, but the name has been changed.
def validate_reuse
QA::Runtime::Logger.debug(["Validating a #{self.class.name} that was reused as #{reuse_as}", identifier].compact.join(' '))
fresh_resource = reference_resource
diff = reuse_validation_diff(fresh_resource)
if diff.present?
raise ResourceReuseError, <<~ERROR
The reused #{self.class.name} resource does not have the attributes expected.
The following change was found: #{diff}"
The resource's web_url is #{web_url}.
It was used in these tests: #{self.class.resources[reuse_as][:tests].to_a.join(', ')}
ERROR
end
ensure
fresh_resource.remove_via_api!
end
private
# Creates a new resource that can be compared to a reused resource, using the post body of the original.
# Must be implemented by classes that include this module.
def reference_resource
return super if defined?(super)
raise NotImplementedError
end
# Confirms that the resource attributes specified in its fabricate_via_api! block will allow it to be reused.
#
# @return [nil] returns nil unless an error is raised
def validate_reuse_preconditions
return unless self.class.resources.key?(reuse_as)
attributes = unique_identifiers.each_with_object({ proposed: {}, existing: {} }) do |id, attrs|
proposed = public_send(id)
existing = self.class.resources[reuse_as][:resource].public_send(id)
next if proposed == existing
attrs[:proposed][id] = proposed
attrs[:existing][id] = existing
end
unless attributes[:proposed].empty? && attributes[:existing].empty?
raise ResourceReuseError, "Reusable resources must use the same unique identifier(s). " \
"The #{self.class.name} to be reused as :#{reuse_as} has the identifier(s) #{attributes[:proposed]} " \
"but it should have #{attributes[:existing]}"
end
end
# Compares the attributes of the current reused resource with a reference instance.
#
# @return [Hash] any differences between the resources.
def reuse_validation_diff(other)
original, reference = prepare_reuse_validation_diff(other)
return if original == reference
diff_values = original.to_a - reference.to_a
diff_values.to_h
end
# Compares the current reusable resource to a reference instance, ignoring identifying unique attributes that
# had to be changed.
#
# @return [Hash, Hash] the current and reference resource attributes, respectively.
def prepare_reuse_validation_diff(other)
original = self.reload!.comparable
reference = other.reload!.comparable
unique_identifiers.each { |id| reference[id] = original[id] }
[original, reference]
end
# The attributes of the resource that should be the same whenever a test wants to reuse a resource. Must be
# implemented by classes that include this module.
#
# @return [Array<Symbol>] the attribute names.
def unique_identifiers
return super if defined?(super)
raise NotImplementedError
end
module ClassMethods
# Includes the resources created/reused by this class in the specified collection
def register(collection)
collection[self.name] = resources
end
# The resources created/reused by this resource class.
#
# @return [Hash<Symbol, Hash>] the resources created/reused by this resource class.
def resources
@resources ||= {}
end
end
end
end
end
|