deepl-rb 3.9.0 → 3.10.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 (237) hide show
  1. checksums.yaml +4 -4
  2. data/.gitlab-ci.yml +70 -52
  3. data/.rubocop.yml +26 -0
  4. data/CHANGELOG.md +429 -18
  5. data/Gemfile +4 -0
  6. data/README.md +981 -223
  7. data/Rakefile +18 -5
  8. data/VERSION +1 -1
  9. data/deepl-rb.gemspec +6 -6
  10. data/lib/deepl/admin_client.rb +183 -0
  11. data/lib/deepl/api.rb +30 -6
  12. data/lib/deepl/app_info.rb +20 -0
  13. data/lib/deepl/client.rb +969 -0
  14. data/lib/deepl/configuration.rb +56 -10
  15. data/lib/deepl/constants/admin_usage_group_by.rb +13 -0
  16. data/lib/deepl/constants/custom_tag_usage_aggregate_by.rb +13 -0
  17. data/lib/deepl/constants/document_status_code.rb +17 -0
  18. data/lib/deepl/constants/language_include.rb +17 -0
  19. data/lib/deepl/constants/language_resource_name.rb +21 -0
  20. data/lib/deepl/constants/tag_handling_version.rb +14 -0
  21. data/lib/deepl/deprecation.rb +42 -0
  22. data/lib/deepl/document_api.rb +41 -22
  23. data/lib/deepl/exceptions/bad_request.rb +1 -3
  24. data/lib/deepl/exceptions/connection_error.rb +37 -0
  25. data/lib/deepl/exceptions/document_not_ready_error.rb +17 -0
  26. data/lib/deepl/exceptions/document_translation_error.rb +17 -1
  27. data/lib/deepl/exceptions/error.rb +13 -1
  28. data/lib/deepl/exceptions/not_found.rb +8 -3
  29. data/lib/deepl/exceptions/not_supported.rb +6 -0
  30. data/lib/deepl/exceptions/request_error.rb +19 -2
  31. data/lib/deepl/exceptions/server_error.rb +7 -1
  32. data/lib/deepl/exceptions/validation_error.rb +14 -0
  33. data/lib/deepl/requests/admin/base.rb +94 -0
  34. data/lib/deepl/requests/admin/create_developer_key.rb +38 -0
  35. data/lib/deepl/requests/admin/custom_tag_usage_analytics.rb +38 -0
  36. data/lib/deepl/requests/admin/list_developer_keys.rb +28 -0
  37. data/lib/deepl/requests/admin/update_developer_key.rb +35 -0
  38. data/lib/deepl/requests/admin/usage_analytics.rb +36 -0
  39. data/lib/deepl/requests/base.rb +229 -59
  40. data/lib/deepl/requests/correct.rb +57 -0
  41. data/lib/deepl/requests/document/download.rb +11 -3
  42. data/lib/deepl/requests/document/get_status.rb +2 -2
  43. data/lib/deepl/requests/document/upload.rb +43 -10
  44. data/lib/deepl/requests/glossary/create.rb +6 -2
  45. data/lib/deepl/requests/glossary/language_pairs.rb +20 -4
  46. data/lib/deepl/requests/language_resources.rb +41 -0
  47. data/lib/deepl/requests/languages.rb +28 -4
  48. data/lib/deepl/requests/languages_for_resource.rb +54 -0
  49. data/lib/deepl/requests/multilingual_glossary/create.rb +37 -0
  50. data/lib/deepl/requests/multilingual_glossary/destroy.rb +36 -0
  51. data/lib/deepl/requests/multilingual_glossary/destroy_dictionary.rb +51 -0
  52. data/lib/deepl/requests/multilingual_glossary/entries.rb +66 -0
  53. data/lib/deepl/requests/multilingual_glossary/find.rb +35 -0
  54. data/lib/deepl/requests/multilingual_glossary/helpers.rb +63 -0
  55. data/lib/deepl/requests/multilingual_glossary/list.rb +39 -0
  56. data/lib/deepl/requests/multilingual_glossary/replace_dictionary.rb +41 -0
  57. data/lib/deepl/requests/multilingual_glossary/update.rb +40 -0
  58. data/lib/deepl/requests/rephrase.rb +5 -9
  59. data/lib/deepl/requests/spoken_terms/create.rb +37 -0
  60. data/lib/deepl/requests/spoken_terms/destroy.rb +36 -0
  61. data/lib/deepl/requests/spoken_terms/destroy_term_list.rb +41 -0
  62. data/lib/deepl/requests/spoken_terms/entries.rb +46 -0
  63. data/lib/deepl/requests/spoken_terms/find.rb +35 -0
  64. data/lib/deepl/requests/spoken_terms/helpers.rb +67 -0
  65. data/lib/deepl/requests/spoken_terms/list.rb +35 -0
  66. data/lib/deepl/requests/spoken_terms/replace_term_list.rb +41 -0
  67. data/lib/deepl/requests/spoken_terms/update.rb +40 -0
  68. data/lib/deepl/requests/style_profile/base.rb +83 -0
  69. data/lib/deepl/requests/style_profile/create.rb +35 -0
  70. data/lib/deepl/requests/style_profile/destroy.rb +34 -0
  71. data/lib/deepl/requests/style_profile/find.rb +33 -0
  72. data/lib/deepl/requests/style_profile/list.rb +43 -0
  73. data/lib/deepl/requests/style_profile/update.rb +35 -0
  74. data/lib/deepl/requests/style_rule/create.rb +1 -1
  75. data/lib/deepl/requests/style_rule/create_custom_instruction.rb +4 -2
  76. data/lib/deepl/requests/style_rule/destroy.rb +4 -2
  77. data/lib/deepl/requests/style_rule/destroy_custom_instruction.rb +5 -3
  78. data/lib/deepl/requests/style_rule/find.rb +4 -2
  79. data/lib/deepl/requests/style_rule/find_custom_instruction.rb +5 -3
  80. data/lib/deepl/requests/style_rule/helpers.rb +35 -0
  81. data/lib/deepl/requests/style_rule/list.rb +1 -1
  82. data/lib/deepl/requests/style_rule/update.rb +4 -2
  83. data/lib/deepl/requests/style_rule/update_configured_rules.rb +4 -2
  84. data/lib/deepl/requests/style_rule/update_custom_instruction.rb +5 -3
  85. data/lib/deepl/requests/translate.rb +52 -20
  86. data/lib/deepl/requests/translation_memory/base.rb +1 -4
  87. data/lib/deepl/requests/translation_memory/create_export.rb +1 -1
  88. data/lib/deepl/requests/translation_memory/create_import.rb +1 -1
  89. data/lib/deepl/requests/translation_memory/destroy.rb +1 -1
  90. data/lib/deepl/requests/translation_memory/download_export.rb +4 -0
  91. data/lib/deepl/requests/translation_memory/find.rb +1 -1
  92. data/lib/deepl/requests/translation_memory/find_job.rb +1 -1
  93. data/lib/deepl/requests/translation_memory/list.rb +2 -1
  94. data/lib/deepl/requests/translation_memory/segments.rb +3 -2
  95. data/lib/deepl/requests/translation_memory/storage_base.rb +40 -8
  96. data/lib/deepl/requests/translation_memory/upload_file.rb +1 -1
  97. data/lib/deepl/requests/usage.rb +28 -1
  98. data/lib/deepl/requests/write_improvements.rb +35 -0
  99. data/lib/deepl/resources/admin_usage_report.rb +73 -0
  100. data/lib/deepl/resources/custom_tag_usage_report.rb +59 -0
  101. data/lib/deepl/resources/developer_key.rb +57 -0
  102. data/lib/deepl/resources/document_handle.rb +24 -25
  103. data/lib/deepl/resources/document_translation_status.rb +23 -4
  104. data/lib/deepl/resources/language.rb +10 -2
  105. data/lib/deepl/resources/language_resource.rb +57 -0
  106. data/lib/deepl/resources/language_support.rb +56 -0
  107. data/lib/deepl/resources/multilingual_glossary.rb +112 -0
  108. data/lib/deepl/resources/spoken_terms.rb +61 -0
  109. data/lib/deepl/resources/style_profile.rb +44 -0
  110. data/lib/deepl/resources/style_rule.rb +3 -0
  111. data/lib/deepl/resources/text.rb +18 -2
  112. data/lib/deepl/resources/translation_memory.rb +8 -2
  113. data/lib/deepl/resources/translation_memory_import.rb +1 -1
  114. data/lib/deepl/resources/translation_memory_job.rb +23 -3
  115. data/lib/deepl/resources/translation_memory_segments.rb +8 -6
  116. data/lib/deepl/resources/usage.rb +93 -4
  117. data/lib/deepl/style_rule_api.rb +11 -0
  118. data/lib/deepl/translation_memory_api.rb +104 -24
  119. data/lib/deepl/utils/client_builder.rb +54 -0
  120. data/lib/deepl/utils/document_poller.rb +70 -0
  121. data/lib/deepl/utils/exception_builder.rb +3 -2
  122. data/lib/deepl/utils/http_client_builder.rb +79 -0
  123. data/lib/deepl/utils/number_parser.rb +34 -0
  124. data/lib/deepl/utils/term_validator.rb +33 -0
  125. data/lib/deepl/utils/time_parser.rb +14 -0
  126. data/lib/deepl.rb +166 -41
  127. data/lib/http_client_options.rb +21 -1
  128. data/lib/version.rb +1 -1
  129. data/spec/fixtures.rb +33 -0
  130. data/spec/offline/admin_client_spec.rb +551 -0
  131. data/spec/offline/client_spec.rb +273 -0
  132. data/spec/{api → offline}/configuration_spec.rb +45 -3
  133. data/spec/{constants → offline}/constants_spec.rb +23 -1
  134. data/spec/offline/correct_text_spec.rb +50 -0
  135. data/spec/offline/deprecation_spec.rb +32 -0
  136. data/spec/offline/errors_spec.rb +92 -0
  137. data/spec/offline/glossaries_spec.rb +98 -0
  138. data/spec/offline/http_client_spec.rb +319 -0
  139. data/spec/offline/languages_spec.rb +155 -0
  140. data/spec/offline/module_methods_spec.rb +13 -0
  141. data/spec/offline/multilingual_glossaries_spec.rb +358 -0
  142. data/spec/offline/offline_helper.rb +71 -0
  143. data/spec/offline/rephrase_text_spec.rb +73 -0
  144. data/spec/offline/spoken_terms_spec.rb +215 -0
  145. data/spec/offline/style_profiles_spec.rb +266 -0
  146. data/spec/offline/style_rules_spec.rb +318 -0
  147. data/spec/offline/translate_document_spec.rb +606 -0
  148. data/spec/offline/translate_text_spec.rb +286 -0
  149. data/spec/offline/translation_memories_client_spec.rb +256 -0
  150. data/spec/offline/translation_memories_spec.rb +463 -0
  151. data/spec/offline/usage_spec.rb +151 -0
  152. data/spec/real_api/admin_client_spec.rb +77 -0
  153. data/spec/real_api/correct_text_spec.rb +20 -0
  154. data/spec/real_api/glossaries_spec.rb +28 -0
  155. data/spec/real_api/languages_spec.rb +27 -0
  156. data/spec/real_api/multilingual_glossaries_spec.rb +53 -0
  157. data/spec/real_api/proxy_spec.rb +41 -0
  158. data/spec/real_api/real_api_helper.rb +65 -0
  159. data/spec/real_api/rephrase_text_spec.rb +16 -0
  160. data/spec/real_api/spoken_terms_spec.rb +56 -0
  161. data/spec/real_api/style_profiles_spec.rb +46 -0
  162. data/spec/real_api/style_rules_spec.rb +46 -0
  163. data/spec/real_api/translate_document_spec.rb +18 -0
  164. data/spec/real_api/translate_text_spec.rb +16 -0
  165. data/spec/real_api/translation_memories_spec.rb +56 -0
  166. data/spec/real_api/usage_spec.rb +20 -0
  167. data/spec/spec_helper.rb +1 -50
  168. metadata +116 -76
  169. data/.circleci/config.yml +0 -27
  170. data/.github/workflows/add_issues_to_kanban.yml +0 -16
  171. data/spec/api/api_spec.rb +0 -20
  172. data/spec/api/deepl_spec.rb +0 -262
  173. data/spec/integration_tests/document_api_spec.rb +0 -211
  174. data/spec/integration_tests/document_error_paths_spec.rb +0 -33
  175. data/spec/integration_tests/glossary_api_spec.rb +0 -114
  176. data/spec/integration_tests/glossary_error_paths_spec.rb +0 -107
  177. data/spec/integration_tests/integration_test_utils.rb +0 -178
  178. data/spec/integration_tests/languages_api_spec.rb +0 -54
  179. data/spec/integration_tests/languages_error_paths_spec.rb +0 -25
  180. data/spec/integration_tests/rephrase_api_spec.rb +0 -90
  181. data/spec/integration_tests/rephrase_error_paths_spec.rb +0 -53
  182. data/spec/integration_tests/smoke_test_spec.rb +0 -24
  183. data/spec/integration_tests/style_rule_api_spec.rb +0 -117
  184. data/spec/integration_tests/style_rule_error_paths_spec.rb +0 -45
  185. data/spec/integration_tests/translate_api_spec.rb +0 -117
  186. data/spec/integration_tests/translate_error_paths_spec.rb +0 -48
  187. data/spec/integration_tests/translation_memory_api_spec.rb +0 -244
  188. data/spec/integration_tests/translation_memory_error_paths_spec.rb +0 -87
  189. data/spec/integration_tests/usage_api_spec.rb +0 -29
  190. data/spec/integration_tests/usage_error_paths_spec.rb +0 -18
  191. data/spec/requests/document/upload_spec.rb +0 -130
  192. data/spec/requests/extra_body_parameters_types_spec.rb +0 -82
  193. data/spec/requests/glossary/create_spec.rb +0 -44
  194. data/spec/requests/glossary/destroy_spec.rb +0 -27
  195. data/spec/requests/glossary/entries_spec.rb +0 -27
  196. data/spec/requests/glossary/find_spec.rb +0 -28
  197. data/spec/requests/glossary/language_pairs_spec.rb +0 -27
  198. data/spec/requests/glossary/list_spec.rb +0 -27
  199. data/spec/requests/languages_spec.rb +0 -27
  200. data/spec/requests/rephrase_spec.rb +0 -46
  201. data/spec/requests/style_rule/create_custom_instruction_spec.rb +0 -30
  202. data/spec/requests/style_rule/create_spec.rb +0 -29
  203. data/spec/requests/style_rule/destroy_custom_instruction_spec.rb +0 -28
  204. data/spec/requests/style_rule/destroy_spec.rb +0 -27
  205. data/spec/requests/style_rule/find_custom_instruction_spec.rb +0 -29
  206. data/spec/requests/style_rule/find_spec.rb +0 -28
  207. data/spec/requests/style_rule/list_spec.rb +0 -27
  208. data/spec/requests/style_rule/update_configured_rules_spec.rb +0 -31
  209. data/spec/requests/style_rule/update_custom_instruction_spec.rb +0 -32
  210. data/spec/requests/style_rule/update_spec.rb +0 -29
  211. data/spec/requests/translate_spec.rb +0 -338
  212. data/spec/requests/translation_memory/create_export_spec.rb +0 -37
  213. data/spec/requests/translation_memory/create_import_spec.rb +0 -61
  214. data/spec/requests/translation_memory/destroy_spec.rb +0 -36
  215. data/spec/requests/translation_memory/download_export_spec.rb +0 -54
  216. data/spec/requests/translation_memory/find_job_spec.rb +0 -37
  217. data/spec/requests/translation_memory/find_spec.rb +0 -36
  218. data/spec/requests/translation_memory/list_spec.rb +0 -27
  219. data/spec/requests/translation_memory/segments_spec.rb +0 -58
  220. data/spec/requests/translation_memory/upload_file_spec.rb +0 -55
  221. data/spec/requests/usage_spec.rb +0 -27
  222. data/spec/resources/custom_instruction_spec.rb +0 -32
  223. data/spec/resources/glossary_spec.rb +0 -38
  224. data/spec/resources/language_pair_spec.rb +0 -23
  225. data/spec/resources/language_spec.rb +0 -45
  226. data/spec/resources/style_rule_spec.rb +0 -68
  227. data/spec/resources/text_spec.rb +0 -23
  228. data/spec/resources/translation_memory_export_spec.rb +0 -38
  229. data/spec/resources/translation_memory_import_spec.rb +0 -30
  230. data/spec/resources/translation_memory_job_spec.rb +0 -109
  231. data/spec/resources/translation_memory_segments_spec.rb +0 -78
  232. data/spec/resources/translation_memory_spec.rb +0 -52
  233. data/spec/resources/usage_spec.rb +0 -35
  234. data/spec/support/live_mock_server.rb +0 -12
  235. data/spec/support/managed_glossary.rb +0 -65
  236. data/spec/support/managed_style_rule.rb +0 -17
  237. data/spec/support/managed_translation_memory.rb +0 -55
