free_zipcode_data 1.0.6 → 1.2.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/.dockerignore +10 -0
- data/.gitignore +2 -0
- data/.rubocop.yml +25 -16
- data/.ruby-version +1 -1
- data/CHANGELOG +17 -0
- data/CLAUDE.md +89 -0
- data/Dockerfile +21 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +50 -36
- data/README.md +38 -5
- data/Rakefile +1 -1
- data/docker-entrypoint.sh +14 -0
- 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 +41 -91
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'free_zipcode_data/country_table'
|
|
4
|
+
|
|
5
|
+
RSpec.describe FreeZipcodeData::CountryTable do
|
|
6
|
+
let(:db) { create_test_database(line_count: 5) }
|
|
7
|
+
let(:table) { described_class.new(database: db, tablename: 'countries') }
|
|
8
|
+
|
|
9
|
+
before { table.build }
|
|
10
|
+
|
|
11
|
+
describe '#build' do
|
|
12
|
+
it 'creates the countries table' do
|
|
13
|
+
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='countries'")
|
|
14
|
+
expect(tables.length).to eq(1)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'creates the unique alpha2 index' do
|
|
18
|
+
indexes = db.execute("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='countries'")
|
|
19
|
+
index_names = indexes.map(&:first)
|
|
20
|
+
expect(index_names).to include('unique_country_alpha2')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'creates columns for alpha2, alpha3, iso, and name' do
|
|
24
|
+
columns = db.execute("PRAGMA table_info('countries')").map { |c| c[1] }
|
|
25
|
+
expect(columns).to include('alpha2', 'alpha3', 'iso', 'name')
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
describe '#write' do
|
|
30
|
+
it 'inserts a country row using the lookup table' do
|
|
31
|
+
table.write({ country: 'US' })
|
|
32
|
+
rows = db.execute('SELECT alpha2, alpha3, name FROM countries')
|
|
33
|
+
expect(rows.length).to eq(1)
|
|
34
|
+
expect(rows[0]).to eq(['US', 'USA', 'United States of America'])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'inserts multiple different countries' do
|
|
38
|
+
table.write({ country: 'US' })
|
|
39
|
+
table.write({ country: 'CA' })
|
|
40
|
+
table.write({ country: 'GB' })
|
|
41
|
+
rows = db.execute('SELECT alpha2 FROM countries ORDER BY alpha2')
|
|
42
|
+
expect(rows.flatten).to eq(%w[CA GB US])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'silently ignores duplicate country codes' do
|
|
46
|
+
table.write({ country: 'US' })
|
|
47
|
+
expect { table.write({ country: 'US' }) }.not_to raise_error
|
|
48
|
+
rows = db.execute('SELECT COUNT(*) FROM countries')
|
|
49
|
+
expect(rows[0][0]).to eq(1)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'free_zipcode_data/county_table'
|
|
4
|
+
|
|
5
|
+
RSpec.describe FreeZipcodeData::CountyTable do
|
|
6
|
+
let(:db) { create_test_database(line_count: 5) }
|
|
7
|
+
let(:table) { described_class.new(database: db, tablename: 'counties') }
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
seed_countries(db)
|
|
11
|
+
seed_states(db)
|
|
12
|
+
table.build
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#build' do
|
|
16
|
+
it 'creates the counties table' do
|
|
17
|
+
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='counties'")
|
|
18
|
+
expect(tables.length).to eq(1)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'creates the unique_county index' do
|
|
22
|
+
indexes = db.execute("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='counties'")
|
|
23
|
+
index_names = indexes.map(&:first)
|
|
24
|
+
expect(index_names).to include('unique_county')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'creates columns for state_id, abbr, name, and county_seat' do
|
|
28
|
+
columns = db.execute("PRAGMA table_info('counties')").map { |c| c[1] }
|
|
29
|
+
expect(columns).to include('state_id', 'abbr', 'name', 'county_seat')
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#write' do
|
|
34
|
+
it 'inserts a county row' do
|
|
35
|
+
table.write({ country: 'US', county: 'Cook', short_county: '031', short_state: 'IL',
|
|
36
|
+
state: 'Illinois' })
|
|
37
|
+
rows = db.execute('SELECT name, abbr FROM counties')
|
|
38
|
+
expect(rows.length).to eq(1)
|
|
39
|
+
expect(rows[0]).to eq(%w[Cook 031])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'links the county to its state' do
|
|
43
|
+
table.write({ country: 'US', county: 'Cook', short_county: '031', short_state: 'IL',
|
|
44
|
+
state: 'Illinois' })
|
|
45
|
+
state_id = db.execute("SELECT id FROM states WHERE abbr = 'IL'")[0][0]
|
|
46
|
+
county_state_id = db.execute('SELECT state_id FROM counties')[0][0]
|
|
47
|
+
expect(county_state_id).to eq(state_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'returns nil and skips when county is nil' do
|
|
51
|
+
result = table.write({ country: 'US', county: nil, short_county: nil, short_state: 'IL',
|
|
52
|
+
state: 'Illinois' })
|
|
53
|
+
expect(result).to be_nil
|
|
54
|
+
rows = db.execute('SELECT COUNT(*) FROM counties')
|
|
55
|
+
expect(rows[0][0]).to eq(0)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns nil when state cannot be found' do
|
|
59
|
+
result = table.write({ country: 'US', county: 'Unknown', short_county: '999', short_state: 'ZZ',
|
|
60
|
+
state: 'Nonexistent' })
|
|
61
|
+
expect(result).to be_nil
|
|
62
|
+
rows = db.execute('SELECT COUNT(*) FROM counties')
|
|
63
|
+
expect(rows[0][0]).to eq(0)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'silently ignores duplicate county entries' do
|
|
67
|
+
table.write({ country: 'US', county: 'Cook', short_county: '031', short_state: 'IL',
|
|
68
|
+
state: 'Illinois' })
|
|
69
|
+
expect do
|
|
70
|
+
table.write({ country: 'US', county: 'Cook', short_county: '031', short_state: 'IL',
|
|
71
|
+
state: 'Illinois' })
|
|
72
|
+
end.not_to raise_error
|
|
73
|
+
rows = db.execute('SELECT COUNT(*) FROM counties')
|
|
74
|
+
expect(rows[0][0]).to eq(1)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'handles county names with single quotes' do
|
|
78
|
+
table.write({ country: 'US', county: "Prince George's", short_county: '033', short_state: 'NY',
|
|
79
|
+
state: 'New York' })
|
|
80
|
+
rows = db.execute('SELECT name FROM counties')
|
|
81
|
+
expect(rows[0][0]).to eq("Prince George's")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -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
|