familia 2.0.0.pre7 → 2.0.0.pre8
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 +4 -4
- data/Gemfile +1 -1
- data/Gemfile.lock +3 -3
- data/README.md +35 -0
- data/docs/wiki/Feature-System-Guide.md +0 -15
- data/lib/familia/features/external_identifiers/external_identifier_field_type.rb +120 -0
- data/lib/familia/features/external_identifiers.rb +111 -0
- data/lib/familia/features/object_identifiers/object_identifier_field_type.rb +91 -0
- data/lib/familia/features/object_identifiers.rb +194 -0
- data/lib/familia/features/relationships/cascading.rb +0 -1
- data/lib/familia/features/relationships/indexing.rb +0 -1
- data/lib/familia/features/relationships/membership.rb +0 -1
- data/lib/familia/features/relationships/querying.rb +7 -12
- data/lib/familia/features/relationships/score_encoding.rb +1 -3
- data/lib/familia/features/relationships/tracking.rb +0 -1
- data/lib/familia/features/transient_fields.rb +8 -10
- data/lib/familia/features.rb +16 -13
- data/lib/familia/horreum/core/serialization.rb +2 -5
- data/lib/familia/horreum/subclass/definition.rb +34 -0
- data/lib/familia/version.rb +1 -3
- data/try/core/errors_try.rb +1 -1
- data/try/features/{encrypted_fields_core_try.rb → encrypted_fields/encrypted_fields_core_try.rb} +1 -1
- data/try/features/{encrypted_fields_integration_try.rb → encrypted_fields/encrypted_fields_integration_try.rb} +1 -1
- data/try/features/{encrypted_fields_no_cache_security_try.rb → encrypted_fields/encrypted_fields_no_cache_security_try.rb} +1 -1
- data/try/features/{encrypted_fields_security_try.rb → encrypted_fields/encrypted_fields_security_try.rb} +1 -1
- data/try/features/{expiration_try.rb → expiration/expiration_try.rb} +1 -1
- data/try/features/external_identifiers/external_identifiers_try.rb +203 -0
- data/try/features/object_identifiers/object_identifiers_integration_try.rb +289 -0
- data/try/features/object_identifiers/object_identifiers_try.rb +191 -0
- data/try/features/{quantization_try.rb → quantization/quantization_try.rb} +1 -1
- data/try/features/{categorical_permissions_try.rb → relationships/categorical_permissions_try.rb} +1 -1
- data/try/features/{relationships_edge_cases_try.rb → relationships/relationships_edge_cases_try.rb} +1 -1
- data/try/features/{relationships_performance_minimal_try.rb → relationships/relationships_performance_minimal_try.rb} +1 -1
- data/try/features/{relationships_performance_simple_try.rb → relationships/relationships_performance_simple_try.rb} +1 -1
- data/try/features/{relationships_performance_try.rb → relationships/relationships_performance_try.rb} +1 -1
- data/try/features/{relationships_performance_working_try.rb → relationships/relationships_performance_working_try.rb} +1 -1
- data/try/features/{relationships_try.rb → relationships/relationships_try.rb} +1 -1
- data/try/features/{safe_dump_advanced_try.rb → safe_dump/safe_dump_advanced_try.rb} +1 -1
- data/try/features/{safe_dump_try.rb → safe_dump/safe_dump_try.rb} +1 -1
- data/try/features/{transient_fields_core_try.rb → transient_fields/transient_fields_core_try.rb} +1 -1
- data/try/features/{transient_fields_integration_try.rb → transient_fields/transient_fields_integration_try.rb} +1 -1
- metadata +38 -31
- /data/try/features/{encryption_fields → encrypted_fields}/aad_protection_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/concealed_string_core_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/context_isolation_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/error_conditions_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/fresh_key_derivation_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/fresh_key_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/key_rotation_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/memory_security_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/missing_current_key_version_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/nonce_uniqueness_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/secure_by_default_behavior_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/thread_safety_try.rb +0 -0
- /data/try/features/{encryption_fields → encrypted_fields}/universal_serialization_safety_try.rb +0 -0
@@ -303,17 +303,15 @@ module Familia
|
|
303
303
|
permission_bits = decoded[:permissions]
|
304
304
|
|
305
305
|
# Check if this member has the required permission bits
|
306
|
-
if (permission_bits & required_bits) == required_bits
|
307
|
-
valid_members << [score, member]
|
308
|
-
end
|
306
|
+
valid_members << [score, member] if (permission_bits & required_bits) == required_bits
|
309
307
|
end
|
310
308
|
|
311
309
|
# Recreate filtered collection if we have valid members
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
310
|
+
next unless valid_members.any?
|
311
|
+
|
312
|
+
dbclient.zadd(filtered_key, valid_members)
|
313
|
+
dbclient.expire(filtered_key, 300) # Temporary key cleanup
|
314
|
+
filtered_keys << filtered_key
|
317
315
|
end
|
318
316
|
|
319
317
|
filtered_keys
|
@@ -341,9 +339,7 @@ module Familia
|
|
341
339
|
permission_bits = decoded[:permissions]
|
342
340
|
|
343
341
|
# Check if this member has the required permission bits
|
344
|
-
if (permission_bits & required_bits) == required_bits
|
345
|
-
valid_members << [score, member]
|
346
|
-
end
|
342
|
+
valid_members << [score, member] if (permission_bits & required_bits) == required_bits
|
347
343
|
end
|
348
344
|
|
349
345
|
# Create filtered collection
|
@@ -613,7 +609,6 @@ module Familia
|
|
613
609
|
collection_info
|
614
610
|
end
|
615
611
|
end
|
616
|
-
|
617
612
|
end
|
618
613
|
end
|
619
614
|
end
|
@@ -40,7 +40,7 @@ module Familia
|
|
40
40
|
configure: 0b00010000, # 16 - Change settings
|
41
41
|
delete: 0b00100000, # 32 - Remove items
|
42
42
|
transfer: 0b01000000, # 64 - Change ownership
|
43
|
-
admin: 0b10000000
|
43
|
+
admin: 0b10000000, # 128 - Full control
|
44
44
|
}.freeze
|
45
45
|
|
46
46
|
# Predefined permission combinations
|
@@ -60,7 +60,6 @@ module Familia
|
|
60
60
|
owner: 0b11111111 # All permissions
|
61
61
|
}.freeze
|
62
62
|
|
63
|
-
|
64
63
|
class << self
|
65
64
|
# Get permission bit flag value for a permission symbol
|
66
65
|
#
|
@@ -93,7 +92,6 @@ module Familia
|
|
93
92
|
}
|
94
93
|
end
|
95
94
|
|
96
|
-
|
97
95
|
# Encode a timestamp and permissions into a Redis score
|
98
96
|
#
|
99
97
|
# @param timestamp [Time, Integer] The timestamp to encode
|
@@ -182,9 +182,7 @@ module Familia
|
|
182
182
|
def clear_transient_fields!
|
183
183
|
self.class.transient_fields.each do |field_name|
|
184
184
|
field_value = instance_variable_get("@#{field_name}")
|
185
|
-
if field_value.respond_to?(:clear!)
|
186
|
-
field_value.clear!
|
187
|
-
end
|
185
|
+
field_value.clear! if field_value.respond_to?(:clear!)
|
188
186
|
end
|
189
187
|
end
|
190
188
|
|
@@ -213,13 +211,13 @@ module Familia
|
|
213
211
|
def transient_fields_summary
|
214
212
|
self.class.transient_fields.each_with_object({}) do |field_name, summary|
|
215
213
|
field_value = instance_variable_get("@#{field_name}")
|
216
|
-
if field_value.nil?
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
214
|
+
summary[field_name] = if field_value.nil?
|
215
|
+
nil
|
216
|
+
elsif field_value.respond_to?(:cleared?) && field_value.cleared?
|
217
|
+
'[CLEARED]'
|
218
|
+
else
|
219
|
+
'[REDACTED]'
|
220
|
+
end
|
223
221
|
end
|
224
222
|
end
|
225
223
|
|
data/lib/familia/features.rb
CHANGED
@@ -1,17 +1,15 @@
|
|
1
1
|
# lib/familia/features.rb
|
2
2
|
|
3
3
|
module Familia
|
4
|
-
|
5
4
|
FeatureDefinition = Data.define(:name, :depends_on)
|
6
5
|
|
7
6
|
# Familia::Features
|
8
7
|
#
|
9
8
|
module Features
|
10
|
-
|
11
9
|
@features_enabled = nil
|
12
10
|
attr_reader :features_enabled
|
13
11
|
|
14
|
-
def feature(feature_name = nil)
|
12
|
+
def feature(feature_name = nil, **options)
|
15
13
|
@features_enabled ||= []
|
16
14
|
|
17
15
|
return features_enabled if feature_name.nil?
|
@@ -28,22 +26,28 @@ module Familia
|
|
28
26
|
return
|
29
27
|
end
|
30
28
|
|
31
|
-
if Familia.debug?
|
32
|
-
|
29
|
+
Familia.trace :FEATURE, nil, "#{self} includes #{feature_name.inspect}", caller(1..1) if Familia.debug?
|
30
|
+
|
31
|
+
# Check dependencies and raise error if missing
|
32
|
+
feature_def = Familia::Base.feature_definitions[feature_name]
|
33
|
+
if feature_def&.depends_on&.any?
|
34
|
+
missing = feature_def.depends_on - features_enabled
|
35
|
+
if missing.any?
|
36
|
+
raise Familia::Problem,
|
37
|
+
"Feature #{feature_name} requires missing dependencies: #{missing.join(', ')}"
|
38
|
+
end
|
33
39
|
end
|
34
40
|
|
35
41
|
# Add it to the list available features_enabled for Familia::Base classes.
|
36
42
|
features_enabled << feature_name
|
37
43
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
feature_def = Familia::Base.feature_definitions[feature_name]
|
42
|
-
if feature_def&.depends_on&.any?
|
43
|
-
missing = feature_def.depends_on - features_enabled
|
44
|
-
raise Familia::Problem, "#{feature_name} requires: #{missing.join(', ')}" if missing.any?
|
44
|
+
# Store feature options if any were provided using the new pattern
|
45
|
+
if options.any?
|
46
|
+
add_feature_options(feature_name, **options)
|
45
47
|
end
|
46
48
|
|
49
|
+
klass = Familia::Base.features_available[feature_name]
|
50
|
+
|
47
51
|
# Extend the Familia::Base subclass (e.g. Customer) with the feature module
|
48
52
|
include klass
|
49
53
|
|
@@ -58,7 +62,6 @@ module Familia
|
|
58
62
|
# We'd need to extend the DataType instances for each Horreum subclass. That
|
59
63
|
# avoids it getting included multiple times per DataType
|
60
64
|
end
|
61
|
-
|
62
65
|
end
|
63
66
|
end
|
64
67
|
|
@@ -135,7 +135,7 @@ module Familia
|
|
135
135
|
multi.hmset(dbkey, to_h_for_storage)
|
136
136
|
end
|
137
137
|
|
138
|
-
result.is_a?(Array)
|
138
|
+
result.is_a?(Array) # transaction succeeded
|
139
139
|
end
|
140
140
|
end
|
141
141
|
|
@@ -463,9 +463,7 @@ module Familia
|
|
463
463
|
#
|
464
464
|
def serialize_value(val)
|
465
465
|
# Security: Handle ConcealedString safely - extract encrypted data for storage
|
466
|
-
if val.respond_to?(:encrypted_value)
|
467
|
-
return val.encrypted_value
|
468
|
-
end
|
466
|
+
return val.encrypted_value if val.respond_to?(:encrypted_value)
|
469
467
|
|
470
468
|
prepared = Familia.distinguisher(val, strict_values: false)
|
471
469
|
|
@@ -530,6 +528,5 @@ module Familia
|
|
530
528
|
end
|
531
529
|
end
|
532
530
|
end
|
533
|
-
|
534
531
|
end
|
535
532
|
end
|
@@ -235,6 +235,40 @@ module Familia
|
|
235
235
|
field_types[field_type.name] = field_type
|
236
236
|
end
|
237
237
|
|
238
|
+
# Get feature options for a specific feature or all features
|
239
|
+
#
|
240
|
+
# @param feature_name [Symbol, nil] The feature name to get options for
|
241
|
+
# @return [Hash] The options hash for the feature, or empty hash if none
|
242
|
+
#
|
243
|
+
def feature_options(feature_name = nil)
|
244
|
+
@feature_options ||= {}
|
245
|
+
return @feature_options if feature_name.nil?
|
246
|
+
|
247
|
+
@feature_options[feature_name.to_sym] || {}
|
248
|
+
end
|
249
|
+
|
250
|
+
# Add feature options for a specific feature
|
251
|
+
#
|
252
|
+
# This method provides a clean way for features to set their default options
|
253
|
+
# without worrying about initialization state. Similar to register_field_type
|
254
|
+
# for field types.
|
255
|
+
#
|
256
|
+
# @param feature_name [Symbol] The feature name
|
257
|
+
# @param options [Hash] The options to add/merge
|
258
|
+
# @return [Hash] The updated options for the feature
|
259
|
+
#
|
260
|
+
def add_feature_options(feature_name, **options)
|
261
|
+
@feature_options ||= {}
|
262
|
+
@feature_options[feature_name.to_sym] ||= {}
|
263
|
+
|
264
|
+
# Only set defaults for options that don't already exist
|
265
|
+
options.each do |key, value|
|
266
|
+
@feature_options[feature_name.to_sym][key] ||= value
|
267
|
+
end
|
268
|
+
|
269
|
+
@feature_options[feature_name.to_sym]
|
270
|
+
end
|
271
|
+
|
238
272
|
# Create and register a transient field type
|
239
273
|
#
|
240
274
|
# @param name [Symbol] The field name
|
data/lib/familia/version.rb
CHANGED
data/try/core/errors_try.rb
CHANGED
@@ -3,7 +3,7 @@
|
|
3
3
|
# Security tests for the no-cache encryption strategy
|
4
4
|
# These tests verify that we maintain security properties by NOT caching derived keys
|
5
5
|
|
6
|
-
require_relative '
|
6
|
+
require_relative '../../helpers/test_helpers'
|
7
7
|
|
8
8
|
test_keys = {
|
9
9
|
v1: Base64.strict_encode64('a' * 32),
|
@@ -0,0 +1,203 @@
|
|
1
|
+
# try/features/external_identifiers_try.rb
|
2
|
+
|
3
|
+
require_relative '../../helpers/test_helpers'
|
4
|
+
|
5
|
+
Familia.debug = false
|
6
|
+
|
7
|
+
# Test ExternalIdentifiers feature functionality
|
8
|
+
|
9
|
+
# Basic class using external identifiers
|
10
|
+
class ExternalIdTest < Familia::Horreum
|
11
|
+
feature :object_identifiers
|
12
|
+
feature :external_identifiers
|
13
|
+
identifier_field :id
|
14
|
+
field :id
|
15
|
+
field :name
|
16
|
+
end
|
17
|
+
|
18
|
+
# Class with custom prefix
|
19
|
+
class CustomPrefixTest < Familia::Horreum
|
20
|
+
feature :object_identifiers
|
21
|
+
feature :external_identifiers, prefix: 'cust'
|
22
|
+
identifier_field :id
|
23
|
+
field :id
|
24
|
+
field :name
|
25
|
+
end
|
26
|
+
|
27
|
+
# Class testing data integrity preservation
|
28
|
+
class ExternalDataIntegrityTest < Familia::Horreum
|
29
|
+
feature :object_identifiers
|
30
|
+
feature :external_identifiers
|
31
|
+
identifier_field :id
|
32
|
+
field :id
|
33
|
+
field :name
|
34
|
+
end
|
35
|
+
|
36
|
+
# Test with existing external ID during initialization
|
37
|
+
@existing_ext_obj = ExternalDataIntegrityTest.new(id: 'test_id', extid: 'preset_ext_123', name: 'Preset External')
|
38
|
+
|
39
|
+
# Test objects for lazy generation and complex initialization
|
40
|
+
@lazy_obj = ExternalIdTest.new
|
41
|
+
@complex_obj = ExternalIdTest.new(id: 'complex_ext', name: 'Complex External')
|
42
|
+
|
43
|
+
## Feature depends on object_identifiers
|
44
|
+
ExternalIdTest.features_enabled.include?(:object_identifiers)
|
45
|
+
#==> true
|
46
|
+
|
47
|
+
## External identifiers feature is included
|
48
|
+
ExternalIdTest.features_enabled.include?(:external_identifiers)
|
49
|
+
#==> true
|
50
|
+
|
51
|
+
## Class has extid field defined
|
52
|
+
ExternalIdTest.respond_to?(:extid)
|
53
|
+
#==> true
|
54
|
+
|
55
|
+
## Object has extid accessor
|
56
|
+
obj = ExternalIdTest.new
|
57
|
+
obj.respond_to?(:extid)
|
58
|
+
#==> true
|
59
|
+
|
60
|
+
## External ID is generated from objid deterministically
|
61
|
+
obj = ExternalIdTest.new
|
62
|
+
obj.id = 'test_obj'
|
63
|
+
obj.name = 'Test Object'
|
64
|
+
objid = obj.objid
|
65
|
+
extid = obj.extid
|
66
|
+
# Same objid should always produce same extid
|
67
|
+
obj2 = ExternalIdTest.new
|
68
|
+
obj2.instance_variable_set(:@objid, objid)
|
69
|
+
obj2.extid == extid
|
70
|
+
#==> true
|
71
|
+
|
72
|
+
## External ID uses default 'ext' prefix
|
73
|
+
obj = ExternalIdTest.new
|
74
|
+
obj.extid.start_with?('ext_')
|
75
|
+
#==> true
|
76
|
+
|
77
|
+
## Custom prefix class uses specified prefix
|
78
|
+
custom_obj = CustomPrefixTest.new
|
79
|
+
custom_obj.extid.start_with?('cust_')
|
80
|
+
#==> true
|
81
|
+
|
82
|
+
## External ID is URL-safe base-36 format
|
83
|
+
obj = ExternalIdTest.new
|
84
|
+
extid = obj.extid
|
85
|
+
extid.match(/\Aext_[0-9a-z]+\z/)
|
86
|
+
#=*> nil
|
87
|
+
|
88
|
+
## Custom prefix external ID format is correct
|
89
|
+
custom_obj = CustomPrefixTest.new
|
90
|
+
extid = custom_obj.extid
|
91
|
+
extid.match(/\Acust_[0-9a-z]+\z/)
|
92
|
+
#=*> nil
|
93
|
+
|
94
|
+
## External ID is lazy - not generated until accessed
|
95
|
+
@lazy_obj.instance_variable_get(:@extid)
|
96
|
+
#=> nil
|
97
|
+
|
98
|
+
## External ID is generated when first accessed
|
99
|
+
@lazy_obj.extid
|
100
|
+
@lazy_obj.instance_variable_get(:@extid)
|
101
|
+
#=*> nil
|
102
|
+
|
103
|
+
## External ID value is stable across multiple calls
|
104
|
+
first_call = @lazy_obj.extid
|
105
|
+
second_call = @lazy_obj.extid
|
106
|
+
first_call == second_call
|
107
|
+
#==> true
|
108
|
+
|
109
|
+
## Data integrity: preset extid is preserved
|
110
|
+
@existing_ext_obj.extid
|
111
|
+
#=> "preset_ext_123"
|
112
|
+
|
113
|
+
## Data integrity: preset extid not regenerated
|
114
|
+
@existing_ext_obj.instance_variable_get(:@extid)
|
115
|
+
#=> "preset_ext_123"
|
116
|
+
|
117
|
+
## find_by_extid class method exists
|
118
|
+
ExternalIdTest.respond_to?(:find_by_extid)
|
119
|
+
#==> true
|
120
|
+
|
121
|
+
## find_by_extid returns correct type
|
122
|
+
result = ExternalIdTest.find_by_extid('nonexistent')
|
123
|
+
result.is_a?(ExternalIdTest) || result.nil?
|
124
|
+
#==> true
|
125
|
+
|
126
|
+
## External ID is deterministic from objid
|
127
|
+
test_objid = "01234567-89ab-7def-8fed-cba987654321"
|
128
|
+
obj1 = ExternalIdTest.new
|
129
|
+
obj1.instance_variable_set(:@objid, test_objid)
|
130
|
+
obj2 = ExternalIdTest.new
|
131
|
+
obj2.instance_variable_set(:@objid, test_objid)
|
132
|
+
obj1.extid == obj2.extid
|
133
|
+
#==> true
|
134
|
+
|
135
|
+
## External ID is different from objid
|
136
|
+
obj = ExternalIdTest.new
|
137
|
+
obj.objid != obj.extid
|
138
|
+
#==> true
|
139
|
+
|
140
|
+
## External ID persists through save/load cycle
|
141
|
+
save_obj = ExternalIdTest.new
|
142
|
+
save_obj.id = 'ext_save_test'
|
143
|
+
save_obj.name = 'External Save Test'
|
144
|
+
original_extid = save_obj.extid
|
145
|
+
save_obj.save
|
146
|
+
loaded_obj = ExternalIdTest.new(id: 'ext_save_test')
|
147
|
+
loaded_obj.extid == original_extid
|
148
|
+
#==> true
|
149
|
+
|
150
|
+
## Different objids produce different external IDs
|
151
|
+
obj1 = ExternalIdTest.new
|
152
|
+
obj2 = ExternalIdTest.new
|
153
|
+
obj1.extid != obj2.extid
|
154
|
+
#==> true
|
155
|
+
|
156
|
+
## extid field type is ExternalIdentifierFieldType
|
157
|
+
ExternalIdTest.field_types[:extid]
|
158
|
+
#=:> Familia::Features::ExternalIdentifiers::ExternalIdentifierFieldType
|
159
|
+
|
160
|
+
## Feature options contain correct prefix
|
161
|
+
ExternalIdTest.feature_options(:external_identifiers)[:prefix]
|
162
|
+
#=> "ext"
|
163
|
+
|
164
|
+
## Custom prefix feature options
|
165
|
+
CustomPrefixTest.feature_options(:external_identifiers)[:prefix]
|
166
|
+
#=> "cust"
|
167
|
+
|
168
|
+
## External ID is shorter than UUID objid
|
169
|
+
obj = ExternalIdTest.new
|
170
|
+
obj.extid.length < obj.objid.length
|
171
|
+
#==> true
|
172
|
+
|
173
|
+
## External ID contains only lowercase alphanumeric after prefix
|
174
|
+
obj = ExternalIdTest.new
|
175
|
+
extid_suffix = obj.extid.split('_', 2)[1]
|
176
|
+
extid_suffix.match(/\A[0-9a-z]+\z/)
|
177
|
+
#=*> nil
|
178
|
+
|
179
|
+
## Complex initialization preserves lazy generation
|
180
|
+
@complex_obj.instance_variable_get(:@extid)
|
181
|
+
#=> nil
|
182
|
+
|
183
|
+
## External ID generation after complex initialization
|
184
|
+
@complex_obj.extid
|
185
|
+
#=*> nil
|
186
|
+
|
187
|
+
## find_by_extid works with saved objects
|
188
|
+
@test_obj = ExternalIdTest.new(id: 'findable_test', name: 'Test Object')
|
189
|
+
@test_obj.save
|
190
|
+
found_obj = ExternalIdTest.find_by_extid(@test_obj.extid)
|
191
|
+
found_obj&.id
|
192
|
+
#=> "findable_test"
|
193
|
+
|
194
|
+
## find_by_extid returns nil for nonexistent extids
|
195
|
+
ExternalIdTest.find_by_extid('nonexistent_extid')
|
196
|
+
#=> nil
|
197
|
+
|
198
|
+
## extid_lookup mapping is maintained
|
199
|
+
ExternalIdTest.extid_lookup[@test_obj.extid]
|
200
|
+
#=> "findable_test"
|
201
|
+
|
202
|
+
# Cleanup test objects
|
203
|
+
@test_obj.destroy! rescue nil
|