curzon_uk 1.0.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +47 -0
  3. data/.github/workflows/release.yml +73 -0
  4. data/.gitignore +19 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +11 -0
  7. data/COMM-LICENSE +22 -0
  8. data/Gemfile +3 -0
  9. data/LICENSE +617 -0
  10. data/README.md +164 -0
  11. data/Rakefile +38 -0
  12. data/curzon_uk.gemspec +31 -0
  13. data/lib/curzon_uk/cinema.rb +109 -0
  14. data/lib/curzon_uk/internal/api_response.rb +35 -0
  15. data/lib/curzon_uk/internal/guest_token.rb +27 -0
  16. data/lib/curzon_uk/internal/parser/api/showtime.rb +62 -0
  17. data/lib/curzon_uk/internal/title_sanitizer.rb +32 -0
  18. data/lib/curzon_uk/performance.rb +82 -0
  19. data/lib/curzon_uk/version.rb +6 -0
  20. data/lib/curzon_uk.rb +30 -0
  21. data/test/fixtures/api/showtimes-SOH1.json +58 -0
  22. data/test/fixtures/api/sites.json +40 -0
  23. data/test/lib/curzon_uk/cinema_test.rb +84 -0
  24. data/test/lib/curzon_uk/internal/api_response_test.rb +52 -0
  25. data/test/lib/curzon_uk/internal/fixture_shape_test.rb +14 -0
  26. data/test/lib/curzon_uk/internal/guest_token_test.rb +91 -0
  27. data/test/lib/curzon_uk/internal/parser/api/showtime_test.rb +59 -0
  28. data/test/lib/curzon_uk/internal/title_sanitizer_test.rb +31 -0
  29. data/test/lib/curzon_uk/performance_test.rb +106 -0
  30. data/test/lib/curzon_uk/version_test.rb +7 -0
  31. data/test/live/api_shape_test.rb +19 -0
  32. data/test/live/integration_test.rb +27 -0
  33. data/test/live/live_helper.rb +11 -0
  34. data/test/package_test.rb +56 -0
  35. data/test/support/api_fixtures_helper.rb +17 -0
  36. data/test/support/api_shapes.rb +48 -0
  37. data/test/support/fake_api_response.rb +20 -0
  38. data/test/test_helper.rb +20 -0
  39. metadata +208 -0
