familia 2.0.0.pre12 → 2.0.0.pre14

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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +2 -3
  3. data/CHANGELOG.rst +529 -0
  4. data/CLAUDE.md +1 -1
  5. data/Gemfile +1 -6
  6. data/Gemfile.lock +13 -7
  7. data/README.md +21 -2
  8. data/changelog.d/README.md +5 -5
  9. data/{setup.cfg → changelog.d/scriv.ini} +1 -1
  10. data/docs/guides/Feature-System-Autoloading.md +228 -0
  11. data/docs/guides/time-utilities.md +221 -0
  12. data/docs/migrating/v2.0.0-pre11.md +14 -16
  13. data/docs/migrating/v2.0.0-pre13.md +95 -0
  14. data/docs/migrating/v2.0.0-pre14.md +37 -0
  15. data/examples/autoloader/mega_customer/safe_dump_fields.rb +6 -0
  16. data/examples/autoloader/mega_customer.rb +17 -0
  17. data/examples/safe_dump.rb +1 -1
  18. data/familia.gemspec +1 -0
  19. data/lib/familia/autoloader.rb +53 -0
  20. data/lib/familia/base.rb +5 -0
  21. data/lib/familia/data_type.rb +4 -0
  22. data/lib/familia/encryption/encrypted_data.rb +4 -4
  23. data/lib/familia/encryption/manager.rb +6 -4
  24. data/lib/familia/encryption.rb +1 -1
  25. data/lib/familia/errors.rb +3 -0
  26. data/lib/familia/features/autoloadable.rb +113 -0
  27. data/lib/familia/features/encrypted_fields/concealed_string.rb +4 -2
  28. data/lib/familia/features/expiration.rb +4 -0
  29. data/lib/familia/features/external_identifier.rb +3 -3
  30. data/lib/familia/features/quantization.rb +5 -0
  31. data/lib/familia/features/safe_dump.rb +7 -0
  32. data/lib/familia/features.rb +20 -16
  33. data/lib/familia/field_type.rb +2 -0
  34. data/lib/familia/horreum/core/serialization.rb +3 -3
  35. data/lib/familia/horreum/subclass/definition.rb +3 -4
  36. data/lib/familia/horreum.rb +2 -0
  37. data/lib/familia/json_serializer.rb +70 -0
  38. data/lib/familia/logging.rb +12 -10
  39. data/lib/familia/refinements/logger_trace.rb +57 -0
  40. data/lib/familia/refinements/snake_case.rb +40 -0
  41. data/lib/familia/refinements/time_literals.rb +279 -0
  42. data/lib/familia/refinements.rb +3 -49
  43. data/lib/familia/utils.rb +2 -0
  44. data/lib/familia/validation/{test_helpers.rb → validation_helpers.rb} +2 -2
  45. data/lib/familia/validation.rb +1 -1
  46. data/lib/familia/version.rb +1 -1
  47. data/lib/familia.rb +15 -3
  48. data/try/core/autoloader_try.rb +112 -0
  49. data/try/core/extensions_try.rb +38 -21
  50. data/try/core/familia_extended_try.rb +4 -3
  51. data/try/core/time_utils_try.rb +130 -0
  52. data/try/data_types/datatype_base_try.rb +3 -2
  53. data/try/features/autoloadable/autoloadable_try.rb +61 -0
  54. data/try/features/encrypted_fields/concealed_string_core_try.rb +8 -3
  55. data/try/features/encrypted_fields/secure_by_default_behavior_try.rb +59 -17
  56. data/try/features/encrypted_fields/universal_serialization_safety_try.rb +36 -12
  57. data/try/features/external_identifier/external_identifier_try.rb +26 -0
  58. data/try/features/feature_improvements_try.rb +2 -1
  59. data/try/features/real_feature_integration_try.rb +1 -1
  60. data/try/features/safe_dump/safe_dump_autoloading_try.rb +111 -0
  61. data/try/helpers/test_helpers.rb +24 -0
  62. data/try/integration/cross_component_try.rb +3 -1
  63. metadata +34 -6
  64. data/CHANGELOG.md +0 -247
  65. data/lib/familia/core_ext.rb +0 -135
  66. data/lib/familia/features/autoloader.rb +0 -57
