active_capture 1.1.1 → 1.1.3
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/lib/active_capture.rb +26 -0
- data/lib/support/capture_storage.rb +73 -13
- data/lib/support/version.rb +1 -1
- data/test/test_active_capture_flush.rb +90 -0
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 408cdbd0f3cea245a023857573782eab133c6f8d3a41981ff190702c8f6424bb
|
4
|
+
data.tar.gz: 9726c3c013f766036550a1170d8f4b71f382043058e78701fee884654b002f6c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: '08763c65b1bf808fbf85677e912621e34ffc2d00de886a6242f1bdc5308e425215e293d471403a6bc14c51b92bab82d4c2e6cf652421192c66977a2cd4c46a7e'
|
7
|
+
data.tar.gz: 6dfbb8e4974cc7382d5b6999413643d7483586843582eba130424ba0929519855f9d0519a14615ac0c37dfdea593bb6e73f353d0130f710e03836069e81d0bab
|
data/lib/active_capture.rb
CHANGED
@@ -4,6 +4,7 @@ require 'json'
|
|
4
4
|
require 'active_record'
|
5
5
|
|
6
6
|
module ActiveCapture
|
7
|
+
# Captures the state of a record and its associations
|
7
8
|
def self.take(record, associations: [], name: nil)
|
8
9
|
validate_record(record)
|
9
10
|
|
@@ -15,18 +16,21 @@ module ActiveCapture
|
|
15
16
|
}
|
16
17
|
|
17
18
|
begin
|
19
|
+
# Save the captured data in json file
|
18
20
|
Support::CaptureStorage.save_capture(record, capture_data, name)
|
19
21
|
rescue StandardError => e
|
20
22
|
raise "Failed to save capture: #{e.message}"
|
21
23
|
end
|
22
24
|
end
|
23
25
|
|
26
|
+
# Restores a record and its associations from a capture file
|
24
27
|
def self.restore(record, capture_file, merge: false)
|
25
28
|
captured_records = Support::CaptureStorage.load_capture(capture_file)
|
26
29
|
validate_capture(record, captured_records)
|
27
30
|
|
28
31
|
ActiveRecord::Base.transaction do
|
29
32
|
begin
|
33
|
+
# Update the record's attributes and restore its associations
|
30
34
|
record.update!(captured_records['attributes'])
|
31
35
|
restore_associations(record, captured_records['associations'], merge: merge)
|
32
36
|
rescue StandardError => e
|
@@ -35,20 +39,28 @@ module ActiveCapture
|
|
35
39
|
end
|
36
40
|
end
|
37
41
|
|
42
|
+
# Flushes all captured data of a specified directory
|
43
|
+
def self.flush(directory)
|
44
|
+
Support::CaptureStorage.flush(directory)
|
45
|
+
end
|
46
|
+
|
38
47
|
private
|
39
48
|
|
49
|
+
# Validates that the given record is an ActiveRecord instance
|
40
50
|
def self.validate_record(record)
|
41
51
|
unless record.is_a?(ActiveRecord::Base)
|
42
52
|
raise ArgumentError, 'Record must be an ActiveRecord::Base instance'
|
43
53
|
end
|
44
54
|
end
|
45
55
|
|
56
|
+
# Validates that the capture file matches the given record
|
46
57
|
def self.validate_capture(record, captured_records)
|
47
58
|
unless captured_records['record_id'] == record.id
|
48
59
|
raise ArgumentError, 'Capture file does not match the given record'
|
49
60
|
end
|
50
61
|
end
|
51
62
|
|
63
|
+
# Captures the specified associations of a record
|
52
64
|
def self.capture_associations(record, associations)
|
53
65
|
associations.each_with_object({}) do |association, association_data|
|
54
66
|
association_name, nested_associations = parse_association(association)
|
@@ -56,6 +68,7 @@ module ActiveCapture
|
|
56
68
|
next unless record.respond_to?(association_name)
|
57
69
|
|
58
70
|
begin
|
71
|
+
# Capture the related records for the association
|
59
72
|
related_records = record.send(association_name)
|
60
73
|
association_data[association_name] = capture_related_records(related_records, nested_associations)
|
61
74
|
rescue StandardError => e
|
@@ -64,6 +77,7 @@ module ActiveCapture
|
|
64
77
|
end
|
65
78
|
end
|
66
79
|
|
80
|
+
# Captures the attributes and nested associations of related records
|
67
81
|
def self.capture_related_records(related_records, nested_associations)
|
68
82
|
if related_records.is_a?(ActiveRecord::Base)
|
69
83
|
{
|
@@ -80,6 +94,7 @@ module ActiveCapture
|
|
80
94
|
end
|
81
95
|
end
|
82
96
|
|
97
|
+
# Parses an association to extract its name and nested associations
|
83
98
|
def self.parse_association(association)
|
84
99
|
if association.is_a?(Hash)
|
85
100
|
[association.keys.first, association.values.flatten]
|
@@ -88,36 +103,44 @@ module ActiveCapture
|
|
88
103
|
end
|
89
104
|
end
|
90
105
|
|
106
|
+
# Restores the associations of a record from captured data
|
91
107
|
def self.restore_associations(record, associations_data, merge: false)
|
92
108
|
associations_data.each do |association_name, related_data|
|
93
109
|
if related_data.is_a?(Array)
|
110
|
+
# Restore a collection association (e.g., has_many)
|
94
111
|
restore_collection_association(record, association_name, related_data, merge)
|
95
112
|
else
|
113
|
+
# Restore a single association (e.g., belongs_to, has_one)
|
96
114
|
restore_single_association(record, association_name, related_data, merge)
|
97
115
|
end
|
98
116
|
end
|
99
117
|
end
|
100
118
|
|
119
|
+
# Restores a collection association (e.g., has_many)
|
101
120
|
def self.restore_collection_association(record, association_name, related_data, merge)
|
102
121
|
associated_class = record.class.reflect_on_association(association_name).klass
|
103
122
|
existing_records = record.send(association_name)
|
104
123
|
|
105
124
|
related_data.each do |related_record_data|
|
106
125
|
if merge && related_record_data['attributes']['id']
|
126
|
+
# Update existing records if merging is enabled
|
107
127
|
existing_record = existing_records.find_by(id: related_record_data['attributes']['id'])
|
108
128
|
if existing_record
|
109
129
|
update_and_restore(existing_record, related_record_data, merge)
|
110
130
|
next
|
111
131
|
end
|
112
132
|
end
|
133
|
+
# Create and restore new records
|
113
134
|
create_and_restore(associated_class, related_record_data, record, association_name)
|
114
135
|
end
|
115
136
|
end
|
116
137
|
|
138
|
+
# Restores a single association (e.g., belongs_to, has_one)
|
117
139
|
def self.restore_single_association(record, association_name, related_data, merge)
|
118
140
|
associated_class = record.class.reflect_on_association(association_name).klass
|
119
141
|
|
120
142
|
if merge && related_data['attributes']['id']
|
143
|
+
# Update existing record if merging is enabled
|
121
144
|
existing_record = record.send(association_name)
|
122
145
|
if existing_record&.id == related_data['attributes']['id']
|
123
146
|
update_and_restore(existing_record, related_data, merge)
|
@@ -125,15 +148,18 @@ module ActiveCapture
|
|
125
148
|
end
|
126
149
|
end
|
127
150
|
|
151
|
+
# Create and restore a new record
|
128
152
|
new_record = create_and_restore(associated_class, related_data)
|
129
153
|
record.update!(association_name => new_record)
|
130
154
|
end
|
131
155
|
|
156
|
+
# Updates an existing record and restores its associations
|
132
157
|
def self.update_and_restore(record, related_data, merge)
|
133
158
|
record.update!(related_data['attributes'])
|
134
159
|
restore_associations(record, related_data['associations'], merge: merge)
|
135
160
|
end
|
136
161
|
|
162
|
+
# Creates a new record and restores its associations
|
137
163
|
def self.create_and_restore(associated_class, related_data, parent_record = nil, association_name = nil)
|
138
164
|
new_record = associated_class.create!(related_data['attributes'])
|
139
165
|
restore_associations(new_record, related_data['associations'], merge: false)
|
@@ -1,44 +1,95 @@
|
|
1
|
+
# This module provides functionality for storing and retrieving JSON-based capture data
|
2
|
+
# associated with specific records. It includes methods for saving captures, loading captures,
|
3
|
+
# and flushing (deleting) files in a specific directory.
|
4
|
+
|
1
5
|
require 'json'
|
2
6
|
require 'fileutils'
|
3
7
|
require 'pathname'
|
4
8
|
|
5
9
|
module Support
|
6
10
|
class CaptureStorage
|
11
|
+
# Constants for directory and filename constraints
|
7
12
|
CAPTURE_DIR = 'captures'.freeze
|
8
13
|
MAX_FILENAME_LENGTH = 100
|
9
14
|
VALID_FILENAME_REGEX = /\A[a-zA-Z0-9\-_\.]+\z/
|
10
15
|
|
11
16
|
class << self
|
17
|
+
# Saves capture data to a JSON file in a structured directory.
|
18
|
+
# @param record [Object] The record object (must respond to :class and :id).
|
19
|
+
# @param capture_data [Hash] The data to be saved.
|
20
|
+
# @param name [String, nil] Optional custom filename.
|
21
|
+
# @return [Hash] Result of the operation with success status and file path or error message.
|
12
22
|
def save_capture(record, capture_data, name = nil)
|
13
|
-
validate_record(record)
|
14
|
-
validate_capture_data(capture_data)
|
23
|
+
validate_record(record) # Ensure the record is valid
|
24
|
+
validate_capture_data(capture_data) # Ensure the capture data is valid
|
15
25
|
|
16
|
-
file_name = generate_filename(record, name)
|
17
|
-
dir_path = create_capture_directory(record)
|
18
|
-
file_path = File.join(dir_path, file_name)
|
26
|
+
file_name = generate_filename(record, name) # Generate a valid filename
|
27
|
+
dir_path = create_capture_directory(record) # Create the directory for the capture
|
28
|
+
file_path = File.join(dir_path, file_name) # Full path to the capture file
|
19
29
|
|
20
|
-
write_capture_file(file_path, capture_data)
|
30
|
+
write_capture_file(file_path, capture_data) # Write the capture data to the file
|
21
31
|
|
22
32
|
{ success: true, file_path: file_path }
|
23
33
|
rescue => e
|
24
34
|
{ success: false, error: e.message }
|
25
35
|
end
|
26
36
|
|
37
|
+
# Loads capture data from a JSON file.
|
38
|
+
# @param capture_file [String] Path to the capture file.
|
39
|
+
# @return [Hash] Parsed JSON data from the file.
|
40
|
+
# @raise [StorageError] If the file cannot be loaded or parsed.
|
27
41
|
def load_capture(capture_file)
|
28
|
-
validate_file_path(capture_file)
|
42
|
+
validate_file_path(capture_file) # Ensure the file path is valid
|
29
43
|
|
30
|
-
file_content = read_file_safely(capture_file)
|
31
|
-
parsed_data = JSON.parse(file_content)
|
44
|
+
file_content = read_file_safely(capture_file) # Read the file content
|
45
|
+
parsed_data = JSON.parse(file_content) # Parse the JSON content
|
32
46
|
|
33
|
-
validate_parsed_data(parsed_data)
|
47
|
+
validate_parsed_data(parsed_data) # Ensure the parsed data is valid
|
34
48
|
|
35
49
|
parsed_data
|
36
50
|
rescue => e
|
37
51
|
raise StorageError, "Failed to load capture: #{e.message}"
|
38
52
|
end
|
39
53
|
|
54
|
+
# Deletes all files in a specified directory under the capture directory.
|
55
|
+
# @param directory [String] Subdirectory name under the capture directory.
|
56
|
+
def flush(directory)
|
57
|
+
begin
|
58
|
+
dir_path = File.join(CAPTURE_DIR, directory) # Full path to the directory
|
59
|
+
|
60
|
+
unless Dir.exist?(dir_path)
|
61
|
+
raise StandardError, "Directory '#{dir_path}' does not exist."
|
62
|
+
end
|
63
|
+
|
64
|
+
files = Dir.glob("#{dir_path}/*") # Get all files in the directory
|
65
|
+
|
66
|
+
if files.empty?
|
67
|
+
puts "No files found in #{dir_path} to delete."
|
68
|
+
return
|
69
|
+
end
|
70
|
+
|
71
|
+
files.each do |file|
|
72
|
+
if File.file?(file)
|
73
|
+
begin
|
74
|
+
File.delete(file) # Delete each file
|
75
|
+
rescue Errno::EACCES
|
76
|
+
puts "Permission denied when deleting file: #{file}"
|
77
|
+
rescue StandardError => e
|
78
|
+
puts "Failed to delete file: #{file}. Error: #{e.message}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
puts "Flushed all files in #{dir_path} successfully."
|
84
|
+
|
85
|
+
rescue StandardError => e
|
86
|
+
puts "An error occurred during flush operation: #{e.message}"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
40
90
|
private
|
41
91
|
|
92
|
+
# Validates that the record has the required attributes.
|
42
93
|
def validate_record(record)
|
43
94
|
unless record.respond_to?(:class) && record.respond_to?(:id)
|
44
95
|
raise ArgumentError, "Invalid record: must respond to :class and :id"
|
@@ -49,6 +100,7 @@ module Support
|
|
49
100
|
end
|
50
101
|
end
|
51
102
|
|
103
|
+
# Validates the structure of the capture data.
|
52
104
|
def validate_capture_data(data)
|
53
105
|
unless data.is_a?(Hash)
|
54
106
|
raise ArgumentError, "Capture data must be a Hash"
|
@@ -61,6 +113,7 @@ module Support
|
|
61
113
|
end
|
62
114
|
end
|
63
115
|
|
116
|
+
# Generates a valid filename for the capture file.
|
64
117
|
def generate_filename(record, custom_name)
|
65
118
|
model_name = record.class.name.downcase.gsub(/[^a-z0-9]/, '_')
|
66
119
|
record_id = record.id.to_s
|
@@ -75,6 +128,7 @@ module Support
|
|
75
128
|
end
|
76
129
|
end
|
77
130
|
|
131
|
+
# Validates the custom filename.
|
78
132
|
def validate_filename(name)
|
79
133
|
if name.empty?
|
80
134
|
raise ArgumentError, "Filename cannot be empty"
|
@@ -89,12 +143,13 @@ module Support
|
|
89
143
|
end
|
90
144
|
end
|
91
145
|
|
146
|
+
# Creates the directory for storing capture files.
|
92
147
|
def create_capture_directory(record)
|
93
148
|
model_name = record.class.name.downcase.gsub(/[^a-z0-9]/, '_')
|
94
149
|
dir_path = File.join(CAPTURE_DIR, model_name)
|
95
150
|
|
96
151
|
begin
|
97
|
-
FileUtils.mkdir_p(dir_path)
|
152
|
+
FileUtils.mkdir_p(dir_path) # Create the directory if it doesn't exist
|
98
153
|
rescue SystemCallError => e
|
99
154
|
raise StorageError, "Failed to create directory '#{dir_path}': #{e.message}"
|
100
155
|
end
|
@@ -102,17 +157,19 @@ module Support
|
|
102
157
|
dir_path
|
103
158
|
end
|
104
159
|
|
160
|
+
# Writes the capture data to a file safely.
|
105
161
|
def write_capture_file(file_path, data)
|
106
162
|
begin
|
107
163
|
temp_path = "#{file_path}.tmp"
|
108
|
-
File.write(temp_path, JSON.pretty_generate(data))
|
109
|
-
File.rename(temp_path, file_path)
|
164
|
+
File.write(temp_path, JSON.pretty_generate(data)) # Write to a temporary file
|
165
|
+
File.rename(temp_path, file_path) # Rename to the final file
|
110
166
|
rescue SystemCallError => e
|
111
167
|
File.delete(temp_path) if File.exist?(temp_path)
|
112
168
|
raise StorageError, "Failed to write capture file '#{file_path}': #{e.message}"
|
113
169
|
end
|
114
170
|
end
|
115
171
|
|
172
|
+
# Validates the file path for loading captures.
|
116
173
|
def validate_file_path(file_path)
|
117
174
|
unless File.exist?(file_path)
|
118
175
|
raise StorageError, "File does not exist: #{file_path}"
|
@@ -127,12 +184,14 @@ module Support
|
|
127
184
|
end
|
128
185
|
end
|
129
186
|
|
187
|
+
# Reads the file content safely.
|
130
188
|
def read_file_safely(file_path)
|
131
189
|
File.read(file_path)
|
132
190
|
rescue SystemCallError => e
|
133
191
|
raise StorageError, "Failed to read file '#{file_path}': #{e.message}"
|
134
192
|
end
|
135
193
|
|
194
|
+
# Validates the parsed JSON data structure.
|
136
195
|
def validate_parsed_data(data)
|
137
196
|
unless data.is_a?(Hash)
|
138
197
|
raise StorageError, "Invalid capture format: expected JSON object"
|
@@ -146,6 +205,7 @@ module Support
|
|
146
205
|
end
|
147
206
|
end
|
148
207
|
|
208
|
+
# Custom error class for storage-related errors.
|
149
209
|
class StorageError < StandardError; end
|
150
210
|
end
|
151
211
|
end
|
data/lib/support/version.rb
CHANGED
@@ -0,0 +1,90 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require 'fileutils'
|
3
|
+
require_relative '../lib/active_capture'
|
4
|
+
|
5
|
+
# Test suite for the ActiveCapture.flush method
|
6
|
+
class ActiveCaptureFlushTest < Minitest::Test
|
7
|
+
# Directory where test captures will be stored
|
8
|
+
CAPTURES_DIR = 'captures'.freeze
|
9
|
+
|
10
|
+
# Setup method to prepare the test environment
|
11
|
+
def setup
|
12
|
+
# Create the captures directory
|
13
|
+
FileUtils.mkdir_p(CAPTURES_DIR)
|
14
|
+
# Create a user subdirectory
|
15
|
+
@user_dir = File.join(CAPTURES_DIR, 'user')
|
16
|
+
FileUtils.mkdir_p(@user_dir)
|
17
|
+
# Add test files to the user directory
|
18
|
+
File.write(File.join(@user_dir, 'test1.json'), '{}')
|
19
|
+
File.write(File.join(@user_dir, 'test2.json'), '{}')
|
20
|
+
end
|
21
|
+
|
22
|
+
# Teardown method to clean up after tests
|
23
|
+
def teardown
|
24
|
+
# Remove the captures directory and its contents
|
25
|
+
FileUtils.rm_rf(CAPTURES_DIR)
|
26
|
+
end
|
27
|
+
|
28
|
+
# Test flushing a valid directory with files
|
29
|
+
def test_flush_valid_directory
|
30
|
+
# Ensure the user directory exists and contains files
|
31
|
+
assert Dir.exist?(@user_dir)
|
32
|
+
assert_equal 2, Dir.glob(File.join(@user_dir, '*')).size
|
33
|
+
|
34
|
+
# Call the flush method
|
35
|
+
ActiveCapture.flush('user')
|
36
|
+
|
37
|
+
# Verify the directory is empty after flushing
|
38
|
+
assert_empty Dir.glob(File.join(@user_dir, '*'))
|
39
|
+
puts "test_flush_valid_directory passed"
|
40
|
+
end
|
41
|
+
|
42
|
+
# Test flushing a non-existent directory
|
43
|
+
def test_flush_non_existent_directory
|
44
|
+
# Capture the output of the flush method
|
45
|
+
output = capture_io do
|
46
|
+
ActiveCapture.flush('non_existent_dir')
|
47
|
+
end
|
48
|
+
# Verify the appropriate error message is displayed
|
49
|
+
assert_match /Directory 'captures\/non_existent_dir' does not exist./, output[0]
|
50
|
+
puts "test_flush_non_existent_directory passed"
|
51
|
+
end
|
52
|
+
|
53
|
+
# Test flushing an empty directory
|
54
|
+
def test_flush_empty_directory
|
55
|
+
# Remove and recreate the user directory to ensure it's empty
|
56
|
+
FileUtils.rm_rf(@user_dir)
|
57
|
+
FileUtils.mkdir_p(@user_dir)
|
58
|
+
assert_empty Dir.glob(File.join(@user_dir, '*'))
|
59
|
+
|
60
|
+
# Capture the output of the flush method
|
61
|
+
output = capture_io do
|
62
|
+
ActiveCapture.flush('user')
|
63
|
+
end
|
64
|
+
# Verify the appropriate message is displayed for an empty directory
|
65
|
+
assert_match /No files found in captures\/user to delete./, output[0]
|
66
|
+
puts "test_flush_empty_directory passed"
|
67
|
+
end
|
68
|
+
|
69
|
+
# Test flushing a directory with a file that has restricted permissions
|
70
|
+
def test_flush_with_permission_error
|
71
|
+
# Skip this test unless run with restricted permissions
|
72
|
+
skip "Run this test with restricted permissions to verify behavior."
|
73
|
+
|
74
|
+
# Create a protected file with no permissions
|
75
|
+
protected_file = File.join(@user_dir, 'protected.json')
|
76
|
+
File.write(protected_file, '{}')
|
77
|
+
FileUtils.chmod(0o000, protected_file)
|
78
|
+
|
79
|
+
# Capture the output of the flush method
|
80
|
+
output = capture_io do
|
81
|
+
ActiveCapture.flush('user')
|
82
|
+
end
|
83
|
+
# Verify the appropriate error message is displayed for permission issues
|
84
|
+
assert_match /Permission denied when deleting file: captures\/user\/protected.json/, output[0]
|
85
|
+
|
86
|
+
# Reset permissions for cleanup
|
87
|
+
FileUtils.chmod(0o644, protected_file)
|
88
|
+
puts "test_flush_with_permission_error passed"
|
89
|
+
end
|
90
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_capture
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.1.
|
4
|
+
version: 1.1.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tanmay Bhawsar
|
@@ -36,6 +36,7 @@ files:
|
|
36
36
|
- lib/support/version.rb
|
37
37
|
- test/support/test_capture_storage.rb
|
38
38
|
- test/test_active_capture.rb
|
39
|
+
- test/test_active_capture_flush.rb
|
39
40
|
- test/test_helper.rb
|
40
41
|
homepage: https://github.com/bhawsartanmay/active_capture
|
41
42
|
licenses:
|