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