@@ -0,0 +1,112 @@
1
+ # try/core/autoloader_try.rb
2
+
3
+ require_relative '../../lib/familia'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+
7
+ # Create test directory structure for Autoloader testing
8
+ @test_dir = Dir.mktmpdir('familia_autoloader_test')
9
+ @features_dir = File.join(@test_dir, 'features')
10
+ @test_file1 = File.join(@features_dir, 'test_feature1.rb')
11
+ @test_file2 = File.join(@features_dir, 'test_feature2.rb')
12
+ @excluded_file = File.join(@features_dir, 'autoloader.rb')
13
+
14
+ # Create directory structure
15
+ FileUtils.mkdir_p(@features_dir)
16
+
17
+ # Write test files
18
+ File.write(@test_file1, <<~RUBY)
19
+ # Test feature file 1
20
+ $test_feature1_loaded = true
21
+ RUBY
22
+
23
+ File.write(@test_file2, <<~RUBY)
24
+ # Test feature file 2
25
+ $test_feature2_loaded = true
26
+ RUBY
27
+
28
+ File.write(@excluded_file, <<~RUBY)
29
+ # This should be excluded
30
+ $autoloader_file_loaded = true
31
+ RUBY
32
+
33
+ ## Test that Familia::Autoloader exists and is a module
34
+ Familia::Autoloader.is_a?(Module)
35
+ #=> true
36
+
37
+ ## Test that autoload_files class method exists
38
+ Familia::Autoloader.respond_to?(:autoload_files)
39
+ #=> true
40
+
41
+ ## Test that included class method exists
42
+ Familia::Autoloader.respond_to?(:included)
43
+ #=> true
44
+
45
+ ## Test autoload_files with single pattern
46
+ $test_feature1_loaded = false
47
+ $test_feature2_loaded = false
48
+ $autoloader_file_loaded = false
49
+
50
+ Familia::Autoloader.autoload_files(File.join(@features_dir, '*.rb'))
51
+ $test_feature1_loaded && $test_feature2_loaded
52
+ #=> true
53
+
54
+ ## Test that autoload_files respects exclusions (using fresh files)
55
+ @exclude_test_dir = Dir.mktmpdir('familia_autoloader_exclude_test')
56
+ @exclude_features_dir = File.join(@exclude_test_dir, 'features')
57
+ @include_file = File.join(@exclude_features_dir, 'include_me.rb')
58
+ @exclude_file = File.join(@exclude_features_dir, 'autoloader.rb')
59
+
60
+ FileUtils.mkdir_p(@exclude_features_dir)
61
+ File.write(@include_file, '$include_me_loaded = true')
62
+ File.write(@exclude_file, '$exclude_me_loaded = true')
63
+
64
+ $include_me_loaded = false
65
+ $exclude_me_loaded = false
66
+
67
+ Familia::Autoloader.autoload_files(
68
+ File.join(@exclude_features_dir, '*.rb'),
69
+ exclude: ['autoloader.rb']
70
+ )
71
+
72
+ # Should load include file but not the excluded one
73
+ $include_me_loaded && !$exclude_me_loaded
74
+ #=> true
75
+
76
+ ## Test autoload_files with array of patterns (using fresh files)
77
+ @pattern_test_dir = Dir.mktmpdir('familia_autoloader_pattern_test')
78
+ @pattern_dir1 = File.join(@pattern_test_dir, 'dir1')
79
+ @pattern_dir2 = File.join(@pattern_test_dir, 'dir2')
80
+ @pattern_file1 = File.join(@pattern_dir1, 'file1.rb')
81
+ @pattern_file2 = File.join(@pattern_dir2, 'file2.rb')
82
+
83
+ FileUtils.mkdir_p(@pattern_dir1)
84
+ FileUtils.mkdir_p(@pattern_dir2)
85
+ File.write(@pattern_file1, '$pattern1_loaded = true')
86
+ File.write(@pattern_file2, '$pattern2_loaded = true')
87
+
88
+ $pattern1_loaded = false
89
+ $pattern2_loaded = false
90
+
91
+ Familia::Autoloader.autoload_files([
92
+ File.join(@pattern_dir1, '*.rb'),
93
+ File.join(@pattern_dir2, '*.rb')
94
+ ])
95
+
96
+ $pattern1_loaded && $pattern2_loaded
97
+ #=> true
98
+
99
+ ## Test that included method loads features from features directory
100
+ # Create a mock module that includes Autoloader
101
+ @mock_features_module = Module.new do
102
+ include Familia::Autoloader
103
+ end
104
+
105
+ # The Features module already includes Autoloader, so test indirectly
106
+ Familia::Features.ancestors.include?(Familia::Autoloader)
107
+ #=> true
108
+
109
+ # Cleanup test files and directories
110
+ FileUtils.rm_rf(@test_dir)
111
+ FileUtils.rm_rf(@exclude_test_dir)
112
+ FileUtils.rm_rf(@pattern_test_dir)
@@ -1,59 +1,76 @@
1
1
  require_relative '../helpers/test_helpers'