@@ -0,0 +1,40 @@
1
+ {
2
+ "sites": [
3
+ {
4
+ "id": "CAN2",
5
+ "name": { "text": "Canterbury" },
6
+ "contactDetails": {
7
+ "addressV2": {
8
+ "line1": { "text": "Unit 9," },
9
+ "line2": { "text": "The Example Riverside," },
10
+ "line3": { "text": "Canterbury, CT1 0ZZ" },
11
+ "line4": null
12
+ }
13
+ }
14
+ },
15
+ {
16
+ "id": "SOH1",
17
+ "name": { "text": "Soho" },
18
+ "contactDetails": {
19
+ "addressV2": {
20
+ "line1": { "text": "99 Example Avenue," },
21
+ "line2": { "text": "London," },
22
+ "line3": { "text": "W1D 0ZZ" },
23
+ "line4": null
24
+ }
25
+ }
26
+ },
27
+ {
28
+ "id": "VIC1",
29
+ "name": { "text": "Victoria" },
30
+ "contactDetails": {
31
+ "addressV2": {
32
+ "line1": { "text": "58 Example Street," },
33
+ "line2": { "text": "London," },
34
+ "line3": { "text": "SW1E 0ZZ " },
35
+ "line4": null
36
+ }
37
+ }
38
+ }
39
+ ]
40
+ }
@@ -0,0 +1,84 @@
1
+ require_relative '../../test_helper'
2
+
3
+ class CurzonUk::CinemaTest < Minitest::Test
4
+ def setup
5
+ @api_response = FakeApiResponse.new
6
+ end
7
+
8
+ def test_new_upcases_id
9
+ assert_equal 'SOH1', CurzonUk::Cinema.new('soh1').id
10
+ end
11
+
12
+ def test_all_returns_cinemas
13
+ with_fake_api do
14
+ cinemas = CurzonUk::Cinema.all
15
+
16
+ assert(cinemas.all? { |cinema| cinema.instance_of?(CurzonUk::Cinema) })
17
+ assert_equal %w(CAN2 SOH1 VIC1), cinemas.map(&:id)
18
+ end
19
+ end
20
+
21
+ def test_adr_strips_line_commas
22
+ expected = {
23
+ street_address: '99 Example Avenue',
24
+ extended_address: nil,
25
+ locality: 'London',
26
+ region: nil,
27
+ postal_code: 'W1D 0ZZ',
28
+ country_name: 'United Kingdom'
29
+ }
30
+
31
+ with_fake_api do
32
+ assert_equal expected, CurzonUk::Cinema.new('SOH1').adr
33
+ assert_equal expected, CurzonUk::Cinema.new('SOH1').address
34
+ end
35
+ end
36
+
37
+ def test_adr_with_town_and_postcode_on_one_line
38
+ with_fake_api do
39
+ adr = CurzonUk::Cinema.new('CAN2').adr
40
+
41
+ assert_equal 'Unit 9', adr[:street_address]
42
+ assert_equal 'The Example Riverside', adr[:extended_address]
43
+ assert_equal 'Canterbury', adr[:locality]
44
+ assert_equal 'CT1 0ZZ', adr[:postal_code]
45
+ end
46
+ end
47
+
48
+ def test_adr_trims_postcode
49
+ with_fake_api do
50
+ assert_equal 'SW1E 0ZZ', CurzonUk::Cinema.new('VIC1').postal_code
51
+ end
52
+ end
53
+
54
+ def test_brand_and_full_name
55
+ with_fake_api do
56
+ assert_equal 'Curzon', CurzonUk::Cinema.new('SOH1').brand
57
+ assert_equal 'Curzon Soho', CurzonUk::Cinema.new('SOH1').full_name
58
+ end
59
+ end
60
+
61
+ def test_name
62
+ with_fake_api do
63
+ assert_equal 'Soho', CurzonUk::Cinema.new('SOH1').name
64
+ end
65
+ end
66
+
67
+ def test_slug
68
+ with_fake_api do
69
+ assert_equal 'curzon-soho', CurzonUk::Cinema.new('SOH1').slug
70
+ end
71
+ end
72
+
73
+ def test_url
74
+ with_fake_api do
75
+ assert_equal 'https://www.curzon.com/venues/soho/', CurzonUk::Cinema.new('SOH1').url
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def with_fake_api(&block)
82
+ CurzonUk::Internal::ApiResponse.stub(:new, @api_response, &block)
83
+ end
84
+ end
@@ -0,0 +1,52 @@
1
+ require_relative '../../../test_helper'
2
+
3
+ class CurzonUk::Internal::ApiResponseTest < Minitest::Test
4
+ include ApiFixturesHelper
5
+
6
+ BASE_URL = 'https://digital-api.curzon.com/ocapi/v1'.freeze
7
+
8
+ def setup
9
+ CurzonUk.token = 'guest-jwt'
10
+ end
11
+
12
+ def teardown
13
+ CurzonUk.token = nil
14
+ super
15
+ end
16
+
17
+ def test_sites_sends_token_without_region
18
+ stub_request(:get, "#{BASE_URL}/sites")
19
+ .with { |request| request.headers['Authorization'] == 'Bearer guest-jwt' && !request.headers.key?('Connect-Region-Code') }
20
+ .to_return(status: 200, body: sites_json)
21
+
22
+ assert_equal %w(CAN2 SOH1 VIC1), api.sites.map { |site| site['id'] }
23
+ end
24
+
25
+ def test_showtimes
26
+ stub_request(:get, "#{BASE_URL}/showtimes/by-business-date/2026-10-24")
27
+ .with(query: { 'siteIds' => 'SOH1' })
28
+ .to_return(status: 200, body: showtimes_json('SOH1'))
29
+
30
+ showtimes = api.showtimes('SOH1', Date.new(2026, 10, 24))
31
+
32
+ assert_equal %w(businessDate relatedData showtimes), showtimes.keys.sort
33
+ end
34
+
35
+ def test_missing_token_raises_before_request
36
+ CurzonUk.token = nil
37
+
38
+ assert_raises(CurzonUk::AuthenticationError) { api.sites }
39
+ end
40
+
41
+ def test_rejected_token_raises
42
+ stub_request(:get, "#{BASE_URL}/sites").to_return(status: 401)
43
+
44
+ assert_raises(CurzonUk::AuthenticationError) { api.sites }
45
+ end
46
+
47
+ private
48
+
49
+ def api
50
+ CurzonUk::Internal::ApiResponse.new
51
+ end
52
+ end
@@ -0,0 +1,14 @@
1
+ require_relative '../../../test_helper'
2
+
3
+ class FixtureShapeTest < Minitest::Test
4
+ include ApiFixturesHelper
5
+ include ApiShapes
6
+
7
+ def test_sites_fixture_matches_shape
8
+ assert_sites_shape(JSON.parse(sites_json))
9
+ end
10
+
11
+ def test_showtimes_fixture_matches_shape
12
+ assert_showtimes_shape(JSON.parse(showtimes_json('SOH1')))
13
+ end
14
+ end
@@ -0,0 +1,91 @@
1
+ require_relative '../../../test_helper'
2
+ require 'base64'
3
+ require 'tmpdir'
4
+
5
+ class CurzonUk::Internal::GuestTokenTest < Minitest::Test
6
+ def setup
7
+ @directory = Dir.mktmpdir('curzon-guest-token')
8
+ end
9
+
10
+ def teardown
11
+ FileUtils.remove_entry(@directory)
12
+ CurzonUk.token = nil
13
+ end
14
+
15
+ def test_fetch_returns_token_from_page_data
16
+ token = token_expiring_at(Time.now + 3600)
17
+ curl = fake_curl("#{page_with(token)}\n200")
18
+
19
+ assert_equal token, guest_token(curl).fetch
20
+ end
21
+
22
+ def test_fetch_accepts_the_not_found_page
23
+ token = token_expiring_at(Time.now + 3600)
24
+ curl = fake_curl("#{page_with(token)}\n404")
25
+
26
+ assert_equal token, guest_token(curl).fetch
27
+ end
28
+
29
+ def test_fetch_rejects_expired_token
30
+ curl = fake_curl("#{page_with(token_expiring_at(Time.now - 60))}\n200")
31
+
32
+ assert_raises(CurzonUk::AuthenticationError) { guest_token(curl).fetch }
33
+ end
34
+
35
+ def test_fetch_rejects_page_without_page_data
36
+ curl = fake_curl("<html></html>\n200")
37
+
38
+ assert_raises(CurzonUk::AuthenticationError) { guest_token(curl).fetch }
39
+ end
40
+
41
+ def test_fetch_rejects_redirect_to_queue
42
+ curl = fake_curl("\n302")
43
+
44
+ assert_raises(CurzonUk::AuthenticationError) { guest_token(curl).fetch }
45
+ end
46
+
47
+ def test_fetch_rejects_failed_command
48
+ curl = fake_curl('', exit_status: 7)
49
+
50
+ assert_raises(CurzonUk::AuthenticationError) { guest_token(curl).fetch }
51
+ end
52
+
53
+ def test_fetch_explains_missing_executable
54
+ error = assert_raises(CurzonUk::AuthenticationError) do
55
+ guest_token(File.join(@directory, 'missing')).fetch
56
+ end
57
+
58
+ assert_match(/curl-impersonate/, error.message)
59
+ end
60
+
61
+ def test_acquire_token_sets_token
62
+ token = token_expiring_at(Time.now + 3600)
63
+
64
+ CurzonUk.acquire_token!(curl: fake_curl("#{page_with(token)}\n200"))
65
+
66
+ assert_equal token, CurzonUk.token
67
+ end
68
+
69
+ private
70
+
71
+ def guest_token(curl)
72
+ CurzonUk::Internal::GuestToken.new(curl: curl)
73
+ end
74
+
75
+ def token_expiring_at(time)
76
+ payload = Base64.urlsafe_encode64(JSON.generate('exp' => time.to_i), padding: false)
77
+ "header.#{payload}.signature"
78
+ end
79
+
80
+ def page_with(token)
81
+ data = { 'api' => { 'apiUrl' => 'https://digital-api.curzon.com', 'authToken' => token } }
82
+ "<script>\n window.initialData = #{JSON.generate(data)};\n window.pageData = [];\n</script>"
83
+ end
84
+
85
+ def fake_curl(output, exit_status: 0)
86
+ path = File.join(@directory, 'curl-impersonate')
87
+ File.write(path, "#!#{RbConfig.ruby}\nSTDOUT.write(#{output.dump})\nexit #{exit_status}\n")
88
+ File.chmod(0o755, path)
89
+ path
90
+ end
91
+ end
@@ -0,0 +1,59 @@
1
+ require_relative '../../../../../test_helper'
2
+
3
+ class CurzonUk::Internal::Parser::Api::ShowtimeTest < Minitest::Test
4
+ FILMS = {
5
+ 'HO1' => { 'title' => { 'text' => 'Sense and Sensibility (2026)' } },
6
+ 'HO2' => { 'title' => { 'text' => 'UKJFF - Example Gala' } }
7
+ }.freeze
8
+
9
+ ATTRIBUTES = {
10
+ '1' => { 'shortName' => { 'text' => 'Captioned' }, 'name' => { 'text' => 'Open Captioned' } },
11
+ '2' => { 'shortName' => { 'text' => 'Baby Club' }, 'name' => { 'text' => 'Baby Club' } },
12
+ '3' => { 'shortName' => { 'text' => 'Q&A' }, 'name' => { 'text' => 'Screening followed by a Q&A' } },
13
+ '4' => { 'shortName' => { 'text' => 'Atmos' }, 'name' => { 'text' => 'Atmos' } }
14
+ }.freeze
15
+
16
+ def test_to_hash
17
+ expected = {
18
+ booking_url: 'https://www.curzon.com/ticketing/seats/?showtimeId=SOH1-1',
19
+ dimension: '2d',
20
+ festival: nil,
21
+ film_name: 'Sense and Sensibility',
22
+ film_year: 2026,
23
+ starting_at: Time.utc(2026, 10, 24, 17, 30),
24
+ variant: []
25
+ }
26
+
27
+ assert_equal expected, parse
28
+ end
29
+
30
+ def test_3d_glasses
31
+ assert_equal '3d', parse(requires3d: true)[:dimension]
32
+ end
33
+
34
+ def test_maps_attribute_names_to_variants
35
+ assert_equal %w(subtitled baby q&a), parse(attribute_ids: %w(1 2 3 4))[:variant]
36
+ end
37
+
38
+ def test_festival
39
+ showtime = parse(film_id: 'HO2')
40
+
41
+ assert_equal Cinebase::Festival.new(name: 'UKJFF', year: 2026), showtime[:festival]
42
+ assert_equal 'Example Gala', showtime[:film_name]
43
+ end
44
+
45
+ private
46
+
47
+ def parse(attribute_ids: [], requires3d: false, film_id: 'HO1')
48
+ data = {
49
+ 'id' => 'SOH1-1',
50
+ 'filmId' => film_id,
51
+ 'schedule' => { 'startsAt' => '2026-10-24T18:30:00+01:00' },
52
+ 'attributeIds' => attribute_ids,
53
+ 'requires3dGlasses' => requires3d
54
+ }
55
+ CurzonUk::Internal::Parser::Api::Showtime.new(
56
+ data, films: FILMS, attributes: ATTRIBUTES
57
+ ).to_hash
58
+ end
59
+ end
@@ -0,0 +1,31 @@
1
+ require_relative '../../../test_helper'
2
+
3
+ class CurzonUk::Internal::TitleSanitizerTest < Minitest::Test
4
+ def test_removes_dochouse_strand
5
+ assert_sanitized 'Derek Vs Derek', 'DocHouse: Derek Vs Derek'
6
+ end
7
+
8
+ def test_removes_film_club_strand
9
+ assert_sanitized 'Black Beyond: Welcome II The Terrordome',
10
+ 'Film Club: Black Beyond: Welcome II The Terrordome'
11
+ end
12
+
13
+ def test_removes_film_year
14
+ assert_sanitized 'Street Fighter', 'Street Fighter (2026)'
15
+ end
16
+
17
+ def test_keeps_titles_with_their_own_subtitles
18
+ [
19
+ 'De Gaulle: Part 2- Liberte',
20
+ 'Ghosts: The Possession of Button House',
21
+ 'Hadestown: The Musical',
22
+ 'Fallen Angels by Noël Coward'
23
+ ].each { |title| assert_sanitized title, title }
24
+ end
25
+
26
+ private
27
+
28
+ def assert_sanitized(expected, title)
29
+ assert_equal expected, CurzonUk::Internal::TitleSanitizer.new(title).sanitized
30
+ end
31
+ end
@@ -0,0 +1,106 @@
1
+ require_relative '../../test_helper'
2
+
3
+ class CurzonUk::PerformanceTest < Minitest::Test
4
+ DATE = Date.new(2026, 10, 24)
5
+
6
+ def setup
7
+ @api_response = FakeApiResponse.new
8
+ end
9
+
10
+ def test_at_returns_performances
11
+ performances = performances_at('SOH1')
12
+
13
+ assert(performances.all? { |p| p.instance_of?(CurzonUk::Performance) })
14
+ assert_equal 5, performances.count
15
+ end
16
+
17
+ def test_at_accepts_lower_case_id
18
+ assert_equal 5, performances_at('soh1').count
19
+ end
20
+
21
+ def test_at_sets_cinema_details
22
+ performances = performances_at('SOH1')
23
+
24
+ assert_equal ['SOH1'], performances.map(&:cinema_id).uniq
25
+ assert_equal ['Soho'], performances.map(&:cinema_name).uniq
26
+ end
27
+
28
+ def test_at_parses_first_performance
29
+ performance = performances_at('SOH1').first
30
+
31
+ assert_equal 'The Example Film', performance.film_name
32
+ assert_equal Time.utc(2026, 10, 24, 12, 0), performance.starting_at
33
+ assert_equal '2d', performance.dimension
34
+ assert_equal [], performance.variant
35
+ assert_equal 'https://www.curzon.com/ticketing/seats/?showtimeId=SOH1-1001', performance.booking_url
36
+ end
37
+
38
+ def test_at_parses_3d_open_captions
39
+ performance = performances_at('SOH1')[1]
40
+
41
+ assert_equal '3d', performance.dimension
42
+ assert performance.subtitled?
43
+ end
44
+
45
+ def test_at_strips_strand_and_year
46
+ performance = performances_at('SOH1')[2]
47
+
48
+ assert_equal 'A Made Up Documentary', performance.film_name
49
+ assert_equal 2025, performance.film_year
50
+ assert_equal %w(q&a), performance.variant
51
+ end
52
+
53
+ def test_at_strips_film_club
54
+ performance = performances_at('SOH1')[3]
55
+
56
+ assert_equal 'Pretend Classic', performance.film_name
57
+ assert_equal %w(baby), performance.variant
58
+ end
59
+
60
+ def test_at_moves_festival_from_title
61
+ performance = performances_at('SOH1')[4]
62
+
63
+ assert_equal 'Imaginary Picture', performance.film_name
64
+ assert_equal Cinebase::Festival.new(name: 'UKJFF', year: 2026), performance.festival
65
+ end
66
+
67
+ def test_at_keeps_after_midnight_on_next_utc_day
68
+ assert_equal Time.utc(2026, 10, 24, 23, 15), performances_at('SOH1').last.starting_at
69
+ end
70
+
71
+ def test_at_requests_each_date_in_range
72
+ to = DATE + 2
73
+
74
+ with_fake_api { CurzonUk::Performance.at('SOH1', from: DATE, to: to) }
75
+
76
+ assert_equal (DATE..to).to_a, @api_response.requested_dates
77
+ end
78
+
79
+ def test_at_rejects_range_ending_before_start
80
+ assert_raises(ArgumentError) { CurzonUk::Performance.at('SOH1', from: DATE, to: DATE - 1) }
81
+ end
82
+
83
+ def test_new_sets_defaults
84
+ performance = CurzonUk::Performance.new(
85
+ film_name: 'Carol', cinema_id: 'SOH1', cinema_name: 'Soho',
86
+ starting_at: Time.utc(2013, 9, 12, 11, 0)
87
+ )
88
+
89
+ assert_nil performance.booking_url
90
+ assert_equal '2d', performance.dimension
91
+ assert_nil performance.festival
92
+ assert_nil performance.film_year
93
+ refute performance.subtitled?
94
+ assert_equal [], performance.variant
95
+ end
96
+
97
+ private
98
+
99
+ def performances_at(cinema_id)
100
+ with_fake_api { CurzonUk::Performance.at(cinema_id, from: DATE, to: DATE) }
101
+ end
102
+
103
+ def with_fake_api(&block)
104
+ CurzonUk::Internal::ApiResponse.stub(:new, @api_response, &block)
105
+ end
106
+ end
@@ -0,0 +1,7 @@
1
+ require_relative '../../test_helper'
2
+
3
+ class CurzonUk::VersionTest < Minitest::Test
4
+ def test_version_is_defined
5
+ refute_nil CurzonUk::VERSION
6
+ end
7
+ end
@@ -0,0 +1,19 @@
1
+ require_relative 'live_helper'
2
+
3
+ class LiveApiShapeTest < Minitest::Test
4
+ include ApiShapes
5
+
6
+ def test_sites_are_readable
7
+ assert_sites_shape('sites' => api.sites)
8
+ end
9
+
10
+ def test_showtimes_are_readable
11
+ assert_showtimes_shape(api.showtimes('SOH1', Date.today + 1))
12
+ end
13
+
14
+ private
15
+
16
+ def api
17
+ CurzonUk::Internal::ApiResponse.new
18
+ end
19
+ end
@@ -0,0 +1,27 @@
1
+ require_relative 'live_helper'
2
+
3
+ class LiveCinemaTest < Minitest::Test
4
+ def test_all_returns_cinemas
5
+ cinemas = CurzonUk::Cinema.all
6
+
7
+ assert(cinemas.all? { |cinema| cinema.instance_of?(CurzonUk::Cinema) })
8
+ assert_operator cinemas.size, :>, 10
9
+ assert_equal 'Soho', cinemas.detect { |cinema| cinema.id == 'SOH1' }.name
10
+ end
11
+
12
+ def test_every_cinema_has_a_postcode
13
+ CurzonUk::Cinema.all.each do |cinema|
14
+ refute_empty cinema.postal_code, "#{cinema.id} #{cinema.name}"
15
+ end
16
+ end
17
+ end
18
+
19
+ class LivePerformanceTest < Minitest::Test
20
+ def test_at_returns_performances
21
+ tomorrow = Date.today + 1
22
+ performances = CurzonUk::Performance.at('SOH1', from: tomorrow, to: tomorrow)
23
+
24
+ assert(performances.all? { |p| p.instance_of?(CurzonUk::Performance) })
25
+ assert_operator performances.count, :>, 3
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ require 'minitest/autorun'
2
+ require 'minitest/reporters'
3
+
4
+ Minitest::Reporters.use! [
5
+ Minitest::Reporters::DefaultReporter.new(color: true)
6
+ ]
7
+
8
+ require File.expand_path('../../../lib/curzon_uk.rb', __FILE__)
9
+ require_relative '../support/api_shapes'
10
+
11
+ CurzonUk.token = ENV.fetch('CURZON_TOKEN') { CurzonUk.acquire_token! }
@@ -0,0 +1,56 @@
1
+ # Run separately after building: CURZON_PACKAGE=pkg/...gem bundle exec ruby test/package_test.rb
2
+ require "minitest/autorun"
3
+ require "rubygems/installer"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "open3"
7
+ require "base64"
8
+ require "json"
9
+
10
+ class PlatformPackageTest < Minitest::Test
11
+ def setup
12
+ @directory = Dir.mktmpdir("curzon-package-test")
13
+ @spec = Gem::Installer.at(
14
+ ENV.fetch("CURZON_PACKAGE"), install_dir: @directory, ignore_dependencies: true
15
+ ).install
16
+ @vendor = File.join(@spec.full_gem_path, "vendor", "curl-impersonate")
17
+ @binary = File.join(@vendor, "bin", "curl-impersonate")
18
+ end
19
+
20
+ def teardown
21
+ FileUtils.remove_entry(@directory)
22
+ end
23
+
24
+ def test_installs_runnable_executable_with_notices_and_provenance
25
+ assert File.executable?(@binary)
26
+ output, status = Open3.capture2(@binary, "--version")
27
+ assert status.success?
28
+ assert_includes output, "curl"
29
+ assert File.file?(File.join(@vendor, "licenses", "LICENSE_CURL"))
30
+ assert_includes File.read(File.join(@vendor, "SOURCE")), "2.2.3"
31
+ end
32
+
33
+ def test_acquires_token_with_gem_local_executable_and_no_curl_on_path
34
+ payload = Base64.urlsafe_encode64(JSON.generate("exp" => Time.now.to_i + 3600), padding: false)
35
+ token = "header.#{payload}.signature"
36
+ data = { "api" => { "authToken" => token } }
37
+ page = "<script>\nwindow.initialData = #{JSON.generate(data)};\n</script>\n200"
38
+ # Substitute only the network response, not the installed package's path resolution.
39
+ File.write(@binary, "#!#{RbConfig.ruby}\nSTDOUT.write(#{page.dump})\n")
40
+ output, error, status = Open3.capture3(
41
+ { "PATH" => @directory, "RUBYOPT" => nil, "BUNDLER_SETUP" => nil,
42
+ "GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR) }, RbConfig.ruby,
43
+ "-I#{File.join(@spec.full_gem_path, 'lib')}", *cinebase_load_path, "-rcurzon_uk",
44
+ "-e", "print CurzonUk.acquire_token!"
45
+ )
46
+ assert status.success?, error
47
+ assert_equal token, output
48
+ end
49
+
50
+ private
51
+
52
+ # The package is installed without dependencies, so load the bundle's cinebase.
53
+ def cinebase_load_path
54
+ Gem.loaded_specs.fetch("cinebase").full_require_paths.map { |path| "-I#{path}" }
55
+ end
56
+ end
@@ -0,0 +1,17 @@
1
+ # Read hand-written fixtures from disk. They follow the shape in
2
+ # api_shapes.rb; they are never recorded API output.
3
+ module ApiFixturesHelper
4
+ private
5
+
6
+ def sites_json
7
+ read_fixture('api/sites')
8
+ end
9
+
10
+ def showtimes_json(cinema_id)
11
+ read_fixture("api/showtimes-#{cinema_id}")
12
+ end
13
+
14
+ def read_fixture(filepath)
15
+ File.read(File.expand_path("../../fixtures/#{filepath}.json", __FILE__))
16
+ end
17
+ end
@@ -0,0 +1,48 @@
1
+ require 'time'
2
+
3
+ # The parts of Vista responses the gem reads. Checked against the hand-written
4
+ # fixtures and, in the live suite, against real responses held only in memory.
5
+ module ApiShapes
6
+ private
7
+
8
+ def assert_sites_shape(data)
9
+ sites = data.fetch('sites')
10
+ refute_empty sites
11
+ sites.each do |site|
12
+ assert_match(/\A[A-Z0-9]+\z/, site.fetch('id'))
13
+ assert_kind_of String, site.dig('name', 'text')
14
+ address = site.fetch('contactDetails').fetch('addressV2')
15
+ %w(line1 line2 line3 line4).each do |key|
16
+ line = address.fetch(key)
17
+ assert_kind_of String, line.fetch('text'), "site #{site['id']} #{key}" unless line.nil?
18
+ end
19
+ end
20
+ end
21
+
22
+ def assert_showtimes_shape(data)
23
+ related = data.fetch('relatedData')
24
+ films = related.fetch('films').to_h { |film| [film.fetch('id'), film] }
25
+ attributes = related.fetch('attributes').to_h do |attribute|
26
+ [attribute.fetch('id'), attribute]
27
+ end
28
+
29
+ films.each_value { |film| assert_kind_of String, film.dig('title', 'text') }
30
+ attributes.each_value do |attribute|
31
+ assert_kind_of String, attribute.dig('shortName', 'text')
32
+ assert_kind_of String, attribute.dig('name', 'text')
33
+ end
34
+ related.fetch('sites').each { |site| assert_kind_of String, site.dig('name', 'text') }
35
+
36
+ showtimes = data.fetch('showtimes')
37
+ refute_empty showtimes
38
+ showtimes.each do |showtime|
39
+ assert_kind_of String, showtime.fetch('id')
40
+ assert_includes films.keys, showtime.fetch('filmId')
41
+ assert_kind_of Time, Time.iso8601(showtime.fetch('schedule').fetch('startsAt'))
42
+ assert_includes [true, false], showtime.fetch('requires3dGlasses')
43
+ showtime.fetch('attributeIds').each do |id|
44
+ assert_includes attributes.keys, id
45
+ end
46
+ end
47
+ end
48
+ end