free_zipcode_data 1.0.6 → 1.1.0
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/.gitignore +1 -0
- data/.rubocop.yml +25 -16
- data/.ruby-version +1 -1
- data/CHANGELOG +11 -0
- data/CLAUDE.md +89 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +50 -36
- data/README.md +3 -5
- data/Rakefile +1 -1
- data/free_zipcode_data.gemspec +8 -14
- data/lib/etl/common.rb +1 -0
- data/lib/etl/csv_source.rb +4 -4
- data/lib/free_zipcode_data/country_table.rb +10 -2
- data/lib/free_zipcode_data/county_table.rb +14 -6
- data/lib/free_zipcode_data/data_source.rb +2 -2
- data/lib/free_zipcode_data/db_table.rb +54 -7
- data/lib/free_zipcode_data/logger.rb +8 -12
- data/lib/free_zipcode_data/runner.rb +2 -2
- data/lib/free_zipcode_data/state_table.rb +37 -5
- data/lib/free_zipcode_data/version.rb +1 -1
- data/lib/free_zipcode_data/zipcode_table.rb +15 -5
- data/lib/free_zipcode_data.rb +3 -3
- data/lib/tasks/version.rake +27 -24
- data/spec/etl/csv_source_spec.rb +57 -0
- data/spec/etl/free_zipcode_data_job_spec.rb +135 -0
- data/spec/fixtures/.free_zipcode_data.yml +1 -0
- data/spec/fixtures/US.txt +5 -0
- data/spec/fixtures/US.zip +0 -0
- data/spec/fixtures/test_data.csv +7 -0
- data/spec/fixtures/test_data.txt +5 -0
- data/spec/free_zipcode_data/country_table_spec.rb +52 -0
- data/spec/free_zipcode_data/county_table_spec.rb +84 -0
- data/spec/free_zipcode_data/data_source_spec.rb +131 -0
- data/spec/free_zipcode_data/db_table_spec.rb +164 -0
- data/spec/free_zipcode_data/logger_spec.rb +78 -0
- data/spec/free_zipcode_data/options_spec.rb +37 -0
- data/spec/free_zipcode_data/runner_spec.rb +91 -0
- data/spec/free_zipcode_data/sqlite_ram_spec.rb +64 -0
- data/spec/free_zipcode_data/state_table_spec.rb +112 -0
- data/spec/free_zipcode_data/zipcode_table_spec.rb +102 -0
- data/spec/free_zipcode_data_spec.rb +38 -0
- data/spec/spec_helper.rb +23 -2
- data/spec/support/database_helpers.rb +48 -0
- metadata +38 -91
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'free_zipcode_data/data_source'
|
|
4
|
+
|
|
5
|
+
RSpec.describe FreeZipcodeData::DataSource do
|
|
6
|
+
let(:work_dir) { Dir.mktmpdir('datasource_test') }
|
|
7
|
+
let(:options) do
|
|
8
|
+
OpenStruct.new(
|
|
9
|
+
work_dir: work_dir,
|
|
10
|
+
clobber: false,
|
|
11
|
+
country: 'US',
|
|
12
|
+
verbose: false
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
let(:options_instance) { FreeZipcodeData::Options.instance }
|
|
16
|
+
|
|
17
|
+
before do
|
|
18
|
+
options_instance.initialize_hash(options)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
after do
|
|
22
|
+
FileUtils.rm_rf(work_dir)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe '#initialize' do
|
|
26
|
+
it 'stores the country' do
|
|
27
|
+
ds = described_class.new('US')
|
|
28
|
+
expect(ds.country).to eq('US')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'defaults country to nil' do
|
|
32
|
+
ds = described_class.new
|
|
33
|
+
expect(ds.country).to be_nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe '#download' do
|
|
38
|
+
let(:datasource) { described_class.new('US') }
|
|
39
|
+
let(:fixture_zip) { File.read(File.join(FreeZipcodeData.root, 'spec', 'fixtures', 'US.zip')) }
|
|
40
|
+
|
|
41
|
+
it 'downloads and saves the zip file' do
|
|
42
|
+
uri_object = instance_double(URI::HTTP)
|
|
43
|
+
allow(URI).to receive(:parse).and_return(uri_object)
|
|
44
|
+
allow(uri_object).to receive(:open).and_yield(StringIO.new(fixture_zip))
|
|
45
|
+
|
|
46
|
+
datasource.download
|
|
47
|
+
|
|
48
|
+
expect(File.exist?(File.join(work_dir, 'US.zip'))).to be true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'skips download if the file already exists and clobber is false' do
|
|
52
|
+
FileUtils.touch(File.join(work_dir, 'US.zip'))
|
|
53
|
+
|
|
54
|
+
expect(URI).not_to receive(:parse)
|
|
55
|
+
datasource.download
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'redownloads if clobber is true' do
|
|
59
|
+
FileUtils.touch(File.join(work_dir, 'US.zip'))
|
|
60
|
+
options_instance.initialize_hash(OpenStruct.new(work_dir: work_dir, clobber: true, country: 'US',
|
|
61
|
+
verbose: false))
|
|
62
|
+
|
|
63
|
+
uri_object = instance_double(URI::HTTP)
|
|
64
|
+
allow(URI).to receive(:parse).and_return(uri_object)
|
|
65
|
+
allow(uri_object).to receive(:open).and_yield(StringIO.new(fixture_zip))
|
|
66
|
+
|
|
67
|
+
datasource.download
|
|
68
|
+
|
|
69
|
+
expect(File.size(File.join(work_dir, 'US.zip'))).to be > 0
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe '#datafile' do
|
|
74
|
+
let(:datasource) { described_class.new('US') }
|
|
75
|
+
|
|
76
|
+
before do
|
|
77
|
+
fixture_dir = File.join(FreeZipcodeData.root, 'spec', 'fixtures')
|
|
78
|
+
# Copy fixture zip and pre-extracted text to work_dir
|
|
79
|
+
FileUtils.cp(File.join(fixture_dir, 'US.zip'), File.join(work_dir, 'US.zip'))
|
|
80
|
+
FileUtils.cp(File.join(fixture_dir, 'US.txt'), File.join(work_dir, 'US.txt'))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'returns a CSV file path with headers prepended' do
|
|
84
|
+
result = datasource.datafile
|
|
85
|
+
expect(result).to end_with('.csv')
|
|
86
|
+
expect(File.exist?(result)).to be true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'prepends headers to the extracted data' do
|
|
90
|
+
result = datasource.datafile
|
|
91
|
+
first_line = File.open(result, &:readline)
|
|
92
|
+
expect(first_line).to include('COUNTRY')
|
|
93
|
+
expect(first_line).to include('POSTAL_CODE')
|
|
94
|
+
expect(first_line).to include('LATITUDE')
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'contains the original data rows' do
|
|
98
|
+
result = datasource.datafile
|
|
99
|
+
lines = File.readlines(result)
|
|
100
|
+
# header + 5 data rows
|
|
101
|
+
expect(lines.length).to eq(6)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'does not re-extract if CSV already exists and clobber is false' do
|
|
105
|
+
first = datasource.datafile
|
|
106
|
+
mtime = File.mtime(first)
|
|
107
|
+
sleep(0.1)
|
|
108
|
+
# Create a new instance to avoid memoization
|
|
109
|
+
ds2 = described_class.new('US')
|
|
110
|
+
second = ds2.datafile
|
|
111
|
+
expect(File.mtime(second)).to eq(mtime)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe 'zipfile naming' do
|
|
116
|
+
it 'uses country code for single country' do
|
|
117
|
+
ds = described_class.new('US')
|
|
118
|
+
expect(ds.send(:zipfile)).to eq('US.zip')
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'uppercases the country code' do
|
|
122
|
+
ds = described_class.new('us')
|
|
123
|
+
expect(ds.send(:zipfile)).to eq('US.zip')
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'uses allCountries when no country specified' do
|
|
127
|
+
ds = described_class.new(nil)
|
|
128
|
+
expect(ds.send(:zipfile)).to eq('allCountries.zip')
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'free_zipcode_data/db_table'
|
|
4
|
+
|
|
5
|
+
RSpec.describe FreeZipcodeData::DbTable do
|
|
6
|
+
let(:db) { create_test_database(line_count: 5) }
|
|
7
|
+
|
|
8
|
+
# DbTable is abstract - we need a concrete subclass to test it
|
|
9
|
+
let(:concrete_class) do
|
|
10
|
+
Class.new(described_class) do
|
|
11
|
+
def build; end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
let(:table) { concrete_class.new(database: db, tablename: 'test_table') }
|
|
16
|
+
|
|
17
|
+
describe '#initialize' do
|
|
18
|
+
it 'stores the database and tablename' do
|
|
19
|
+
expect(table.database).to eq(db)
|
|
20
|
+
expect(table.tablename).to eq('test_table')
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe '#update_progress' do
|
|
25
|
+
it 'increments the progress bar without error' do
|
|
26
|
+
expect { table.update_progress }.not_to raise_error
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe 'private #escape_single_quotes' do
|
|
31
|
+
it 'escapes single quotes for SQL safety' do
|
|
32
|
+
result = table.send(:escape_single_quotes, "O'Brien")
|
|
33
|
+
expect(result).to eq("O''Brien")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'handles nil gracefully' do
|
|
37
|
+
result = table.send(:escape_single_quotes, nil)
|
|
38
|
+
expect(result).to eq('')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'handles strings without quotes' do
|
|
42
|
+
result = table.send(:escape_single_quotes, 'Chicago')
|
|
43
|
+
expect(result).to eq('Chicago')
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe 'private #country_lookup_table' do
|
|
48
|
+
it 'loads the YAML lookup table' do
|
|
49
|
+
lookup = table.send(:country_lookup_table)
|
|
50
|
+
expect(lookup).to be_a(Hash)
|
|
51
|
+
expect(lookup['US'][:name]).to eq('United States of America')
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe 'private #select_first' do
|
|
56
|
+
it 'returns the first column of the first row' do
|
|
57
|
+
result = table.send(:select_first, "SELECT value FROM meta WHERE name = 'line_count'")
|
|
58
|
+
expect(result).to eq('5')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'returns nil when no rows match' do
|
|
62
|
+
result = table.send(:select_first, "SELECT value FROM meta WHERE name = 'nonexistent'")
|
|
63
|
+
expect(result).to be_nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'raises with issue URL on SQL error' do
|
|
67
|
+
expect do
|
|
68
|
+
table.send(:select_first, 'SELECT * FROM nonexistent_table')
|
|
69
|
+
end.to raise_error(/Please file an issue/)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
context 'with seeded countries and states' do
|
|
74
|
+
before do
|
|
75
|
+
seed_countries(db)
|
|
76
|
+
seed_states(db)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe 'private #get_country_id' do
|
|
80
|
+
it 'returns the country ID for a known alpha2 code' do
|
|
81
|
+
id = table.send(:get_country_id, 'US')
|
|
82
|
+
expect(id).to be_a(Integer)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'returns nil for an unknown country' do
|
|
86
|
+
id = table.send(:get_country_id, 'ZZ')
|
|
87
|
+
expect(id).to be_nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe 'private #get_state_id' do
|
|
92
|
+
it 'finds a state by exact match (abbr + name + country)' do
|
|
93
|
+
expected_id = db.execute("SELECT id FROM states WHERE abbr = 'NY'")[0][0]
|
|
94
|
+
id = table.send(:get_state_id, 'US', 'NY', 'New York')
|
|
95
|
+
expect(id).to eq(expected_id)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'falls back to abbr + country when name does not match' do
|
|
99
|
+
expected_id = db.execute("SELECT id FROM states WHERE abbr = 'NY'")[0][0]
|
|
100
|
+
id = table.send(:get_state_id, 'US', 'NY', 'Wrong Name')
|
|
101
|
+
expect(id).to eq(expected_id)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'falls back to name + country when abbr does not match' do
|
|
105
|
+
expected_id = db.execute("SELECT id FROM states WHERE name = 'New York'")[0][0]
|
|
106
|
+
id = table.send(:get_state_id, 'US', 'XX', 'New York')
|
|
107
|
+
expect(id).to eq(expected_id)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'returns nil for an unknown state' do
|
|
111
|
+
id = table.send(:get_state_id, 'US', 'ZZ', 'Nonexistent')
|
|
112
|
+
expect(id).to be_nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'returns nil when country is nil' do
|
|
116
|
+
id = table.send(:get_state_id, nil, 'NY', 'New York')
|
|
117
|
+
expect(id).to be_nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'scopes lookup by country' do
|
|
121
|
+
# Seed a Canadian state with the same abbr as a US state
|
|
122
|
+
ca_state_table = FreeZipcodeData::StateTable.new(database: db, tablename: 'states')
|
|
123
|
+
ca_state_table.write({ country: 'CA', short_state: 'NY', state: 'Northern Yukon' })
|
|
124
|
+
|
|
125
|
+
us_id = table.send(:get_state_id, 'US', 'NY', 'New York')
|
|
126
|
+
ca_id = table.send(:get_state_id, 'CA', 'NY', 'Northern Yukon')
|
|
127
|
+
|
|
128
|
+
expect(us_id).to be_a(Integer)
|
|
129
|
+
expect(ca_id).to be_a(Integer)
|
|
130
|
+
expect(us_id).not_to eq(ca_id)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'returns nil when all three fallbacks fail' do
|
|
134
|
+
id = table.send(:get_state_id, 'GB', 'ZZ', 'Nonexistent')
|
|
135
|
+
expect(id).to be_nil
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
context 'with seeded counties' do
|
|
141
|
+
before do
|
|
142
|
+
seed_countries(db)
|
|
143
|
+
seed_states(db)
|
|
144
|
+
seed_counties(db)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
describe 'private #get_county_id' do
|
|
148
|
+
it 'returns the county ID for a known county name' do
|
|
149
|
+
id = table.send(:get_county_id, 'Cook')
|
|
150
|
+
expect(id).to be_a(Integer)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'returns nil for nil county' do
|
|
154
|
+
id = table.send(:get_county_id, nil)
|
|
155
|
+
expect(id).to be_nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'returns nil for an unknown county' do
|
|
159
|
+
id = table.send(:get_county_id, 'Nonexistent County')
|
|
160
|
+
expect(id).to be_nil
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe FreeZipcodeData::Logger do
|
|
4
|
+
let(:logger) { described_class.instance }
|
|
5
|
+
let(:string_io) { StringIO.new }
|
|
6
|
+
let(:test_provider) { Logger.new(string_io) }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
logger.log_provider = test_provider
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
after do
|
|
13
|
+
# Restore default logger
|
|
14
|
+
logger.log_provider = Logger.new($stdout)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#info' do
|
|
18
|
+
it 'delegates to the log provider' do
|
|
19
|
+
logger.info('test message')
|
|
20
|
+
expect(string_io.string).to include('test message')
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
describe '#log_exception' do
|
|
25
|
+
it 'logs exception class, message, and backtrace' do
|
|
26
|
+
error = begin
|
|
27
|
+
raise StandardError, 'something broke'
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
e
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
logger.log_exception(error)
|
|
33
|
+
output = string_io.string
|
|
34
|
+
expect(output).to include('EXCEPTION')
|
|
35
|
+
expect(output).to include('StandardError')
|
|
36
|
+
expect(output).to include('something broke')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'includes data hash when provided' do
|
|
40
|
+
error = begin
|
|
41
|
+
raise StandardError, 'oops'
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
e
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
logger.log_exception(error, { user_id: 42 })
|
|
47
|
+
expect(string_io.string).to include('user_id')
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#verbose' do
|
|
52
|
+
let(:options) { FreeZipcodeData::Options.instance }
|
|
53
|
+
|
|
54
|
+
it 'logs when verbose option is true' do
|
|
55
|
+
options.initialize_hash(OpenStruct.new(verbose: true))
|
|
56
|
+
logger.verbose('verbose message')
|
|
57
|
+
expect(string_io.string).to include('verbose message')
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'does not log when verbose option is false' do
|
|
61
|
+
options.initialize_hash(OpenStruct.new(verbose: false))
|
|
62
|
+
logger.verbose('should not appear')
|
|
63
|
+
expect(string_io.string).not_to include('should not appear')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
describe '#respond_to?' do
|
|
68
|
+
it 'returns true for methods the log provider responds to' do
|
|
69
|
+
expect(logger.respond_to?(:info)).to be true
|
|
70
|
+
expect(logger.respond_to?(:warn)).to be true
|
|
71
|
+
expect(logger.respond_to?(:error)).to be true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'returns false for unknown methods' do
|
|
75
|
+
expect(logger.respond_to?(:nonexistent_method)).to be false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe FreeZipcodeData::Options do
|
|
4
|
+
let(:options) { described_class.instance }
|
|
5
|
+
|
|
6
|
+
after do
|
|
7
|
+
# Reset singleton state
|
|
8
|
+
options.initialize_hash({})
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
describe '#initialize_hash' do
|
|
12
|
+
it 'stores the given hash' do
|
|
13
|
+
options.initialize_hash({ work_dir: '/tmp/claude/test', country: 'US' })
|
|
14
|
+
expect(options.hash).to include(work_dir: '/tmp/claude/test', country: 'US')
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe '#[]' do
|
|
19
|
+
it 'returns the value for the given key' do
|
|
20
|
+
options.initialize_hash({ country: 'GB' })
|
|
21
|
+
expect(options[:country]).to eq('GB')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'returns nil for missing keys' do
|
|
25
|
+
options.initialize_hash({})
|
|
26
|
+
expect(options[:nonexistent]).to be_nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '#hash' do
|
|
31
|
+
it 'returns the full options hash' do
|
|
32
|
+
data = { work_dir: '/tmp/claude/test', verbose: true }
|
|
33
|
+
options.initialize_hash(data)
|
|
34
|
+
expect(options.hash).to eq(data)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'free_zipcode_data/runner'
|
|
4
|
+
|
|
5
|
+
RSpec.describe FreeZipcodeData::Runner do
|
|
6
|
+
let(:work_dir) { Dir.mktmpdir('runner_test') }
|
|
7
|
+
let(:fixture_zip) { File.join(FreeZipcodeData.root, 'spec', 'fixtures', 'US.zip') }
|
|
8
|
+
let(:string_io) { StringIO.new }
|
|
9
|
+
|
|
10
|
+
after do
|
|
11
|
+
FileUtils.rm_rf(work_dir)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '.instance' do
|
|
15
|
+
it 'returns a Runner instance' do
|
|
16
|
+
expect(described_class.instance).to be_a(described_class)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe '#initialize' do
|
|
21
|
+
it 'sets up a logger' do
|
|
22
|
+
runner = described_class.new
|
|
23
|
+
expect(runner.logger).to eq(FreeZipcodeData::Logger.instance)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '#start' do
|
|
28
|
+
let(:runner) { described_class.new }
|
|
29
|
+
|
|
30
|
+
before do
|
|
31
|
+
# Suppress logger output
|
|
32
|
+
runner.logger.log_provider = Logger.new(string_io)
|
|
33
|
+
|
|
34
|
+
# Copy fixture zip and pre-extracted text into work_dir
|
|
35
|
+
fixture_dir = File.join(FreeZipcodeData.root, 'spec', 'fixtures')
|
|
36
|
+
FileUtils.mkdir_p(work_dir)
|
|
37
|
+
FileUtils.cp(File.join(fixture_dir, 'US.zip'), File.join(work_dir, 'US.zip'))
|
|
38
|
+
FileUtils.cp(File.join(fixture_dir, 'US.txt'), File.join(work_dir, 'US.txt'))
|
|
39
|
+
|
|
40
|
+
# Stub ARGV to provide required CLI args
|
|
41
|
+
stub_const('ARGV', [
|
|
42
|
+
'--work-dir', work_dir,
|
|
43
|
+
'--country', 'US',
|
|
44
|
+
'--generate-files'
|
|
45
|
+
])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'creates an SQLite database in the work directory' do
|
|
49
|
+
runner.start
|
|
50
|
+
expect(File.exist?(File.join(work_dir, 'free_zipcode_data.sqlite3'))).to be true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'generates CSV files when --generate-files is specified' do
|
|
54
|
+
runner.start
|
|
55
|
+
%w[countries states counties zipcodes].each do |table|
|
|
56
|
+
expect(File.exist?(File.join(work_dir, "#{table}.csv"))).to be true
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'populates the SQLite database with data' do
|
|
61
|
+
runner.start
|
|
62
|
+
db = SQLite3::Database.new(File.join(work_dir, 'free_zipcode_data.sqlite3'))
|
|
63
|
+
country_count = db.execute('SELECT COUNT(*) FROM countries')[0][0]
|
|
64
|
+
zipcode_count = db.execute('SELECT COUNT(*) FROM zipcodes')[0][0]
|
|
65
|
+
expect(country_count).to be >= 1
|
|
66
|
+
expect(zipcode_count).to be >= 1
|
|
67
|
+
db.close
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'sets the options on the runner' do
|
|
71
|
+
runner.start
|
|
72
|
+
expect(runner.options).not_to be_nil
|
|
73
|
+
expect(runner.options[:work_dir]).to eq(work_dir)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
context 'without --generate-files' do
|
|
77
|
+
before do
|
|
78
|
+
stub_const('ARGV', [
|
|
79
|
+
'--work-dir', work_dir,
|
|
80
|
+
'--country', 'US'
|
|
81
|
+
])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'creates the database but not CSV files' do
|
|
85
|
+
runner.start
|
|
86
|
+
expect(File.exist?(File.join(work_dir, 'free_zipcode_data.sqlite3'))).to be true
|
|
87
|
+
expect(File.exist?(File.join(work_dir, 'countries.csv'))).to be false
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tempfile'
|
|
4
|
+
require 'free_zipcode_data/sqlite_ram'
|
|
5
|
+
|
|
6
|
+
RSpec.describe SqliteRam do
|
|
7
|
+
let(:tmpdir) { Dir.mktmpdir('sqlite_ram_test') }
|
|
8
|
+
let(:db_path) { File.join(tmpdir, 'test_db.sqlite3') }
|
|
9
|
+
let(:sqlite_ram) { described_class.new(db_path) }
|
|
10
|
+
|
|
11
|
+
after do
|
|
12
|
+
FileUtils.rm_rf(tmpdir)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#initialize' do
|
|
16
|
+
it 'creates an in-memory database connection' do
|
|
17
|
+
expect(sqlite_ram.conn).to be_a(SQLite3::Database)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'stores the filename' do
|
|
21
|
+
expect(sqlite_ram.filename).to eq(db_path)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe '#save_to_disk' do
|
|
26
|
+
it 'persists in-memory data to the file database' do
|
|
27
|
+
sqlite_ram.conn.execute('CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)')
|
|
28
|
+
sqlite_ram.conn.execute("INSERT INTO test (name) VALUES ('hello')")
|
|
29
|
+
sqlite_ram.save_to_disk
|
|
30
|
+
|
|
31
|
+
file_db = SQLite3::Database.new(db_path)
|
|
32
|
+
rows = file_db.execute('SELECT name FROM test')
|
|
33
|
+
expect(rows).to eq([['hello']])
|
|
34
|
+
file_db.close
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#dump_tables' do
|
|
39
|
+
it 'exports each table to a CSV file in the given directory' do
|
|
40
|
+
sqlite_ram.conn.execute('CREATE TABLE widgets (id INTEGER PRIMARY KEY, name TEXT, weight REAL)')
|
|
41
|
+
sqlite_ram.conn.execute("INSERT INTO widgets (name, weight) VALUES ('gear', 1.5)")
|
|
42
|
+
sqlite_ram.conn.execute("INSERT INTO widgets (name, weight) VALUES ('bolt', 0.3)")
|
|
43
|
+
|
|
44
|
+
sqlite_ram.dump_tables(tmpdir)
|
|
45
|
+
|
|
46
|
+
csv_path = File.join(tmpdir, 'widgets.csv')
|
|
47
|
+
expect(File.exist?(csv_path)).to be true
|
|
48
|
+
|
|
49
|
+
csv = CSV.read(csv_path)
|
|
50
|
+
expect(csv[0]).to eq(%w[id name weight])
|
|
51
|
+
expect(csv.length).to eq(3) # header + 2 rows
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'exports multiple tables' do
|
|
55
|
+
sqlite_ram.conn.execute('CREATE TABLE a (id INTEGER PRIMARY KEY)')
|
|
56
|
+
sqlite_ram.conn.execute('CREATE TABLE b (id INTEGER PRIMARY KEY)')
|
|
57
|
+
|
|
58
|
+
sqlite_ram.dump_tables(tmpdir)
|
|
59
|
+
|
|
60
|
+
expect(File.exist?(File.join(tmpdir, 'a.csv'))).to be true
|
|
61
|
+
expect(File.exist?(File.join(tmpdir, 'b.csv'))).to be true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'free_zipcode_data/state_table'
|
|
4
|
+
|
|
5
|
+
RSpec.describe FreeZipcodeData::StateTable do
|
|
6
|
+
let(:db) { create_test_database(line_count: 5) }
|
|
7
|
+
let(:table) { described_class.new(database: db, tablename: 'states') }
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
seed_countries(db)
|
|
11
|
+
table.build
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '#build' do
|
|
15
|
+
it 'creates the states table' do
|
|
16
|
+
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='states'")
|
|
17
|
+
expect(tables.length).to eq(1)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'creates the unique_state index' do
|
|
21
|
+
indexes = db.execute("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='states'")
|
|
22
|
+
index_names = indexes.map(&:first)
|
|
23
|
+
expect(index_names).to include('unique_state')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'creates the state_name index' do
|
|
27
|
+
indexes = db.execute("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='states'")
|
|
28
|
+
index_names = indexes.map(&:first)
|
|
29
|
+
expect(index_names).to include('state_name')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'creates columns for country_id, abbr, and name' do
|
|
33
|
+
columns = db.execute("PRAGMA table_info('states')").map { |c| c[1] }
|
|
34
|
+
expect(columns).to include('country_id', 'abbr', 'name')
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#write' do
|
|
39
|
+
it 'inserts a state row' do
|
|
40
|
+
table.write({ country: 'US', short_state: 'NY', state: 'New York' })
|
|
41
|
+
rows = db.execute('SELECT abbr, name FROM states')
|
|
42
|
+
expect(rows.length).to eq(1)
|
|
43
|
+
expect(rows[0]).to eq(['NY', 'New York'])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'links the state to its country' do
|
|
47
|
+
table.write({ country: 'US', short_state: 'NY', state: 'New York' })
|
|
48
|
+
country_id = db.execute("SELECT id FROM countries WHERE alpha2 = 'US'")[0][0]
|
|
49
|
+
state_country_id = db.execute('SELECT country_id FROM states')[0][0]
|
|
50
|
+
expect(state_country_id).to eq(country_id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'creates a state from country lookup when short_state is nil' do
|
|
54
|
+
row = { country: 'US', short_state: nil, state: 'Unknown' }
|
|
55
|
+
table.write(row)
|
|
56
|
+
rows = db.execute("SELECT abbr, name FROM states WHERE abbr = 'US'")
|
|
57
|
+
expect(rows.length).to eq(1)
|
|
58
|
+
expect(rows[0]).to eq(['US', 'United States of America'])
|
|
59
|
+
# Verify row mutation for downstream Kiba destinations
|
|
60
|
+
expect(row[:short_state]).to eq('US')
|
|
61
|
+
expect(row[:state]).to eq('United States of America')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'creates a state from country lookup when short_state is empty' do
|
|
65
|
+
table.write({ country: 'US', short_state: '', state: 'Unknown' })
|
|
66
|
+
rows = db.execute("SELECT abbr, name FROM states WHERE abbr = 'US'")
|
|
67
|
+
expect(rows.length).to eq(1)
|
|
68
|
+
expect(rows[0]).to eq(['US', 'United States of America'])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'returns nil when short_state is nil and country is unknown' do
|
|
72
|
+
result = table.write({ country: 'ZZ', short_state: nil, state: 'Unknown' })
|
|
73
|
+
expect(result).to be_nil
|
|
74
|
+
rows = db.execute('SELECT COUNT(*) FROM states')
|
|
75
|
+
expect(rows[0][0]).to eq(0)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'returns nil when country is not in the countries table' do
|
|
79
|
+
result = table.write({ country: 'DE', short_state: 'BY', state: 'Bavaria' })
|
|
80
|
+
expect(result).to be_nil
|
|
81
|
+
rows = db.execute('SELECT COUNT(*) FROM states')
|
|
82
|
+
expect(rows[0][0]).to eq(0)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'silently ignores duplicate state entries' do
|
|
86
|
+
table.write({ country: 'US', short_state: 'NY', state: 'New York' })
|
|
87
|
+
expect { table.write({ country: 'US', short_state: 'NY', state: 'New York' }) }.not_to raise_error
|
|
88
|
+
rows = db.execute('SELECT COUNT(*) FROM states')
|
|
89
|
+
expect(rows[0][0]).to eq(1)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'handles the Marshall Islands edge case' do
|
|
93
|
+
table.write({ country: 'US', short_state: 'MH', state: nil })
|
|
94
|
+
rows = db.execute("SELECT name FROM states WHERE abbr = 'MH'")
|
|
95
|
+
expect(rows[0][0]).to eq('Marshall Islands')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'handles state names with single quotes' do
|
|
99
|
+
# Some international state names can have apostrophes
|
|
100
|
+
table.write({ country: 'US', short_state: 'TX', state: "Cote d'Ivoire" })
|
|
101
|
+
rows = db.execute("SELECT name FROM states WHERE abbr = 'TX'")
|
|
102
|
+
expect(rows[0][0]).to eq("Cote d'Ivoire")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'allows states with the same name in different countries' do
|
|
106
|
+
table.write({ country: 'US', short_state: 'BC', state: 'British Columbia' })
|
|
107
|
+
table.write({ country: 'CA', short_state: 'BC', state: 'British Columbia' })
|
|
108
|
+
rows = db.execute("SELECT COUNT(*) FROM states WHERE name = 'British Columbia'")
|
|
109
|
+
expect(rows[0][0]).to eq(2)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|