2
2
 
3
+ module RefinedContext
4
+ using Familia::Refinements::TimeLiterals
5
+
6
+ # This helper evaluates code within the refined context using eval.
7
+ # This works because eval executes the code as if it were written
8
+ # at this location, making the refinements available.
9
+ def self.eval_in_refined_context(code)
10
+ eval(code)
11
+ end
12
+
13
+ # This helper also evaluates code in the refined context using instance_eval.
14
+ # This provides an alternative approach for testing refinements.
15
+ def self.instance_eval_in_refined_context(code)
16
+ instance_eval(code)
17
+ end
18
+ end
19
+
3
20
  # Test core extensions
4
21
 
5
22
  ## String time parsing - seconds
6
- '60s'.in_seconds
7
- #=> 60
23
+ RefinedContext.eval_in_refined_context("'60s'.in_seconds")
24
+ #=> 60.0
8
25
 
9
26
  ## String time parsing - minutes
10
- '5m'.in_seconds
11
- #=> 300
27
+ RefinedContext.instance_eval_in_refined_context("'5m'.in_seconds")
28
+ #=> 300.0
12
29
 
13
30
  ## String time parsing - hours
14
- '2h'.in_seconds
15
- #=> 7200
31
+ RefinedContext.eval_in_refined_context("'2h'.in_seconds")
32
+ #=> 7200.0
16
33
 
17
34
  ## String time parsing - days
18
- '1d'.in_seconds
19
- #=> 86_400
35
+ RefinedContext.instance_eval_in_refined_context("'1d'.in_seconds")
36
+ #=> 86_400.0
20
37
 
21
- ## String time parsing - days
22
- '1y'.in_seconds
23
- #=> 31536000
38
+ ## String time parsing - years
39
+ RefinedContext.eval_in_refined_context("'1y'.in_seconds")
40
+ #=> 31556952.0
24
41
 
25
42
  ## Time::Units - second
26
- 1.second
43
+ RefinedContext.instance_eval_in_refined_context("1.second")
27
44
  #=> 1
28
45
 
29
46
  ## Time::Units - minute
30
- 1.minute
47
+ RefinedContext.eval_in_refined_context("1.minute")
31
48
  #=> 60
32
49
 
33
50
  ## Time::Units - hour
34
- 1.hour
51
+ RefinedContext.instance_eval_in_refined_context("1.hour")
35
52
  #=> 3600
36
53
 
37
54
  ## Time::Units - day
38
- 1.day
55
+ RefinedContext.eval_in_refined_context("1.day")
39
56
  #=> 86_400
40
57
 
41
58
  ## Time::Units - week
42
- 1.week
59
+ RefinedContext.instance_eval_in_refined_context("1.week")
43
60
  #=> 604_800
44
61
 
45
62
  ## Numeric extension to_ms
46
- 1000.to_ms
47
- #=> 1000 * 1000
63
+ RefinedContext.eval_in_refined_context("1000.to_ms")
64
+ #=> 1000000.0
48
65
 
49
66
  ## Numeric extension to_bytes - single byte
50
- 1.to_bytes
67
+ RefinedContext.instance_eval_in_refined_context("1.to_bytes")
51
68
  #=> '1.00 B'
52
69
 
53
70
  ## Numeric extension to_bytes - kilobytes
