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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.dockerignore +10 -0
  3. data/.gitignore +2 -0
  4. data/.rubocop.yml +25 -16
  5. data/.ruby-version +1 -1
  6. data/CHANGELOG +17 -0
  7. data/CLAUDE.md +89 -0
  8. data/Dockerfile +21 -0
  9. data/Gemfile +10 -0
  10. data/Gemfile.lock +50 -36
  11. data/README.md +38 -5
  12. data/Rakefile +1 -1
  13. data/docker-entrypoint.sh +14 -0
  14. data/free_zipcode_data.gemspec +8 -14
  15. data/lib/etl/common.rb +1 -0
  16. data/lib/etl/csv_source.rb +4 -4
  17. data/lib/free_zipcode_data/country_table.rb +10 -2
  18. data/lib/free_zipcode_data/county_table.rb +14 -6
  19. data/lib/free_zipcode_data/data_source.rb +2 -2
  20. data/lib/free_zipcode_data/db_table.rb +54 -7
  21. data/lib/free_zipcode_data/logger.rb +8 -12
  22. data/lib/free_zipcode_data/runner.rb +2 -2
  23. data/lib/free_zipcode_data/state_table.rb +37 -5
  24. data/lib/free_zipcode_data/version.rb +1 -1
  25. data/lib/free_zipcode_data/zipcode_table.rb +15 -5
  26. data/lib/free_zipcode_data.rb +3 -3
  27. data/lib/tasks/version.rake +27 -24
  28. data/spec/etl/csv_source_spec.rb +57 -0
  29. data/spec/etl/free_zipcode_data_job_spec.rb +135 -0
  30. data/spec/fixtures/.free_zipcode_data.yml +1 -0
  31. data/spec/fixtures/US.txt +5 -0
  32. data/spec/fixtures/US.zip +0 -0
  33. data/spec/fixtures/test_data.csv +7 -0
  34. data/spec/fixtures/test_data.txt +5 -0
  35. data/spec/free_zipcode_data/country_table_spec.rb +52 -0
  36. data/spec/free_zipcode_data/county_table_spec.rb +84 -0
  37. data/spec/free_zipcode_data/data_source_spec.rb +131 -0
  38. data/spec/free_zipcode_data/db_table_spec.rb +164 -0
  39. data/spec/free_zipcode_data/logger_spec.rb +78 -0
  40. data/spec/free_zipcode_data/options_spec.rb +37 -0
  41. data/spec/free_zipcode_data/runner_spec.rb +91 -0
  42. data/spec/free_zipcode_data/sqlite_ram_spec.rb +64 -0
  43. data/spec/free_zipcode_data/state_table_spec.rb +112 -0
  44. data/spec/free_zipcode_data/zipcode_table_spec.rb +102 -0
  45. data/spec/free_zipcode_data_spec.rb +38 -0
  46. data/spec/spec_helper.rb +23 -2
  47. data/spec/support/database_helpers.rb +48 -0
  48. 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