resizing 1.2.1 → 1.3.1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.devcontainer/Dockerfile +73 -0
  3. data/.devcontainer/compose.yaml +58 -0
  4. data/.devcontainer/devcontainer-lock.json +9 -0
  5. data/.devcontainer/devcontainer.json +32 -0
  6. data/.devcontainer/post-create.sh +41 -0
  7. data/.github/copilot-instructions.md +1 -0
  8. data/.github/labeler.yml +28 -0
  9. data/.github/release.yml +35 -0
  10. data/.github/workflows/labeler.yml +20 -0
  11. data/.github/workflows/lint.yml +37 -0
  12. data/.github/workflows/test.yml +16 -9
  13. data/.gitignore +6 -0
  14. data/AGENTS.md +1 -0
  15. data/CHANGELOG.md +3 -0
  16. data/CLAUDE.md +68 -0
  17. data/Gemfile +9 -3
  18. data/README.md +114 -2
  19. data/Rakefile +5 -7
  20. data/bin/setup +3 -0
  21. data/git-hooks/pre-push +6 -0
  22. data/git-hooks/pre-push-ruby.sh +48 -0
  23. data/lib/resizing/carrier_wave/callback_file.rb +88 -0
  24. data/lib/resizing/carrier_wave/storage/file.rb +120 -47
  25. data/lib/resizing/carrier_wave/storage/remote.rb +7 -3
  26. data/lib/resizing/carrier_wave.rb +51 -7
  27. data/lib/resizing/client.rb +0 -1
  28. data/lib/resizing/configurable.rb +1 -1
  29. data/lib/resizing/configuration.rb +40 -10
  30. data/lib/resizing/mock_client.rb +23 -7
  31. data/lib/resizing/public_id.rb +3 -2
  32. data/lib/resizing/version.rb +1 -1
  33. data/lib/resizing.rb +0 -1
  34. data/resizing.gemspec +3 -3
  35. data/test/resizing/carrier_wave/storage/file_test.rb +455 -9
  36. data/test/resizing/carrier_wave/storage/remote_test.rb +91 -9
  37. data/test/resizing/carrier_wave_cache_callbacks_test.rb +224 -0
  38. data/test/resizing/carrier_wave_callback_file_test.rb +77 -0
  39. data/test/resizing/carrier_wave_test.rb +477 -23
  40. data/test/resizing/carrier_wave_uploader_test.rb +211 -0
  41. data/test/resizing/client_test.rb +324 -25
  42. data/test/resizing/configuration_test.rb +235 -10
  43. data/test/resizing/http_clientable_test.rb +27 -0
  44. data/test/resizing/mock_client_test.rb +111 -1
  45. data/test/resizing/public_id_test.rb +96 -0
  46. data/test/resizing_module_test.rb +110 -0
  47. data/test/resizing_test.rb +8 -0
  48. data/test/test_helper.rb +358 -11
  49. data/test/vcr/carrier_wave_test/update_image.yml +63 -0
  50. metadata +35 -10
@@ -4,10 +4,11 @@ require 'test_helper'
4
4
 
5
5
  module Resizing
6
6
  class ConfigurationTest < Minitest::Test
7
+ ACTIVE_SUPPORT_OBJECT_EXTENSIONS = %i[present? blank? try].freeze
8
+
7
9
  def setup