54
- 1024.to_bytes
71
+ RefinedContext.eval_in_refined_context("1024.to_bytes")
55
72
  #=> '1.00 KiB'
56
73
 
57
74
  ## Numeric extension to_bytes - megabytes
58
- (1024 * 1024).to_bytes
75
+ RefinedContext.instance_eval_in_refined_context("(1024 * 1024).to_bytes")
59
76
  #=> '1.00 MiB'
@@ -44,14 +44,15 @@ parsed_time = Familia.now(Time.parse('2011-04-10 20:56:20 UTC').utc)
44
44
  #=> [1302468980.0, true, true]
45
45
 
46
46
  ## Familia.qnow
47
- Familia.qstamp 10.minutes, time: 1_302_468_980
47
+ RefinedContext.eval_in_refined_context("Familia.qstamp 10.minutes, time: 1_302_468_980")
48
48
  #=> 1302468600
49
49
 
50
50
  ## Familia::Object.qstamp
51
- Limiter.qstamp(10.minutes, pattern: '%H:%M', time: 1_302_468_980)
51
+ RefinedContext.eval_in_refined_context("Limiter.qstamp(10.minutes, pattern: '%H:%M', time: 1_302_468_980)")
52
52
  #=> '20:50'
53
53
 
54
54
  ## Familia::Object#qstamp
55
55
  limiter = Limiter.new :request
56
- limiter.qstamp(10.minutes, pattern: '%H:%M', time: 1_302_468_980)
56
+ RefinedContext.instance_variable_set(:@limiter, limiter)
57
+ RefinedContext.eval_in_refined_context("@limiter.qstamp(10.minutes, pattern: '%H:%M', time: 1_302_468_980)")
57
58
  #=> '20:50'