@@ -0,0 +1,551 @@
1
+ # Copyright 2026 DeepL SE (https://www.deepl.com)
2
+ # Use of this source code is governed by an MIT
3
+ # license that can be found in the LICENSE.md file.
4
+ # frozen_string_literal: true
5
+
6
+ require_relative 'offline_helper'
7
+
8
+ describe DeepL::AdminClient do
9
+ subject(:client) { described_class.new(admin_key, max_retries: 2) }
10
+
11
+ let(:admin_key) { 'admin-key' }
12
+ let(:admin_headers) do
13
+ { 'Authorization' => "DeepL-Auth-Key #{admin_key}", 'Content-Type' => 'application/json' }
14
+ end
15
+ let(:key_id) { 'ca7d5694-96eb-4263-a9a4-7f7e4211529e:20c2abcf-4c3c-4cd6-8ae8-8bd2a7d4da38' }
16
+ let(:developer_key_json) do
17
+ { key_id: key_id, label: 'developer key prod', creation_time: '2025-07-08T08:15:29.362Z',
18
+ deactivated_time: nil, is_deactivated: false,
19
+ usage_limits: { characters: 5000, speech_to_text_milliseconds: nil,
20
+ speech_to_speech_milliseconds: 3_600_000 } }
21
+ end
22
+ let(:key) { DeepL::Resources::DeveloperKey.new(developer_key_json.transform_keys(&:to_s), nil, nil) }
23
+
24
+ def stub_put(action, body)
25
+ stub_request(:put, url("/v2/admin/developer-keys/#{action}"))
26
+ .with(body: body, headers: admin_headers).to_return(reply(developer_key_json))
27
+ end
28
+
29
+ describe '#initialize' do
30
+ it 'rejects an empty admin key' do
31
+ ['', ' ', nil].each do |admin_key|
32
+ expect { described_class.new(admin_key) }.to raise_error(ArgumentError, /must not be empty/)
33
+ end
34
+ end
35
+
36
+ it 'uses the admin key and the Pro server regardless of the environment' do # rubocop:disable RSpec/ExampleLength
37
+ env = ENV.to_h.slice('DEEPL_AUTH_KEY', 'DEEPL_SERVER_URL')
38
+ ENV['DEEPL_AUTH_KEY'] = 'environment-key'
39
+ ENV['DEEPL_SERVER_URL'] = 'http://environment.example.test'
40
+ begin
41
+ stub_request(:get, url('/v2/admin/developer-keys')).with(headers: admin_headers)
42
+ .to_return(reply([]))
43
+ stub_request(:get, 'http://localhost:3000/v2/admin/developer-keys').to_return(reply([]))
44
+
45
+ client.list_developer_keys
46
+ described_class.new(admin_key, server_url: 'http://localhost:3000').list_developer_keys
47
+ ensure
48
+ %w[DEEPL_AUTH_KEY DEEPL_SERVER_URL].each { |name| ENV.delete(name) }
49
+ ENV.update(env)
50
+ end
51
+ end
52
+
53
+ it 'trims the admin key' do
54
+ stub_request(:get, url('/v2/admin/developer-keys')).with(headers: admin_headers)
55
+ .to_return(reply([]))
56
+
57
+ described_class.new(" #{admin_key}\n").list_developer_keys
58
+ end
59
+
60
+ it 'sends the app info and the configured headers' do
61
+ stub_request(:get, url('/v2/admin/developer-keys'))
62
+ .with(headers: { 'X-Custom' => 'v',
63
+ 'User-Agent' => %r{\Adeepl-ruby/[^ ]+ my-app/1\.2\z} })
64
+ .to_return(reply([]))
65
+
66
+ described_class.new(admin_key, send_platform_info: false, headers: { 'X-Custom' => 'v' },
67
+ app_info: DeepL::AppInfo.new(app_name: 'my-app',
68
+ app_version: '1.2'))
69
+ .list_developer_keys
70
+ end
71
+
72
+ it 'applies the HTTP client options' do
73
+ options = DeepL::HTTPClientOptions.new({}, nil, read_timeout: 7)
74
+ admin_client = described_class.new(admin_key, http_client_options: options)
75
+ expect(admin_client.send(:api).http_client.read_timeout).to eq(7)
76
+ end
77
+
78
+ it 'enables SSL from the URL scheme and verifies certificates unless disabled' do
79
+ build = lambda do |host, verify|
80
+ options = DeepL::HTTPClientOptions.new(nil, nil, enable_ssl_verification: verify)
81
+ described_class.new(admin_key, server_url: host, http_client_options: options)
82
+ .send(:api).http_client
83
+ end
84
+
85
+ expect(build.call('https://api.deepl.com', true)).to have_attributes(use_ssl?: true,
86
+ verify_mode: nil)
87
+ expect(build.call('https://api.deepl.com', false))
88
+ .to have_attributes(use_ssl?: true, verify_mode: OpenSSL::SSL::VERIFY_NONE)
89
+ expect(build.call('http://localhost:3000', false)).to have_attributes(use_ssl?: false)
90
+ end
91
+ end
92
+
93
+ describe '#get_usage_analytics' do
94
+ let(:report_json) do
95
+ { usage_report: {
96
+ total_usage: { total_characters: 9619, text_translation_characters: 4892,
97
+ document_translation_characters: 0, text_improvement_characters: 4727,
98
+ speech_to_text_minutes: 107.46 },
99
+ group_by: 'key_and_day', start_date: '2025-09-29T00:00:00',
100
+ end_date: '2025-10-01T00:00:00',
101
+ key_and_day_usages: [
102
+ { api_key: 'dc88****3a2c', api_key_label: 'Staging API Key',
103
+ usage_date: '2025-09-29T00:00:00Z',
104
+ usage: { total_characters: 315, text_improvement_characters: 156,
105
+ speech_to_text_minutes: 11.94 } }
106
+ ]
107
+ } }
108
+ end
109
+
110
+ it 'sends the date range and grouping' do
111
+ stub_request(:get, url('/v2/admin/analytics'))
112
+ .with(query: { start_date: '2025-09-29', end_date: '2025-10-01', group_by: 'key_and_day' },
113
+ headers: admin_headers)
114
+ .to_return(reply(report_json))
115
+
116
+ client.get_usage_analytics(Date.new(2025, 9, 29), '2025-10-01',
117
+ group_by: DeepL::Constants::AdminUsageGroupBy::KEY_AND_DAY)
118
+ end
119
+
120
+ it 'parses the report, reading timestamps without a zone as UTC' do
121
+ stub_request(:get, url('/v2/admin/analytics')).with(query: hash_including({}))
122
+ .to_return(reply(report_json))
123
+ report = client.get_usage_analytics('2025-09-29', '2025-10-01')
124
+
125
+ expect(report.total_usage.total_characters).to eq(9619)
126
+ expect([report.start_date, report.key_usages]).to eq([Time.utc(2025, 9, 29), []])
127
+ expect(report.key_and_day_usages.first).to have_attributes(
128
+ api_key_label: 'Staging API Key', usage_date: Time.utc(2025, 9, 29)
129
+ )
130
+ end
131
+
132
+ it 'sends the UTC date of a Time or DateTime, without changing it' do
133
+ stub_request(:get, url('/v2/admin/analytics'))
134
+ .with(query: { start_date: '2025-09-28', end_date: '2025-10-02' })
135
+ .to_return(reply(report_json))
136
+ start_time = Time.new(2025, 9, 29, 1, 0, 0, '+02:00')
137
+ end_time = DateTime.new(2025, 10, 1, 23, 0, 0, '-02:00')
138
+
139
+ client.get_usage_analytics(start_time, end_time.freeze)
140
+
141
+ expect(start_time).to eq(Time.new(2025, 9, 29, 1, 0, 0, '+02:00'))
142
+ expect(start_time.utc_offset).to eq(7200)
143
+ expect(end_time.offset).to eq(Rational(-1, 12))
144
+ end
145
+
146
+ it 'sends local midnight in UTC+2 as the previous day' do
147
+ stub_request(:get, url('/v2/admin/analytics'))
148
+ .with(query: { start_date: '2026-10-19', end_date: '2026-10-20' })
149
+ .to_return(reply(report_json))
150
+
151
+ client.get_usage_analytics(Time.new(2026, 10, 20, 0, 0, 0, '+02:00'),
152
+ DateTime.new(2026, 10, 21, 0, 30, 0, '+02:00'))
153
+ end
154
+
155
+ it 'sends a Date as is' do
156
+ stub_request(:get, url('/v2/admin/analytics'))
157
+ .with(query: { start_date: '2026-10-20', end_date: '2026-10-21' })
158
+ .to_return(reply(report_json))
159
+
160
+ client.get_usage_analytics(Date.new(2026, 10, 20), '2026-10-21')
161
+ end
162
+
163
+ it 'accepts a frozen Time' do
164
+ stub_request(:get, url('/v2/admin/analytics'))
165
+ .with(query: { start_date: '2025-09-28', end_date: '2025-10-01' })
166
+ .to_return(reply(report_json))
167
+
168
+ client.get_usage_analytics(Time.new(2025, 9, 29, 1, 0, 0, '+02:00').freeze, '2025-10-01')
169
+ end
170
+
171
+ it 'leaves a rejected date range to the API' do
172
+ stub_request(:get, url('/v2/admin/analytics'))
173
+ .with(query: { start_date: '2025-10-01', end_date: '2025-10-01' })
174
+ .to_return(reply({ message: 'start_date must be before end_date' }, 400))
175
+
176
+ expect { client.get_usage_analytics('2025-10-01', '2025-10-01') }
177
+ .to raise_error(DeepL::Exceptions::BadRequest)
178
+ end
179
+
180
+ it 'reads an absent speech_to_speech_minutes as nil' do
181
+ body = { usage_report: {
182
+ total_usage: { speech_to_speech_minutes: 1.5 },
183
+ key_usages: [{ api_key: 'dc88****3a2c', usage: { total_characters: 3 } }]
184
+ } }
185
+ stub_request(:get, url('/v2/admin/analytics')).with(query: hash_including({}))
186
+ .to_return(reply(body))
187
+
188
+ report = client.get_usage_analytics('2025-09-29', '2025-10-01')
189
+
190
+ expect(report.total_usage.speech_to_speech_minutes).to eq(1.5)
191
+ expect(report.key_usages.first.usage.speech_to_speech_minutes).to be_nil
192
+ end
193
+
194
+ it 'reads an absent total_usage as nil' do
195
+ body = { usage_report: { start_date: '2025-09-29T00:00:00' } }
196
+ stub_request(:get, url('/v2/admin/analytics')).with(query: hash_including({}))
197
+ .to_return(reply(body))
198
+
199
+ report = client.get_usage_analytics('2025-09-29', '2025-10-01')
200
+
201
+ expect(report.total_usage).to be_nil
202
+ end
203
+
204
+ it 'tolerates malformed values in the report' do # rubocop:disable RSpec/ExampleLength
205
+ body = { usage_report: {
206
+ total_usage: { total_characters: 12, speech_to_text_minutes: 'many' },
207
+ start_date: 'not a timestamp', end_date: 5, group_by: 'keyAndDay',
208
+ key_and_day_usages: [
209
+ 'bad entry', nil,
210
+ { usage_date: 'not a timestamp', usage: 'oops' },
211
+ { api_key: 'dc88****3a2c', usage: { total_characters: 3 } }
212
+ ]
213
+ } }
214
+ stub_request(:get, url('/v2/admin/analytics')).with(query: hash_including({}))
215
+ .to_return(reply(body))
216
+
217
+ report = client.get_usage_analytics('2025-09-29', '2025-10-01')
218
+
219
+ expect(report).to have_attributes(start_date: nil, end_date: nil, group_by: 'keyAndDay',
220
+ key_usages: [])
221
+ expect(report.total_usage).to have_attributes(total_characters: 12,
222
+ speech_to_text_minutes: 0.0)
223
+ expect(report.key_and_day_usages.map { |usage| [usage.api_key, usage.api_key_label] })
224
+ .to eq([['', nil], ['dc88****3a2c', nil]])
225
+ expect(report.key_and_day_usages.map(&:usage_date)).to eq([nil, nil])
226
+ expect(report.key_and_day_usages.last.usage.total_characters).to eq(3)
227
+ end
228
+
229
+ {
230
+ 'a date that is not YYYY-MM-DD' => ['2025-9-29', '2025-10-01', /Invalid date/],
231
+ 'a date with a time' => ['2025-09-29T00:00:00Z', '2025-10-01', /Invalid date/],
232
+ 'a date that does not exist' => ['2025-02-30', '2025-10-01', /Invalid date/],
233
+ 'a date of another type' => [20_250_929, '2025-10-01', /Invalid date/]
234
+ }.each do |description, (start_date, end_date, message)|
235
+ it "rejects #{description} before sending a request" do
236
+ expect { client.get_usage_analytics(start_date, end_date) }
237
+ .to raise_error(ArgumentError, message)
238
+ end
239
+ end
240
+ end
241
+
242
+ describe '#get_custom_tag_usage_analytics' do
243
+ let(:report_json) do
244
+ { custom_tag_usage_report: {
245
+ aggregate_by: 'day', start_date: '2026-05-03T00:00:00', end_date: '2026-05-15T00:00:00',
246
+ next_page: 3,
247
+ usage: [{ custom_tag: 'example-custom-tag', usage_date: '2026-05-04T00:00:00Z',
248
+ breakdown: { total_characters: 380, speech_to_speech_minutes: 4.2 } }]
249
+ } }
250
+ end
251
+
252
+ it 'sends the date range, aggregation and page' do
253
+ stub_request(:get, url('/v2/admin/analytics/custom-tags'))
254
+ .with(query: { start_date: '2026-05-03', end_date: '2026-05-15', aggregate_by: 'day',
255
+ page: '2' })
256
+ .to_return(reply(report_json))
257
+
258
+ client.get_custom_tag_usage_analytics(Time.utc(2026, 5, 3, 12, 30), Date.new(2026, 5, 15),
259
+ aggregate_by: :day, page: 2)
260
+ end
261
+
262
+ it 'parses the report' do
263
+ stub_request(:get, url('/v2/admin/analytics/custom-tags')).with(query: hash_including({}))
264
+ .to_return(reply(report_json))
265
+ report = client.get_custom_tag_usage_analytics('2026-05-03', '2026-05-15')
266
+
267
+ expect(report).to have_attributes(aggregate_by: 'day', next_page: 3,
268
+ start_date: Time.utc(2026, 5, 3))
269
+ expect(report.usage.first).to have_attributes(custom_tag: 'example-custom-tag',
270
+ usage_date: Time.utc(2026, 5, 4))
271
+ expect(report.usage.first.breakdown).to have_attributes(speech_to_speech_minutes: 4.2,
272
+ speech_to_text_minutes: 0.0)
273
+ end
274
+
275
+ it 'reads an absent speech_to_speech_minutes as nil' do
276
+ body = { custom_tag_usage_report: {
277
+ usage: [{ custom_tag: 'tag', breakdown: { total_characters: 380 } }]
278
+ } }
279
+ stub_request(:get, url('/v2/admin/analytics/custom-tags')).with(query: hash_including({}))
280
+ .to_return(reply(body))
281
+
282
+ report = client.get_custom_tag_usage_analytics('2026-05-03', '2026-05-15')
283
+
284
+ expect(report.usage.first.breakdown.speech_to_speech_minutes).to be_nil
285
+ end
286
+
287
+ it 'rejects an invalid date like the usage analytics' do
288
+ expect { client.get_custom_tag_usage_analytics('2025-9-1', '2025-09-01') }
289
+ .to raise_error(ArgumentError, /Invalid date/)
290
+ end
291
+
292
+ it 'tolerates malformed values in the report' do # rubocop:disable RSpec/ExampleLength
293
+ body = { custom_tag_usage_report: {
294
+ aggregate_by: 'day', start_date: 'not a timestamp', next_page: nil,
295
+ usage: ['bad entry', { usage_date: 'x', breakdown: [] },
296
+ { custom_tag: 'tag', breakdown: { total_characters: 'many',
297
+ speech_to_speech_minutes: 4.2 } }]
298
+ } }
299
+ stub_request(:get, url('/v2/admin/analytics/custom-tags')).with(query: hash_including({}))
300
+ .to_return(reply(body))
301
+
302
+ report = client.get_custom_tag_usage_analytics('2026-05-03', '2026-05-15')
303
+
304
+ expect(report).to have_attributes(aggregate_by: 'day', start_date: nil, next_page: nil)
305
+ expect(report.usage.map(&:custom_tag)).to eq(['', 'tag'])
306
+ expect(report.usage.last.breakdown).to have_attributes(total_characters: 0,
307
+ speech_to_speech_minutes: 4.2)
308
+ end
309
+ end
310
+
311
+ describe '#create_developer_key' do
312
+ def stub_create
313
+ stub_request(:post, url('/v2/admin/developer-keys'))
314
+ end
315
+
316
+ it 'posts the label and parses the key' do
317
+ stub_create.with(body: { label: 'developer key prod' }.to_json, headers: admin_headers)
318
+ .to_return(reply(developer_key_json))
319
+
320
+ key = client.create_developer_key(label: 'developer key prod')
321
+
322
+ expect(key).to have_attributes(key_id: key_id, label: 'developer key prod',
323
+ creation_time: Time.utc(2025, 7, 8, 8, 15, 29.362r),
324
+ deactivated_time: nil, deactivated?: false)
325
+ expect(key.usage_limits).to have_attributes(characters: 5000,
326
+ speech_to_text_milliseconds: nil,
327
+ speech_to_speech_milliseconds: 3_600_000)
328
+ end
329
+
330
+ it 'posts an empty body without a label' do
331
+ stub_create.with(body: '{}').to_return(reply(developer_key_json))
332
+
333
+ client.create_developer_key
334
+ end
335
+
336
+ it 'adds the extra body parameters last' do
337
+ stub_create.with(body: { label: 'replaced', extra: [1] }.to_json)
338
+ .to_return(reply(developer_key_json))
339
+
340
+ client.create_developer_key(label: 'given',
341
+ extra_body_parameters: { label: 'replaced', extra: [1] })
342
+ end
343
+
344
+ it 'raises when the response has no key_id' do
345
+ stub_create.to_return(reply(developer_key_json.reject { |k, _| k == :key_id }))
346
+
347
+ expect { client.create_developer_key }
348
+ .to raise_error(DeepL::Exceptions::Error, /missing key_id/)
349
+ end
350
+
351
+ it 'raises when the response is not a JSON object' do
352
+ stub_create.to_return(reply([developer_key_json]))
353
+
354
+ expect { client.create_developer_key }.to raise_error(DeepL::Exceptions::Error)
355
+ end
356
+
357
+ it 'reports should_retry? after a 429 reply when the retries are used up' do
358
+ stub_create.to_return(reply({}, 429))
359
+
360
+ expect { described_class.new(admin_key, max_retries: 0).create_developer_key }
361
+ .to raise_error(DeepL::Exceptions::Error) { |e| expect(e.should_retry?).to be(true) }
362
+ end
363
+
364
+ it 'retries after a 429 reply' do
365
+ stub = stub_create.to_return(reply({}, 429), reply(developer_key_json))
366
+
367
+ client.create_developer_key
368
+
369
+ expect(stub).to have_been_requested.twice
370
+ end
371
+
372
+ it 'reports should_retry? after a DNS resolution error when the retries are used up' do
373
+ stub_create.to_raise(SocketError)
374
+
375
+ expect { described_class.new(admin_key, max_retries: 0).create_developer_key }
376
+ .to raise_error(DeepL::Exceptions::Error) { |e| expect(e.should_retry?).to be(true) }
377
+ end
378
+
379
+ it 'retries after a DNS resolution error' do
380
+ stub = stub_create.to_raise(SocketError).then.to_return(reply(developer_key_json))
381
+
382
+ client.create_developer_key
383
+
384
+ expect(stub).to have_been_requested.twice
385
+ end
386
+
387
+ it 'reports should_retry? after a connection timeout when the retries are used up' do
388
+ stub_create.to_raise(Net::OpenTimeout)
389
+
390
+ expect { described_class.new(admin_key, max_retries: 0).create_developer_key }
391
+ .to raise_error(DeepL::Exceptions::Error) { |e| expect(e.should_retry?).to be(true) }
392
+ end
393
+
394
+ it 'retries after a connection timeout' do
395
+ stub = stub_create.to_raise(Net::OpenTimeout).then.to_return(reply(developer_key_json))
396
+
397
+ client.create_developer_key
398
+
399
+ expect(stub).to have_been_requested.twice
400
+ end
401
+
402
+ it 'reports should_retry? after a refused connection when the retries are used up' do
403
+ stub_create.to_raise(Errno::ECONNREFUSED)
404
+
405
+ expect { described_class.new(admin_key, max_retries: 0).create_developer_key }
406
+ .to raise_error(DeepL::Exceptions::Error) { |e| expect(e.should_retry?).to be(true) }
407
+ end
408
+
409
+ it 'retries after a refused connection' do
410
+ stub = stub_create.to_raise(Errno::ECONNREFUSED).then.to_return(reply(developer_key_json))
411
+
412
+ client.create_developer_key
413
+
414
+ expect(stub).to have_been_requested.twice
415
+ end
416
+
417
+ it 'does not retry a 500 or 503 reply' do
418
+ [500, 503].each do |status|
419
+ WebMock.reset!
420
+ stub = stub_create.to_return(reply({ message: 'Error' }, status))
421
+
422
+ expect { client.create_developer_key }
423
+ .to raise_error(DeepL::Exceptions::ServerError) { |e| expect(e.should_retry?).to be(false) }
424
+ expect(stub).to have_been_requested.once
425
+ end
426
+ end
427
+
428
+ it 'does not retry a read timeout or a connection reset' do
429
+ [Net::ReadTimeout, Errno::ECONNRESET].each do |error|
430
+ WebMock.reset!
431
+ stub = stub_create.to_raise(error)
432
+
433
+ expect { client.create_developer_key }
434
+ .to(raise_error(DeepL::Exceptions::ConnectionError) do |e|
435
+ expect([e.cause, e.should_retry?]).to match([be_a(error), false])
436
+ end)
437
+ expect(stub).to have_been_requested.once
438
+ end
439
+ end
440
+
441
+ it 'does not retry a body shorter than its Content-Length' do
442
+ short = { status: 200, body: '{"api', headers: { 'Content-Length' => '99' } }
443
+ stub = stub_create.to_return(short)
444
+
445
+ expect { client.create_developer_key }
446
+ .to raise_error(DeepL::Exceptions::ConnectionError) { |e| expect(e.should_retry?).to be(false) }
447
+ expect(stub).to have_been_requested.once
448
+ end
449
+ end
450
+
451
+ describe '#list_developer_keys' do
452
+ it 'parses all keys and retries a 500 reply' do
453
+ deactivated = developer_key_json.merge(is_deactivated: true,
454
+ deactivated_time: '2025-07-09T08:15:29Z')
455
+ stub = stub_request(:get, url('/v2/admin/developer-keys'))
456
+ .with(body: nil, headers: admin_headers)
457
+ .to_return(reply({}, 500), reply([developer_key_json, deactivated]))
458
+
459
+ keys = client.list_developer_keys
460
+
461
+ expect(stub).to have_been_requested.twice
462
+ expect(keys.map(&:deactivated?)).to eq([false, true])
463
+ expect(keys.last.deactivated_time).to eq(Time.utc(2025, 7, 9, 8, 15, 29))
464
+ end
465
+ end
466
+
467
+ describe 'malformed developer keys' do
468
+ it 'skips list entries that are not objects or lack a key_id, and fills in neutral defaults' do # rubocop:disable RSpec/ExampleLength
469
+ body = [1, { label: 'no id' },
470
+ { key_id: key_id, creation_time: 'not a timestamp', usage_limits: 'none',
471
+ is_deactivated: 'false' },
472
+ developer_key_json.merge(key_id: 'second', label: 'ok', is_deactivated: nil)]
473
+ stub_request(:get, url('/v2/admin/developer-keys')).to_return(reply(body))
474
+
475
+ keys = client.list_developer_keys
476
+
477
+ expect(keys.map(&:label)).to eq([nil, 'ok'])
478
+ expect(keys.first).to have_attributes(key_id: key_id, creation_time: nil,
479
+ is_deactivated: false, usage_limits: nil)
480
+ expect(keys.last).to have_attributes(is_deactivated: false)
481
+ expect(keys.last.creation_time).to eq(Time.utc(2025, 7, 8, 8, 15, 29.362r))
482
+ end
483
+
484
+ it 'raises when the list is not an array' do
485
+ stub_request(:get, url('/v2/admin/developer-keys')).to_return(reply(developer_key_json))
486
+
487
+ expect { client.list_developer_keys }.to raise_error(DeepL::Exceptions::Error)
488
+ end
489
+
490
+ it 'raises when an analytics response is not a JSON object' do
491
+ stub_request(:get, url('/v2/admin/analytics')).with(query: hash_including({}))
492
+ .to_return(reply([]))
493
+
494
+ expect { client.get_usage_analytics('2025-09-29', '2025-10-01') }
495
+ .to raise_error(DeepL::Exceptions::Error)
496
+ end
497
+ end
498
+
499
+ describe 'modifying developer keys' do
500
+ it 'deactivates and renames by key or key ID' do
501
+ stub_put('deactivate', key_id: key_id)
502
+ stub_put('label', key_id: key_id, label: 'renamed')
503
+
504
+ client.deactivate_developer_key(key)
505
+ client.rename_developer_key(key_id, 'renamed')
506
+ end
507
+
508
+ it 'sends only the given usage limits, including explicit nulls' do
509
+ stub_put('limits', key_id: key_id, characters: 0)
510
+ stub_put('limits', key_id: key_id, speech_to_text_milliseconds: nil,
511
+ speech_to_speech_milliseconds: 60_000)
512
+
513
+ client.set_developer_key_usage_limits(key, characters: 0)
514
+ client.set_developer_key_usage_limits(key_id, speech_to_text_milliseconds: nil,
515
+ speech_to_speech_milliseconds: 60_000)
516
+ end
517
+
518
+ it 'adds the extra body parameters last, apart from the usage limits' do
519
+ stub_put('deactivate', key_id: key_id, extra: true)
520
+ stub_put('label', key_id: key_id, label: 'replaced')
521
+ stub_put('limits', key_id: key_id, characters: 0, extra: nil)
522
+
523
+ client.deactivate_developer_key(key, extra_body_parameters: { extra: true })
524
+ client.rename_developer_key(key, 'given', extra_body_parameters: { label: 'replaced' })
525
+ client.set_developer_key_usage_limits(key, characters: 0,
526
+ extra_body_parameters: { extra: nil })
527
+ end
528
+
529
+ it 'sends the given key even if the limits contain a key_id' do
530
+ stub_put('limits', key_id: key_id, characters: 0)
531
+
532
+ client.set_developer_key_usage_limits(key, characters: 0, key_id: 'other')
533
+ expect { client.set_developer_key_usage_limits('', key_id: 'other') }
534
+ .to raise_error(ArgumentError, /key ID/)
535
+ end
536
+
537
+ it 'rejects an empty key ID before sending a request' do
538
+ expect { client.deactivate_developer_key('') }.to raise_error(ArgumentError, /key ID/)
539
+ expect { client.rename_developer_key(nil, 'renamed') }.to raise_error(ArgumentError, /key ID/)
540
+ expect { client.set_developer_key_usage_limits(nil, characters: 1) }
541
+ .to raise_error(ArgumentError, /key ID/)
542
+ end
543
+ end
544
+
545
+ it 'raises an authorization error on a 403 reply' do
546
+ stub_request(:get, url('/v2/admin/developer-keys'))
547
+ .to_return(reply({ message: 'Forbidden' }, 403))
548
+
549
+ expect { client.list_developer_keys }.to raise_error(DeepL::Exceptions::AuthorizationFailed)
550
+ end
551
+ end