8
10
  @template = {
9
11
  image_host: 'http://192.168.56.101:5000',
10
- video_host: 'http://192.168.56.101:5000',
11
12
  project_id: '098a2a0d-c387-4135-a071-1254d6d7e70a',
12
13
  secret_token: '4g1cshg2lq8j93ufhvqrpjswxmtjz12yhfvq6w79jpwi7cr7nnknoqgwzkwerbs6',
13
14
  open_timeout: 10,
@@ -26,11 +27,13 @@ module Resizing
26
27
  assert_equal(config.image_host, Resizing::Configuration::DEFAULT_IMAGE_HOST)
27
28
  end
28
29
 
29
- def test_that_it_has_default_video_host
30
- template = @template.dup
31
- template.delete(:video_host)
32
- config = Resizing::Configuration.new template
33
- assert_equal(config.video_host, Resizing::Configuration::DEFAULT_VIDEO_HOST)
30
+ # 動画 API の廃止にともない video_host は非推奨。値は保持するが参照時に警告を出す
31
+ def test_that_video_host_reader_warns_and_still_returns_the_default
32
+ config = Resizing::Configuration.new @template.dup
33
+
34
+ _out, err = capture_io { assert_equal('https://video.resizing.net', config.video_host) }
35
+
36
+ assert_includes err, Resizing::Configuration::DEPRECATED_VIDEO_HOST_MESSAGE
34
37
  end
35
38
 
36
39
  def test_that_it_need_raise_exception_if_host_presented
@@ -41,16 +44,77 @@ module Resizing
41
44
  end
42
45
  end
43
46
 
44
- def test_that_it_has_same_image_host_value
47
+ def test_that_it_does_not_raise_exception_if_host_is_nil
45
48
  template = @template.dup
49
+ template[:host] = nil
46
50
  config = Resizing::Configuration.new template
47
51
  assert_equal(config.image_host, template[:image_host])
48
52
  end
49
53
 
50
- def test_that_it_has_same_video_host_value
54
+ def test_that_it_does_not_raise_exception_if_host_is_empty_string
55
+ ['', ' '].each do |host|
56
+ template = @template.dup
57
+ template[:host] = host
58
+ config = Resizing::Configuration.new template
59
+ assert_equal(config.image_host, template[:image_host])
60
+ end
61
+ end
62
+
63
+ # Rails 外(ActiveSupport 未ロード)から利用されるため、
64
+ # Configuration は present? / blank? などの ActiveSupport 拡張に依存してはいけない。
65
+ # see: https://github.com/jksy/resizing-gem/issues/87
66
+ def test_that_it_does_not_depend_on_active_support_object_extensions
67
+ without_active_support_object_extensions do
68
+ template = @template.dup
69
+ config = Resizing::Configuration.new template
70
+ assert_equal(config.image_host, template[:image_host])
71
+
72
+ template_with_host = @template.dup
73
+ template_with_host[:host] = 'https://www.resizing.net'
74
+ assert_raises ConfigurationError do
75
+ Resizing::Configuration.new template_with_host
76
+ end
77
+ end
78
+ end
79
+
80
+ def test_that_it_has_same_image_host_value
51
81
  template = @template.dup
52
82
  config = Resizing::Configuration.new template
53
- assert_equal(config.video_host, template[:video_host])
83
+ assert_equal(config.image_host, template[:image_host])
84
+ end
85
+
86
+ def test_that_passing_video_host_warns_but_keeps_the_value
87
+ template = @template.dup
88
+ template[:video_host] = 'http://192.168.56.101:5000'
89
+
90
+ _out, err = capture_io { @config = Resizing::Configuration.new template }
91
+
92
+ assert_includes err, Resizing::Configuration::DEPRECATED_VIDEO_HOST_MESSAGE
93
+ capture_io { assert_equal(template[:video_host], @config.video_host) }
94
+ end
95
+
96
+ # 非推奨だが == の比較対象からは外さない。比較そのものは警告を出さない
97
+ def test_equality_still_compares_the_deprecated_video_host
98
+ base = other = nil
99
+ capture_io do
100
+ base = Resizing::Configuration.new @template.merge(video_host: 'https://a.example.com')
101
+ other = Resizing::Configuration.new @template.merge(video_host: 'https://b.example.com')
102
+ end
103
+
104
+ _out, err = capture_io { refute_equal base, other }
105
+
106
+ assert_empty err
107
+ end
108
+
109
+ def test_that_the_default_video_host_constant_is_deprecated
110
+ previous = Warning[:deprecated]
111
+ Warning[:deprecated] = true
112
+
113
+ _out, err = capture_io { Resizing::Configuration::DEFAULT_VIDEO_HOST }
114
+
115
+ assert_match(/DEFAULT_VIDEO_HOST is deprecated/, err)
116
+ ensure
117
+ Warning[:deprecated] = previous
54
118
  end
55
119
 
56
120
  def test_that_it_has_no_project_id
@@ -69,7 +133,7 @@ module Resizing
69
133
 
70
134
  def test_that_it_has_no_secret_token
71
135
  template = @template.dup
72
- template.delete(:project_id)
136
+ template.delete(:secret_token)
73
137
  assert_raises ConfigurationError do
74
138
  Resizing::Configuration.new template
75
139
  end
@@ -263,5 +327,166 @@ module Resizing
263
327
 
264
328
  assert_equal true, config.enable_mock
265
329
  end
330
+
331
+ def test_that_it_has_default_response_timeout
332
+ template = @template.dup
333
+ template.delete(:response_timeout)
334
+ config = Resizing::Configuration.new template
335
+ assert_equal(Resizing::Configuration::DEFAULT_RESPONSE_TIMEOUT, config.response_timeout)
336
+ end
337
+
338
+ def test_that_it_has_same_response_timeout
339
+ config = Resizing::Configuration.new @template
340
+ assert_equal(@template[:response_timeout], config.response_timeout)
341
+ end
342
+
343
+ def test_that_it_raises_when_initialized_without_hash
344
+ assert_raises(ConfigurationError) { Resizing::Configuration.new }
345
+ assert_raises(ConfigurationError) { Resizing::Configuration.new(nil) }
346
+ assert_raises(ConfigurationError) { Resizing::Configuration.new('not a hash') }
347
+ end
348
+
349
+ def test_that_it_can_be_initialized_with_only_required_keys
350
+ config = Resizing::Configuration.new(project_id: 'project', secret_token: 'token')
351
+
352
+ assert_equal 'project', config.project_id
353
+ assert_equal 'token', config.secret_token
354
+ assert_equal Resizing::Configuration::DEFAULT_IMAGE_HOST, config.image_host
355
+ assert_equal Resizing::Configuration::DEFAULT_OPEN_TIMEOUT, config.open_timeout
356
+ assert_equal Resizing::Configuration::DEFAULT_RESPONSE_TIMEOUT, config.response_timeout
357
+ assert_equal false, config.enable_mock
358
+ end
359
+
360
+ def test_that_string_attributes_are_frozen_copies_of_input
361
+ template = @template.dup
362
+ template[:image_host] = +'http://mutable.host'
363
+ template[:project_id] = +'mutable-project'
364
+ template[:secret_token] = +'mutable-token'
365
+ config = Resizing::Configuration.new template
366
+
367
+ assert config.image_host.frozen?
368
+ assert config.project_id.frozen?
369
+ assert config.secret_token.frozen?
370
+
371
+ # 入力の Hash の文字列を後から変更しても設定値には影響しない
372
+ template[:image_host] << '/changed'
373
+ template[:project_id] << '-changed'
374
+ template[:secret_token] << '-changed'
375
+ assert_equal 'http://mutable.host', config.image_host
376
+ assert_equal 'mutable-project', config.project_id
377
+ assert_equal 'mutable-token', config.secret_token
378
+ end
379
+
380
+ def test_that_auth_header_is_version_timestamp_and_sha256_of_timestamp_and_secret_token
381
+ # サーバー側 (ResizingTokenAuthenticator) は "v1,<unix time>,<sha256>" 形式を期待している
382
+ now = Time.parse('2024-01-02 03:04:05 UTC')
383
+ Timecop.freeze(now) do
384
+ config = Resizing::Configuration.new @template
385
+ header = config.generate_auth_header
386
+
387
+ assert_match(/\Av\d,\d+,[0-9a-f]{64}\z/, header)
388
+
389
+ version, timestamp, token = header.split(',')
390
+ assert_equal 'v1', version
391
+ assert_equal now.to_i.to_s, timestamp
392
+ assert_equal Digest::SHA2.hexdigest("#{now.to_i}|#{@template[:secret_token]}"), token
393
+ end
394
+ end
395
+
396
+ def test_that_auth_header_changes_with_time
397
+ config = Resizing::Configuration.new @template
398
+ first = Timecop.freeze(Time.parse('2024-01-01 00:00:00 UTC')) { config.generate_auth_header }
399
+ second = Timecop.freeze(Time.parse('2024-01-01 00:00:01 UTC')) { config.generate_auth_header }
400
+
401
+ refute_equal first, second
402
+ end
403
+
404
+ def test_that_it_return_image_url_with_transformations
405
+ config = Resizing::Configuration.new @template
406
+ assert_equal(
407
+ 'http://192.168.56.101:5000/projects/098a2a0d-c387-4135-a071-1254d6d7e70a/upload/images/some-image-id/w_100,h_200',
408
+ config.generate_image_url('some-image-id', nil, [{ w: 100, h: 200 }])
409
+ )
410
+ end
411
+
412
+ def test_that_it_return_image_url_with_version_id_and_transformations
413
+ config = Resizing::Configuration.new @template
414
+ assert_equal(
415
+ 'http://192.168.56.101:5000/projects/098a2a0d-c387-4135-a071-1254d6d7e70a/upload/images/some-image-id/vversion-id/w_40,h_40,c_fill/f_webp',
416
+ config.generate_image_url('some-image-id', 'version-id', [{ c: 'fill', w: 40, h: 40 }, { f: 'webp' }])
417
+ )
418
+ end
419
+
420
+ def test_that_it_return_image_url_accepts_single_hash_transformation
421
+ config = Resizing::Configuration.new @template
422
+ assert_equal(
423
+ 'http://192.168.56.101:5000/projects/098a2a0d-c387-4135-a071-1254d6d7e70a/upload/images/some-image-id/w_100',
424
+ config.generate_image_url('some-image-id', nil, { w: 100 })
425
+ )
426
+ end
427
+
428
+ def test_that_it_return_image_url_without_trailing_slash_for_empty_transformation
429
+ config = Resizing::Configuration.new @template
430
+ expected = 'http://192.168.56.101:5000/projects/098a2a0d-c387-4135-a071-1254d6d7e70a/upload/images/some-image-id'
431
+
432
+ assert_equal expected, config.generate_image_url('some-image-id', nil, [])
433
+ assert_equal expected, config.generate_image_url('some-image-id', nil, [{}])
434
+ assert_equal expected, config.generate_image_url('some-image-id', nil, { unknown: 1 })
435
+ end
436
+
437
+ def test_transformation_path_normalizes_key_order_to_transform_options_order
438
+ # Hash#slice は引数の順で返すため、入力順に関係なく TRANSFORM_OPTIONS の順 (w, h, f, c, q) になる
439
+ config = Resizing::Configuration.new @template
440
+
441
+ assert_equal 'w_100,h_200', config.transformation_path(h: 200, w: 100)
442
+ assert_equal 'w_100,h_200', config.transformation_path(w: 100, h: 200)
443
+ assert_equal 'w_40,h_40,f_webp,c_fill,q_80', config.transformation_path(q: 80, c: 'fill', f: 'webp', h: 40, w: 40)
444
+ end
445
+
446
+ def test_transformation_path_with_quality
447
+ config = Resizing::Configuration.new @template
448
+
449
+ assert_equal 'q_80', config.transformation_path(q: 80)
450
+ assert_equal 'w_100,q_auto', config.transformation_path(w: 100, q: 'auto')
451
+ end
452
+
453
+ def test_equality_returns_false_when_any_compared_attribute_differs
454
+ base = Resizing::Configuration.new @template
455
+ {
456
+ secret_token: 'different-token',
457
+ open_timeout: 99,
458
+ response_timeout: 99
459
+ }.each do |key, value|
460
+ other = Resizing::Configuration.new @template.merge(key => value)
461
+ refute_equal base, other, "#{key} should be compared by =="
462
+ end
463
+ end
464
+
465
+ private
466
+
467
+ def without_active_support_object_extensions
468
+ removed = each_active_support_extension.map do |klass, method_name|
469
+ klass.send(:alias_method, backup_name(method_name), method_name)
470
+ klass.send(:undef_method, method_name)
471
+ [klass, method_name]
472
+ end
473
+
474
+ yield
475
+ ensure
476
+ removed&.each do |klass, method_name|
477
+ klass.send(:alias_method, method_name, backup_name(method_name))
478
+ klass.send(:remove_method, backup_name(method_name))
479
+ end
480
+ end
481
+
482
+ def each_active_support_extension
483
+ [NilClass, String].product(ACTIVE_SUPPORT_OBJECT_EXTENSIONS).select do |klass, method_name|
484
+ klass.method_defined?(method_name)
485
+ end
486
+ end
487
+
488
+ def backup_name(method_name)
489
+ :"__resizing_test_original_#{method_name}"
490
+ end
266
491
  end
267
492
  end
@@ -80,5 +80,32 @@ module Resizing
80
80
  assert_includes e.message, 'TimeoutError'
81
81
  end
82
82
  end
83
+
84
+ def test_http_client_uses_multipart_and_url_encoded_request_middleware
85
+ handlers = @client.http_client.builder.handlers
86
+
87
+ assert_includes handlers, Faraday::Multipart::Middleware
88
+ assert_includes handlers, Faraday::Request::UrlEncoded
89
+ end
90
+
91
+ def test_handle_faraday_error_does_not_rescue_other_faraday_errors
92
+ # TimeoutError 以外の Faraday エラーはそのまま伝播する (現状の仕様)
93
+ assert_raises Faraday::ConnectionFailed do
94
+ @client.handle_faraday_error { raise Faraday::ConnectionFailed, 'connection refused' }
95
+ end
96
+ end
97
+
98
+ def test_handle_faraday_error_does_not_rescue_non_faraday_errors
99
+ assert_raises RuntimeError do
100
+ @client.handle_faraday_error { raise 'unexpected' }
101
+ end
102
+ end
103
+
104
+ def test_handle_timeout_error_message_includes_original_message
105
+ error = Faraday::TimeoutError.new('execution expired')
106
+
107
+ raised = assert_raises(Resizing::APIError) { @client.handle_timeout_error(error) }
108
+ assert_includes raised.message, 'execution expired'
109
+ end
83
110
  end
84
111
  end
@@ -34,9 +34,50 @@ module Resizing
34
34
  assert_includes result['public_id'], name
35
35
  assert result.key?('latest_version_id')
36
36
  assert result.key?('latest_etag')
37
+
38
+ # id と public_id の image_id が一致していること
39
+ public_id = Resizing::PublicId.new(result['public_id'])
40
+ assert_equal name, public_id.image_id
41
+ # カセットに含まれる元の image_id が残っていないこと
42
+ refute_includes result['public_id'], 'AWEaewfAreaweFAFASfwe'
43
+ # version がカセットの固定値から更新されていること
44
+ refute_equal 'fztekhN_WoeXo8ZkCZ4i5jcQvmPpZewR', result['version']
45
+ refute_includes result['public_id'], 'vfztekhN_WoeXo8ZkCZ4i5jcQvmPpZewR'
46
+ # version / latest_version_id / public_id の version が揃っていること
47
+ assert_equal result['version'], public_id.version
48
+ assert_equal result['latest_version_id'], public_id.version
37
49
  end
38
50
  end
39
51
 
52
+ def test_put_generates_new_version_for_each_call
53
+ name = 'test-image-123'
54
+ first = @client.put(name, nil, {})
55
+ second = @client.put(name, nil, {})
56
+
57
+ refute_equal first['version'], second['version']
58
+ refute_equal first['public_id'], second['public_id']
59
+ end
60
+
61
+ def test_metadata_returns_public_id_for_given_name
62
+ name = 'metadata-test-image'
63
+ result = @client.metadata(name)
64
+ public_id = Resizing::PublicId.new(result['public_id'])
65
+
66
+ assert_equal result['id'], public_id.image_id
67
+ assert_equal name, public_id.image_id
68
+ end
69
+
70
+ def test_delete_keeps_version_from_cassette
71
+ name = 'delete-test-image'
72
+ result = @client.delete(name)
73
+ public_id = Resizing::PublicId.new(result['public_id'])
74
+
75
+ assert_equal name, public_id.image_id
76
+ assert_equal result['project_id'], public_id.project_id
77
+ assert_equal result['latest_version_id'], public_id.version
78
+ refute_includes result['public_id'], '28c49144-c00d-4cb5-8619-98ce95977b9c'
79
+ end
80
+
40
81
  def test_delete_returns_parsed_json_with_modified_name
41
82
  VCR.use_cassette('client/delete', record: :none) do
42
83
  name = 'delete-test-image'
@@ -55,8 +96,17 @@ module Resizing
55
96
 
56
97
  assert_instance_of Hash, result
57
98
  assert_equal name, result['id']
58
- # The cassette contains a fixed public_id, so we just check it exists
59
99
  assert result.key?('public_id')
100
+
101
+ # id と public_id の image_id が一致していること
102
+ public_id = Resizing::PublicId.new(result['public_id'])
103
+ assert_equal name, public_id.image_id
104
+ assert_equal result['id'], public_id.image_id
105
+ # カセットに含まれる元の image_id が残っていないこと
106
+ refute_includes result['public_id'], '87263920-2081-498e-a107-9625f4fde01b'
107
+ # project_id と version はカセットの値を引き継ぐこと
108
+ assert_equal result['project_id'], public_id.project_id
109
+ assert_equal result['version'], public_id.version
60
110
  end
61
111
  end
62
112
 
@@ -71,5 +121,65 @@ module Resizing
71
121
  assert result['latest_etag'].is_a?(String)
72
122
  end
73
123
  end
124
+
125
+ def test_mock_client_does_not_perform_http_requests
126
+ # VCR はカセット外の HTTP 接続を禁止しているため、カセットなしで成功すれば通信していないことが分かる
127
+ post_result = @client.post('test/data/images/sample1.jpg', content_type: 'image/jpeg')
128
+ assert_equal '87263920-2081-498e-a107-9625f4fde01b', post_result['id']
129
+
130
+ refute_nil @client.put('img', nil, {})
131
+ refute_nil @client.delete('img')
132
+ refute_nil @client.metadata('img')
133
+ end
134
+
135
+ def test_post_returns_public_id_parsable_by_public_id
136
+ result = @client.post(nil)
137
+ public_id = Resizing::PublicId.new(result['public_id'])
138
+
139
+ assert_equal result['id'], public_id.image_id
140
+ assert_equal result['project_id'], public_id.project_id
141
+ assert_equal result['latest_version_id'], public_id.version
142
+ end
143
+
144
+ def test_put_returns_public_id_for_given_name
145
+ name = 'test-image-123'
146
+ result = @client.put(name, nil, {})
147
+ public_id = Resizing::PublicId.new(result['public_id'])
148
+
149
+ assert_equal name, public_id.image_id
150
+ refute_nil public_id.version
151
+ end
152
+
153
+ def test_delete_returns_public_id_for_given_name
154
+ name = 'delete-test-image'
155
+ result = @client.delete(name)
156
+ public_id = Resizing::PublicId.new(result['public_id'])
157
+
158
+ assert_equal name, public_id.image_id
159
+ end
160
+
161
+ def test_each_call_returns_independent_result
162
+ first = @client.post(nil)
163
+ second = @client.post(nil)
164
+
165
+ refute_same first, second
166
+ first['id'] = 'changed'
167
+ first['public_id'] << '/changed'
168
+ assert_equal '87263920-2081-498e-a107-9625f4fde01b', second['id']
169
+ refute_includes second['public_id'], 'changed'
170
+ end
171
+
172
+ def test_mock_client_is_selected_by_resizing_module_when_enable_mock
173
+ Resizing.configure = {
174
+ image_host: 'https://img.example.com',
175
+ project_id: 'project123',
176
+ secret_token: 'token123',
177
+ enable_mock: true
178
+ }
179
+
180
+ result = Resizing.post('test/data/images/sample1.jpg', content_type: 'image/jpeg')
181
+
182
+ assert_equal '87263920-2081-498e-a107-9625f4fde01b', result['id']
183
+ end
74
184
  end
75
185
  end
@@ -37,5 +37,101 @@ module Resizing
37
37
  public_id = Resizing::PublicId.new @public_id_as_string
38
38
  assert_equal @public_id_as_string, public_id.to_s
39
39
  end
40
+
41
+ def test_empty_is_false_for_valid_public_id
42
+ public_id = Resizing::PublicId.new @public_id_as_string
43
+ refute public_id.empty?
44
+ end
45
+
46
+ def test_filename_returns_image_id
47
+ public_id = Resizing::PublicId.new @public_id_as_string
48
+ assert_equal @image_id, public_id.filename
49
+ end
50
+
51
+ def test_version_is_nil_when_public_id_has_no_version
52
+ public_id = Resizing::PublicId.new "/projects/#{@project_id}/upload/images/#{@image_id}"
53
+
54
+ assert_nil public_id.version
55
+ assert_equal @image_id, public_id.image_id
56
+ assert_equal @project_id, public_id.project_id
57
+ assert_equal "/projects/#{@project_id}/upload/images/#{@image_id}", public_id.identifier
58
+ end
59
+
60
+ def test_version_may_start_with_underscore
61
+ # 実際の API が返すバージョン ID の形式 (test/vcr/carrier_wave_test/save.yml より)
62
+ public_id = Resizing::PublicId.new "/projects/#{@project_id}/upload/images/#{@image_id}/v_8c5lEhDB5RT3PZp1Fn5PYGm9YVx_x0e"
63
+
64
+ assert_equal '_8c5lEhDB5RT3PZp1Fn5PYGm9YVx_x0e', public_id.version
65
+ assert_equal @image_id, public_id.image_id
66
+ end
67
+
68
+ def test_nil_public_id_is_empty_and_has_no_parts
69
+ public_id = Resizing::PublicId.new nil
70
+
71
+ assert public_id.empty?
72
+ assert_nil public_id.image_id
73
+ assert_nil public_id.project_id
74
+ assert_nil public_id.version
75
+ assert_equal '', public_id.to_s
76
+ end
77
+
78
+ def test_empty_string_public_id_is_empty_and_has_no_parts
79
+ public_id = Resizing::PublicId.new ''
80
+
81
+ assert public_id.empty?
82
+ assert_nil public_id.image_id
83
+ assert_nil public_id.project_id
84
+ assert_nil public_id.version
85
+ assert_equal '', public_id.to_s
86
+ end
87
+
88
+ def test_empty_string_public_id_does_not_raise
89
+ # Issue #94: nil は許容されるのに空文字は RuntimeError になっていた
90
+ Resizing::PublicId.new ''
91
+ end
92
+
93
+ def test_empty_string_public_id_behaves_same_as_nil
94
+ from_empty_string = Resizing::PublicId.new ''
95
+ from_nil = Resizing::PublicId.new nil
96
+
97
+ parts = ->(public_id) { [public_id.empty?, public_id.image_id, public_id.project_id, public_id.version, public_id.to_s] }
98
+
99
+ assert_equal parts.call(from_nil), parts.call(from_empty_string)
100
+ end
101
+
102
+ def test_whitespace_only_public_id_is_treated_as_empty
103
+ public_id = Resizing::PublicId.new " \t\n "
104
+
105
+ assert public_id.empty?
106
+ assert_nil public_id.image_id
107
+ assert_nil public_id.project_id
108
+ assert_nil public_id.version
109
+ end
110
+
111
+ def test_invalid_public_id_raises_error
112
+ assert_raises(RuntimeError) { Resizing::PublicId.new 'not/a/public/id' }
113
+ assert_raises(RuntimeError) { Resizing::PublicId.new "/projects/#{@project_id}/upload/videos/#{@image_id}" }
114
+ end
115
+
116
+ def test_project_id_must_consist_of_hex_digits_and_hyphens
117
+ # public_id の project_id 部分は UUID 形式 ([0-9a-f-]+) のみ受け付ける
118
+ assert_raises(RuntimeError) { Resizing::PublicId.new "/projects/project123/upload/images/#{@image_id}" }
119
+ end
120
+
121
+ def test_image_id_accepts_non_uuid_value
122
+ public_id = Resizing::PublicId.new "/projects/#{@project_id}/upload/images/AWEaewfAreaweFAFASfwe/vabc"
123
+
124
+ assert_equal 'AWEaewfAreaweFAFASfwe', public_id.image_id
125
+ assert_equal 'abc', public_id.version
126
+ end
127
+
128
+ def test_parses_public_id_embedded_in_full_url
129
+ # match はアンカーなしのため image_host 付きの URL からも各要素を取り出せる
130
+ public_id = Resizing::PublicId.new "https://img.resizing.net#{@public_id_as_string}"
131
+
132
+ assert_equal @project_id, public_id.project_id
133
+ assert_equal @image_id, public_id.image_id
134
+ assert_equal @version, public_id.version
135
+ end
40
136
  end
41
137
  end
@@ -203,4 +203,114 @@ class ResizingModuleTest < Minitest::Test
203
203
  assert_instance_of Hash, result
204
204
  assert_equal 'image_id', result['id']
205
205
  end
206
+
207
+ def test_post_delegates_to_client
208
+ Resizing.configure = {
209
+ image_host: 'https://img.example.com',
210
+ project_id: 'project123',
211
+ secret_token: 'token123',
212
+ enable_mock: true
213
+ }
214
+
215
+ result = Resizing.post('test/data/images/sample1.jpg', content_type: 'image/jpeg')
216
+
217
+ assert_instance_of Hash, result
218
+ assert_equal '87263920-2081-498e-a107-9625f4fde01b', result['id']
219
+ end
220
+
221
+ def test_client_raises_configuration_error_when_not_configured
222
+ assert_raises(Resizing::ConfigurationError) { Resizing.client }
223
+ end
224
+
225
+ def test_api_methods_raise_configuration_error_when_not_configured
226
+ assert_raises(Resizing::ConfigurationError) { Resizing.post('dummy', content_type: 'image/jpeg') }
227
+ assert_raises(Resizing::ConfigurationError) { Resizing.put('id', 'dummy', content_type: 'image/jpeg') }
228
+ assert_raises(Resizing::ConfigurationError) { Resizing.delete('id') }
229
+ assert_raises(Resizing::ConfigurationError) { Resizing.metadata('id', {}) }
230
+ assert_raises(Resizing::ConfigurationError) { Resizing.url_from_image_id('id') }
231
+ assert_raises(Resizing::ConfigurationError) { Resizing.generate_identifier }
232
+ end
233
+
234
+ def test_configure_setter_raises_for_invalid_hash
235
+ assert_raises(Resizing::ConfigurationError) { Resizing.configure = { project_id: 'only-project' } }
236
+ assert_raises(Resizing::ConfigurationError) { Resizing.configure = { secret_token: 'only-token' } }
237
+ assert_raises(Resizing::ConfigurationError) do
238
+ Resizing.configure = { project_id: 'p', secret_token: 's', host: 'deprecated-host' }
239
+ end
240
+ end
241
+
242
+ def test_configure_setter_replaces_previous_configuration
243
+ Resizing.configure = { project_id: 'first', secret_token: 'token' }
244
+ Resizing.configure = { project_id: 'second', secret_token: 'token' }
245
+
246
+ assert_equal 'second', Resizing.configure.project_id
247
+ end
248
+
249
+ def test_client_reflects_latest_configuration
250
+ Resizing.configure = { project_id: 'p', secret_token: 's', enable_mock: false }
251
+ assert_instance_of Resizing::Client, Resizing.client
252
+
253
+ Resizing.configure = { project_id: 'p', secret_token: 's', enable_mock: true }
254
+ assert_instance_of Resizing::MockClient, Resizing.client
255
+ end
256
+
257
+ def test_separate_public_id_extracts_project_id_image_id_and_version
258
+ matched = Resizing.separate_public_id(
259
+ '/projects/098a2a0d-c387-4135-a071-1254d6d7e70a/upload/images/28c49144-c00d-4cb5-8619-98ce95977b9c/v1Id850q34fgsaer23w'
260
+ )
261
+
262
+ assert_equal '098a2a0d-c387-4135-a071-1254d6d7e70a', matched[:project_id]
263
+ assert_equal '28c49144-c00d-4cb5-8619-98ce95977b9c', matched[:image_id]
264
+ assert_equal '1Id850q34fgsaer23w', matched[:version]
265
+ end
266
+
267
+ def test_separate_public_id_without_version
268
+ matched = Resizing.separate_public_id(
269
+ '/projects/098a2a0d-c387-4135-a071-1254d6d7e70a/upload/images/28c49144-c00d-4cb5-8619-98ce95977b9c'
270
+ )
271
+
272
+ assert_equal '28c49144-c00d-4cb5-8619-98ce95977b9c', matched[:image_id]
273
+ assert_nil matched[:version]
274
+ end
275
+
276
+ def test_separate_public_id_returns_nil_for_unrelated_string
277
+ assert_nil Resizing.separate_public_id('/foo/bar')
278
+ assert_nil Resizing.separate_public_id('')
279
+ end
280
+
281
+ def test_generate_identifier_returns_new_value_each_time
282
+ Resizing.configure = { project_id: '098a2a0d-c387-4135-a071-1254d6d7e70a', secret_token: 'token' }
283
+
284
+ first = Resizing.generate_identifier
285
+ second = Resizing.generate_identifier
286
+
287
+ refute_equal first, second
288
+ # 生成した identifier は PublicId として解釈できる
289
+ assert_equal '098a2a0d-c387-4135-a071-1254d6d7e70a', Resizing::PublicId.new(first).project_id
290
+ end
291
+
292
+ def test_url_from_image_id_uses_default_image_host_when_not_specified
293
+ Resizing.configure = { project_id: 'project123', secret_token: 'token123' }
294
+
295
+ assert_equal 'https://img.resizing.net/projects/project123/upload/images/image456', Resizing.url_from_image_id('image456')
296
+ end
297
+
298
+ # Issue #95: 実体のないファイルを指す autoload が残っていると
299
+ # その定数を参照した瞬間に LoadError になるため、
300
+ # 全 autoload 先が実際にロードできることを確認する
301
+ def test_all_autoload_targets_are_loadable
302
+ source = File.read(File.expand_path('../lib/resizing.rb', __dir__))
303
+ const_names = source.scan(/autoload\s+:(\w+),/).flatten
304
+
305
+ refute_empty const_names
306
+
307
+ const_names.each do |const_name|
308
+ assert Resizing.const_get(const_name), "Resizing::#{const_name} should be loadable"
309
+ end
310
+ end
311
+
312
+ def test_video_constant_is_not_registered
313
+ refute_includes Resizing.constants, :Video
314
+ assert_raises(NameError) { Resizing.const_get(:Video) }
315
+ end
206
316
  end
@@ -6,4 +6,12 @@ class ResizingTest < Minitest::Test
6
6
  def test_that_it_has_a_version_number
7
7
  refute_nil ::Resizing::VERSION
8
8
  end
9
+
10
+ def test_version_follows_semantic_versioning
11
+ assert_match(/\A\d+\.\d+\.\d+(\.pre)?\z/, ::Resizing::VERSION)
12
+ end
13
+
14
+ def test_version_is_frozen_string
15
+ assert ::Resizing::VERSION.frozen?
16
+ end
9
17
  end