@@ -0,0 +1,130 @@
1
+ require_relative '../helpers/test_helpers'
2
+
3
+ module RefinedContext
4
+ using Familia::Refinements::TimeLiterals
5
+
6
+ def self.eval_in_refined_context(code)
7
+ eval(code)
8
+ end
9
+
10
+ def self.instance_eval_in_refined_context(code)
11
+ instance_eval(code)
12
+ end
13
+ end
14
+
15
+ # Test TimeLiterals refinement
16
+
17
+ ## Numeric#months - convert number to months in seconds
18
+ result = RefinedContext.eval_in_refined_context("1.month")
19
+ result.round(0)
20
+ #=> 2629746.0
21
+
22
+ ## Numeric#months - plural form
23
+ result = RefinedContext.instance_eval_in_refined_context("2.months")
24
+ result.round(0)
25
+ #=> 5259492.0
26
+
27
+ ## Numeric#years - convert number to years in seconds
28
+ result = RefinedContext.eval_in_refined_context("1.year")
29
+ result.round(0)
30
+ #=> 31556952.0
31
+
32
+ ## Numeric#in_months - convert seconds to months
33
+ RefinedContext.instance_eval_in_refined_context("2629746.in_months")
34
+ #=> 1.0
35
+
36
+ ## Numeric#in_years - convert seconds to years
37
+ result = RefinedContext.eval_in_refined_context("#{Familia::Refinements::TimeLiterals::PER_YEAR}.in_years")
38
+ result.round(1)
39
+ #=> 1.0
40
+
41
+ ## String#in_seconds - parse month string
42
+ RefinedContext.instance_eval_in_refined_context("'1mo'.in_seconds")
43
+ #=> 2629746.0
44
+
45
+ ## String#in_seconds - parse month string (long form)
46
+ RefinedContext.eval_in_refined_context("'2months'.in_seconds")
47
+ #=> 5259492.0
48
+
49
+ ## String#in_seconds - parse year string
50
+ result = RefinedContext.instance_eval_in_refined_context("'1y'.in_seconds")
51
+ result.round(0)
52
+ #=> 31556952.0
53
+
54
+ ## Numeric#age_in - calculate age in months from timestamp (approximately 1 month ago)
55
+ timestamp = Time.now.to_f - Familia::Refinements::TimeLiterals::PER_MONTH
56
+ result = RefinedContext.eval_in_refined_context("#{timestamp}.age_in(:months)")
57
+ (result - 1.0).abs < 0.01
58
+ #=> true
59
+
60
+ ## Numeric#age_in - calculate age in years from timestamp (approximately 1 year ago)
61
+ timestamp = Time.now.to_f - Familia::Refinements::TimeLiterals::PER_YEAR
62
+ result = RefinedContext.instance_eval_in_refined_context("#{timestamp}.age_in(:years)")
63
+ (result - 1.0).abs < 0.01
64
+ #=> true
65
+
66
+ ## Numeric#months_old - convenience method for age_in(:months)
67
+ timestamp = Time.now.to_f - Familia::Refinements::TimeLiterals::PER_MONTH
68
+ result = RefinedContext.eval_in_refined_context("#{timestamp}.months_old")
69
+ (result - 1.0).abs < 0.01
70
+ #=> true
71
+
72
+ ## Numeric#years_old - convenience method for age_in(:years)
73
+ timestamp = Time.now.to_f - Familia::Refinements::TimeLiterals::PER_YEAR
74
+ result = RefinedContext.instance_eval_in_refined_context("#{timestamp}.years_old")
75
+ (result - 1.0).abs < 0.01
76
+ #=> true
77
+
78
+ ## Numeric#months_old - should NOT return seconds (the original bug)
79
+ timestamp = Time.now.to_f - Familia::Refinements::TimeLiterals::PER_MONTH
80
+ result = RefinedContext.eval_in_refined_context("#{timestamp}.months_old")
81
+ result.between?(0.9, 1.1) # Should be ~1 month, not millions of seconds
82
+ #=> true
83
+
84
+ ## Numeric#years_old - should NOT return seconds (the original bug)
85
+ timestamp = Time.now.to_f - Familia::Refinements::TimeLiterals::PER_YEAR
86
+ result = RefinedContext.instance_eval_in_refined_context("#{timestamp}.years_old")
87
+ result.between?(0.9, 1.1) # Should be ~1 year, not millions of seconds
88
+ #=> true
89
+
90
+ ## age_in with from_time parameter - months
91
+ past_time = Time.now - (2 * Familia::Refinements::TimeLiterals::PER_MONTH) # 2 months ago
92
+ from_time = Time.now - Familia::Refinements::TimeLiterals::PER_MONTH # 1 month ago
93
+ result = RefinedContext.eval_in_refined_context("#{past_time.to_f}.age_in(:months, #{from_time.to_f})")
94
+ (result - 1.0).abs < 0.01
95
+ #=> true
96
+
97
+ ## age_in with from_time parameter - years
98
+ past_time = Time.now - (2 * Familia::Refinements::TimeLiterals::PER_YEAR) # 2 years ago
99
+ from_time = Time.now - Familia::Refinements::TimeLiterals::PER_YEAR # 1 year ago
100
+ result = RefinedContext.instance_eval_in_refined_context("#{past_time.to_f}.age_in(:years, #{from_time.to_f})")
101
+ (result - 1.0).abs < 0.01
102
+ #=> true
103
+
104
+ ## Verify month constant is approximately correct (30.437 days)
105
+ expected_seconds_per_month = 30.437 * 24 * 60 * 60
106
+ Familia::Refinements::TimeLiterals::PER_MONTH.round(0)
107
+ #=> 2629746.0
108
+
109
+ ## Verify year constant (365.2425 days - Gregorian year)
110
+ expected_seconds_per_year = 365.2425 * 24 * 60 * 60
111
+ Familia::Refinements::TimeLiterals::PER_YEAR.round(0)
112
+ #=> 31556952.0
113
+
114
+ ## UNIT_METHODS contains months mapping
115
+ Familia::Refinements::TimeLiterals::UNIT_METHODS['months']
116
+ #=> :months
117
+
118
+ ## UNIT_METHODS contains mo mapping
119
+ Familia::Refinements::TimeLiterals::UNIT_METHODS['mo']
120
+ #=> :months
121
+
122
+ ## UNIT_METHODS contains month mapping
123
+ Familia::Refinements::TimeLiterals::UNIT_METHODS['month']
124
+ #=> :months
125
+
126
+ ## Calendar consistency - 12 months equals 1 year (fix for inconsistency issue)
127
+ result1 = RefinedContext.eval_in_refined_context("12.months")
128
+ result2 = RefinedContext.instance_eval_in_refined_context("1.year")
129
+ result1 == result2
130
+ #=> true
@@ -21,7 +21,7 @@ p [@a.name, @b.name]
21
21
  #=> true
22
22
 
23
23
  ## Limiter#qstamp
24
- @limiter1.counter.qstamp(10.minutes, '%H:%M', 1_302_468_980)
24
+ RefinedContext.eval_in_refined_context("@limiter1.counter.qstamp(10.minutes, '%H:%M', 1_302_468_980)")
25
25
  ##=> '20:50'
26
26
 
27
27
  ## Database Types can be stored to quantized stamp suffix
@@ -32,7 +32,8 @@ p [@a.name, @b.name]
32
32
  @limiter2 = Limiter.new :requests
33
33
  p [@limiter1.default_expiration, @limiter2.default_expiration]
34
34
  p [@limiter1.counter.parent.default_expiration, @limiter2.counter.parent.default_expiration]
35
- @limiter2.counter.qstamp(10.minutes, pattern: nil, time: 1_302_468_980)
35
+ RefinedContext.instance_variable_set(:@limiter2, @limiter2)
36
+ RefinedContext.eval_in_refined_context("@limiter2.counter.qstamp(10.minutes, pattern: nil, time: 1_302_468_980)")
36
37
  #=> 1302468600
37
38
 
38
39
  ## Database Types can be stored to quantized numeric suffix. This
@@ -0,0 +1,61 @@
1
+ # try/features/autoloadable/autoloadable_try.rb
2
+
3
+ require_relative '../../../lib/familia'
4
+
5
+ # Create test feature module that includes Autoloadable
6
+ module TestAutoloadableFeature
7
+ include Familia::Features::Autoloadable
8
+
9
+ def self.included(base)
10
+ super
11
+ base.define_method(:test_feature_method) { "feature_loaded" }
12
+ end
13
+ end
14
+
15
+ # Create test class to include the feature
16
+ class TestModelForAutoloadable < Familia::Horreum
17
+ field :name
18
+ end
19
+
20
+ ## Test that Autoloadable can be included in feature modules
21
+ TestAutoloadableFeature.ancestors.include?(Familia::Features::Autoloadable)
22
+ #=> true
23
+
24
+ ## Test that Autoloadable extends feature modules with ClassMethods
25
+ TestAutoloadableFeature.respond_to?(:post_inclusion_autoload)
26
+ #=> true
27
+
28
+ ## Test that including autoloadable feature in Horreum class works
29
+ TestModelForAutoloadable.include(TestAutoloadableFeature)
30
+ TestModelForAutoloadable.ancestors.include?(TestAutoloadableFeature)
31
+ #=> true
32
+
33
+ ## Test that post_inclusion_autoload can be called with test class
34
+ TestAutoloadableFeature.post_inclusion_autoload(TestModelForAutoloadable, :test_autoloadable_feature, {})
35
+ "success"
36
+ #=> "success"
37
+
38
+ ## Test that feature methods are available on the model
39
+ @test_instance = TestModelForAutoloadable.new(name: 'test')
40
+ @test_instance.respond_to?(:test_feature_method)
41
+ #=> true
42
+
43
+ ## Test that feature method works
44
+ @test_instance.test_feature_method
45
+ #=> "feature_loaded"
46
+
47
+ ## Test that Autoloadable works with DataType classes (should not crash)
48
+ class TestDataTypeAutoloadable < Familia::DataType
49
+ include Familia::Features::Autoloadable
50
+ end
51
+
52
+ TestDataTypeAutoloadable.ancestors.include?(Familia::Features::Autoloadable)
53
+ #=> true
54
+
55
+ ## Test that SafeDump includes Autoloadable (real-world usage)
56
+ Familia::Features::SafeDump.ancestors.include?(Familia::Features::Autoloadable)
57
+ #=> true
58
+
59
+ ## Test that SafeDump has post_inclusion_autoload capability
60
+ Familia::Features::SafeDump.respond_to?(:post_inclusion_autoload)
61
+ #=> true
@@ -77,9 +77,14 @@ end
77
77
  @doc.content.to_str
78
78
  #=!> NoMethodError
79
79
 
80
- ## JSON serialization - to_json
81
- @doc.content.to_json
82
- #=> "\"[CONCEALED]\""
80
+ ## JSON serialization - to_json (fails for security)
81
+ begin
82
+ @doc.content.to_json
83
+ raise "Should have raised SerializerError"
84
+ rescue Familia::SerializerError => e
85
+ e.class
86
+ end
87
+ #=> Familia::SerializerError
83
88
 
84
89
  ## JSON serialization - as_json
85
90
  @doc.content.as_json
@@ -188,18 +188,30 @@ rescue TypeError => e
188
188
  end
189
189
  #=> true
190
190
 
191
- ## JSON serialization is safe
192
- @user_json = {
193
- id: @user.id,
194
- username: @user.username,
195
- password: @user.password_hash
196
- }.to_json
197
-
198
- @user_json.include?("bcrypt")
199
- #=> false
191
+ ## JSON serialization prevents leakage by raising error
192
+ begin
193
+ user_json = {
194
+ id: @user.id,
195
+ username: @user.username,
196
+ password: @user.password_hash
197
+ }.to_json
198
+ false
199
+ rescue Familia::SerializerError
200
+ true
201
+ end
202
+ #=> true
200
203
 
201
- ## JSON contains concealed marker
202
- @user_json.include?("[CONCEALED]")
204
+ ## JSON serialization with ConcealedString raises error
205
+ begin
206
+ user_json = {
207
+ id: @user.id,
208
+ username: @user.username,
209
+ password: @user.password_hash
210
+ }.to_json
211
+ false
212
+ rescue Familia::SerializerError => e
213
+ e.message.include?("ConcealedString")
214
+ end
203
215
  #=> true
204
216
 
205
217
  ## Bulk field operations are secure
@@ -281,16 +293,46 @@ api_response = {
281
293
  }
282
294
  }
283
295
 
284
- @response_json = api_response.to_json
285
- @response_json.include?("bcrypt")
286
- #=> false
296
+ begin
297
+ @response_json = api_response.to_json
298
+ false
299
+ rescue Familia::SerializerError
300
+ true
301
+ end
302
+ #=> true
287
303
 
288
304
  ## API response doesn't leak secrets
289
- @response_json.include?("sk-1234567890abcdef")
290
- #=> false
305
+ api_response = {
306
+ user_id: @user.id,
307
+ credentials: {
308
+ password: @user.password_hash,
309
+ api_key: @user.api_secret
310
+ }
311
+ }
312
+
313
+ begin
314
+ @response_json = api_response.to_json
315
+ false
316
+ rescue Familia::SerializerError
317
+ true
318
+ end
319
+ #=> true
291
320
 
292
321
  ## API response contains concealed markers
293
- @response_json.include?("[CONCEALED]")
322
+ api_response = {
323
+ user_id: @user.id,
324
+ credentials: {
325
+ password: @user.password_hash,
326
+ api_key: @user.api_secret
327
+ }
328
+ }
329
+
330
+ begin
331
+ @response_json = api_response.to_json
332
+ false
333
+ rescue Familia::SerializerError => e
334
+ e.message.include?("ConcealedString")
335
+ end
294
336
  #=> true
295
337
 
296
338
  ## Debug logging safety
@@ -67,9 +67,14 @@ hash_result.keys.include?("api_token")
67
67
  @record.api_token.inspect
68
68
  #=> "[CONCEALED]"
69
69
 
70
- ## JSON serialization - to_json
71
- @record.api_token.to_json
72
- #=> "\"[CONCEALED]\""
70
+ ## JSON serialization - to_json (fails for security)
71
+ begin
72
+ @record.api_token.to_json
73
+ raise "Should have raised SerializerError"
74
+ rescue Familia::SerializerError => e
75
+ e.class
76
+ end
77
+ #=> Familia::SerializerError
73
78
 
74
79
  ## JSON serialization - as_json
75
80
  @record.api_token.as_json
@@ -100,12 +105,21 @@ hash_result.keys.include?("api_token")
100
105
  }
101
106
  }
102
107
 
103
- @serialized = @nested_data.to_json
104
- @serialized.include?("token-abc123456789")
105
- #=> false
108
+ begin
109
+ @serialized = @nested_data.to_json
110
+ false
111
+ rescue Familia::SerializerError
112
+ true
113
+ end
114
+ #=> true
106
115
 
107
- ## Nested JSON contains concealed markers
108
- @nested_data.to_json.include?("[CONCEALED]")
116
+ ## Nested JSON with ConcealedString raises error
117
+ begin
118
+ @nested_data.to_json
119
+ false
120
+ rescue Familia::SerializerError => e
121
+ e.message.include?("ConcealedString cannot be serialized")
122
+ end
109
123
  #=> true
110
124
 
111
125
  ## Array of mixed field types safety
@@ -116,11 +130,21 @@ hash_result.keys.include?("api_token")
116
130
  @record.secret_notes
117
131
  ]
118
132
 
119
- @mixed_array.to_json.include?("token-abc123456789")
120
- #=> false
133
+ begin
134
+ @mixed_array.to_json
135
+ false
136
+ rescue Familia::SerializerError
137
+ true
138
+ end
139
+ #=> true
121
140
 
122
- ## Mixed array preserves public data
123
- @mixed_array.to_json.include?("Public Record")
141
+ ## Mixed array with ConcealedString raises error
142
+ begin
143
+ @mixed_array.to_json
144
+ false
145
+ rescue Familia::SerializerError => e
146
+ e.message.include?("ConcealedString")
147
+ end
124
148
  #=> true
125
149
 
126
150
  ## String interpolation safety
@@ -198,3 +198,29 @@ ExternalIdTest.extid_lookup[@test_obj.extid]
198
198
 
199
199
  # Cleanup test objects
200
200
  @test_obj.destroy! rescue nil
201
+
202
+ ## Test 1: Changing extid value (should work after bug fix)
203
+ bug_test_obj = ExternalIdTest.new(id: 'bug_test', name: 'Bug Test Object')
204
+ bug_test_obj.save
205
+ bug_test_obj.extid = 'new_extid_value'
206
+ bug_test_obj.extid
207
+ #=> "new_extid_value"
208
+
209
+ ## Test 2: find_by_extid with deleted object (should work after bug fix)
210
+ delete_test_obj = ExternalIdTest.new(id: 'delete_test', name: 'Delete Test')
211
+ delete_test_obj.save
212
+ test_extid = delete_test_obj.extid
213
+ # Delete the object directly from Redis to simulate cleanup scenario
214
+ ExternalIdTest.dbclient.del(delete_test_obj.dbkey)
215
+ # Now try to find by extid - this should clean up mapping and return nil
216
+ ExternalIdTest.find_by_extid(test_extid)
217
+ #=> nil
218
+
219
+ ## Test 3: destroy! method (should work after bug fix)
220
+ destroy_test_obj = ExternalIdTest.new(id: 'destroy_test', name: 'Destroy Test')
221
+ destroy_test_obj.save
222
+ destroy_extid = destroy_test_obj.extid
223
+ destroy_test_obj.destroy!
224
+ # Verify mapping was cleaned up
225
+ ExternalIdTest.extid_lookup.key?(destroy_extid)
226
+ #=> false
@@ -46,7 +46,8 @@ class ::TestModelWithSafeDump
46
46
  safe_dump_field :id
47
47
  safe_dump_field :name
48
48
  safe_dump_field :status, ->(obj) { obj.active? ? 'active' : 'inactive' }
49
- safe_dump_fields :email, { computed_field: ->(obj) { "#{obj.name}-computed" } }
49
+ safe_dump_field :email
50
+ safe_dump_field :computed_field, ->(obj) { "#{obj.name}-computed" }
50
51
  end
51
52
 
52
53
  # Test field definitions in feature modules
@@ -101,7 +101,7 @@ SafeDumpCategoryTest.features_enabled.include?(:safe_dump)
101
101
  @safedump_result.keys.sort
102
102
  #=> [:email, :id, :public_name]
103
103
 
104
- ## Safe dump respects safe_dump_fields configuration
104
+ ## Safe dump respects safe_dump_field configuration
105
105
  @safedump_result.key?(:tryouts_cache_data)
106
106
  #=> false
107
107