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,463 @@
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 file.
4
+ # frozen_string_literal: true
5
+
6
+ require_relative 'offline_helper'
7
+
8
+ describe DeepL::TranslationMemoryApi do
9
+ let(:memories) { DeepL.translation_memories }
10
+ let(:upload_url) { 'https://storage.example.test/upload?sig=abc' }
11
+ let(:download_url) { 'https://storage.example.test/download?sig=def' }
12
+ let(:memory_json) do
13
+ { translation_memory_id: 'tm-1', name: 'memory', source_language: 'de',
14
+ target_languages: %w[en fr], segment_count: 3, creation_time: '2026-01-02T03:04:05Z',
15
+ updated_time: '2026-02-03T04:05:06Z' }
16
+ end
17
+
18
+ def job_json(status, operation: 'import', **result)
19
+ { job_id: 'j-1', product: 'translation_memory', operation: operation,
20
+ results: [{ status: status }.merge(result)] }
21
+ end
22
+
23
+ def job_resource(status, **result)
24
+ json = job_json(status, operation: 'export', **result).to_json
25
+ DeepL::Resources::TranslationMemoryJob.new(JSON.parse(json), nil, nil)
26
+ end
27
+
28
+ def stub_job(*replies)
29
+ stub_request(:get, url('/v3/translation_memories/jobs/j-1')).with(headers: json_headers)
30
+ .to_return(*replies)
31
+ end
32
+
33
+ def no_authorization
34
+ ->(request) { !request.headers.key?('Authorization') }
35
+ end
36
+
37
+ describe '#list' do
38
+ it 'sends a GET without a query by default and parses the timestamps' do
39
+ stub_request(:get, url('/v3/translation_memories'))
40
+ .with(headers: json_headers).to_return(reply(translation_memories: [memory_json]))
41
+
42
+ memory = memories.list.first
43
+
44
+ expect([memory.creation_time, memory.updated_time])
45
+ .to eq([Time.utc(2026, 1, 2, 3, 4, 5), Time.utc(2026, 2, 3, 4, 5, 6)])
46
+ end
47
+
48
+ it 'sends page and page_size in the query' do
49
+ stub_request(:get, url('/v3/translation_memories?page=1&page_size=20'))
50
+ .to_return(reply(translation_memories: []))
51
+
52
+ memories.list(page: 1, page_size: 20)
53
+ end
54
+
55
+ it 'does not send an option set to nil' do
56
+ stub_request(:get, url('/v3/translation_memories?page_size=20'))
57
+ .to_return(reply(translation_memories: []))
58
+
59
+ memories.list(page: nil, page_size: 20)
60
+ end
61
+ end
62
+
63
+ describe '#find and #destroy' do
64
+ it 'accept an ID or a translation memory object' do
65
+ object = DeepL::Resources::TranslationMemory.new({ 'translation_memory_id' => 'tm-1' },
66
+ nil, nil)
67
+ stub_request(:get, url('/v3/translation_memories/tm-1'))
68
+ .with(headers: json_headers).to_return(reply(memory_json))
69
+ stub_request(:delete, url('/v3/translation_memories/tm-1'))
70
+ .with(headers: json_headers).to_return(status: 204)
71
+
72
+ memories.find('tm-1')
73
+ memories.find(object)
74
+
75
+ expect([memories.destroy('tm-1'), memories.destroy(object)]).to eq(%w[tm-1 tm-1])
76
+ end
77
+
78
+ ['', nil].each do |id|
79
+ it "raise Error for the ID #{id.inspect} without sending a request" do
80
+ expect { memories.find(id) }.to raise_error(DeepL::Exceptions::Error, /must not be empty/)
81
+ expect { memories.destroy(id) }
82
+ .to raise_error(DeepL::Exceptions::Error, /must not be empty/)
83
+ end
84
+ end
85
+ end
86
+
87
+ describe 'IDs in the path' do
88
+ it 'URL-encodes translation memory and job IDs' do # rubocop:disable RSpec/ExampleLength
89
+ stub_request(:get, url('/v3/translation_memories/a%2Fb%20c')).to_return(reply(memory_json))
90
+ stub_request(:delete, url('/v3/translation_memories/a%2Fb%20c')).to_return(status: 204)
91
+ stub_request(:get, url('/v3/translation_memories/a%2Fb%20c/segments'))
92
+ .to_return(reply(segment_count: 0, segments: []))
93
+ stub_request(:post, url('/v3/translation_memories/a%2Fb%20c/export'))
94
+ .to_return(reply({ job_id: 'j-1' }, 202))
95
+ stub_request(:get, url('/v3/translation_memories/jobs/j%3F1'))
96
+ .to_return(reply(job_json('processing')))
97
+
98
+ memories.find('a/b c')
99
+ memories.destroy('a/b c')
100
+ memories.segments('a/b c')
101
+ memories.create_export('a/b c')
102
+ memories.find_job('j?1')
103
+ end
104
+ end
105
+
106
+ describe 'timestamps' do
107
+ it 'are read as UTC without an offset and as nil when malformed' do # rubocop:disable RSpec/ExampleLength
108
+ times = { 'creation_time' => '2026-01-02T03:04:05', 'updated_time' => 'not a time',
109
+ 'last_used_time' => 'not a time', 'expires_at' => '2026-01-02T03:04:05' }
110
+ job = DeepL::Resources::TranslationMemoryJob.new(times.merge('results' => [times]), nil,
111
+ nil)
112
+ parsed = [DeepL::Resources::TranslationMemory.new(times, nil, nil),
113
+ DeepL::Resources::TranslationMemorySegment.new(times),
114
+ DeepL::Resources::TranslationMemoryTargetSegment.new(times), job]
115
+
116
+ expect(parsed.map { |resource| [resource.creation_time, resource.updated_time] })
117
+ .to all(eq([Time.utc(2026, 1, 2, 3, 4, 5), nil]))
118
+ expect([DeepL::Resources::TranslationMemoryImport.new(times, nil, nil).expires_at,
119
+ job.result.expires_at]).to all(eq(Time.utc(2026, 1, 2, 3, 4, 5)))
120
+ end
121
+ end
122
+
123
+ describe '#segments' do
124
+ let(:segments_json) do
125
+ { segment_count: 7, next_page_cursor: 'c-2',
126
+ segments: [{ source_segment_id: 'a', source_text: 'Haus',
127
+ targets: [{ target_segment_id: 'b', target_language: 'en',
128
+ target_text: 'house', creation_time: '2026-01-02T03:04:05Z' }] }] }
129
+ end
130
+
131
+ it 'sends the paging and filter options in the query' do
132
+ query = 'page_size=5&page_cursor=c-1&filter_text=a%20b&filter_case_sensitive=true'
133
+ stub_request(:get, url("/v3/translation_memories/tm-1/segments?#{query}"))
134
+ .with(headers: json_headers).to_return(reply(segments_json))
135
+
136
+ memories.segments('tm-1', page_size: 5, page_cursor: 'c-1', filter_text: 'a b',
137
+ filter_case_sensitive: true)
138
+ end
139
+
140
+ it 'does not send an option set to nil' do
141
+ stub_request(:get, url('/v3/translation_memories/tm-1/segments?page_size=5'))
142
+ .to_return(reply(segments_json))
143
+
144
+ memories.segments('tm-1', page_size: 5, page_cursor: nil, filter_text: nil,
145
+ filter_case_sensitive: nil)
146
+ end
147
+
148
+ it 'parses the timestamp of a target and the next page' do
149
+ stub_request(:get, url('/v3/translation_memories/tm-1/segments'))
150
+ .to_return(reply(segments_json))
151
+
152
+ page = memories.segments('tm-1')
153
+ segment = page.segments.first
154
+ target = segment.targets.first
155
+
156
+ expect([page.next_page?, target.creation_time]).to eq([true, Time.utc(2026, 1, 2, 3, 4, 5)])
157
+ end
158
+
159
+ it 'reports no next page when there is no cursor' do
160
+ stub_request(:get, url('/v3/translation_memories/tm-1/segments'))
161
+ .to_return(reply(segment_count: 0, segments: []))
162
+
163
+ expect(memories.segments('tm-1').next_page?).to be(false)
164
+ end
165
+ end
166
+
167
+ describe '#create_import' do
168
+ it 'sends the source file description' do
169
+ stub_request(:post, url('/v3/translation_memories/import'))
170
+ .with(body: { source_file: { file_name: 'a.tmx', content_length: 12 } },
171
+ headers: json_headers)
172
+ .to_return(reply({ job_id: 'j-1', upload_url: upload_url }, 201))
173
+
174
+ memories.create_import('a.tmx', 12)
175
+ end
176
+
177
+ it 'sends the content type and display name when given, and parses the expiry' do
178
+ stub_request(:post, url('/v3/translation_memories/import'))
179
+ .with(body: { source_file: { file_name: 'a.tmx', content_length: 12,
180
+ content_type: 'application/x-tmx' },
181
+ parameters: { display_name: 'My memory' } })
182
+ .to_return(reply({ job_id: 'j-1', upload_url: upload_url,
183
+ expires_at: '2026-03-04T05:06:07Z' }, 201))
184
+
185
+ created = memories.create_import('a.tmx', 12, content_type: 'application/x-tmx',
186
+ display_name: 'My memory')
187
+
188
+ expect(created.expires_at).to eq(Time.utc(2026, 3, 4, 5, 6, 7))
189
+ end
190
+ end
191
+
192
+ describe '#create_import with extra_body_parameters' do
193
+ it 'adds them to the request body last' do
194
+ stub_request(:post, url('/v3/translation_memories/import'))
195
+ .with(body: { source_file: { file_name: 'a.tmx', content_length: 12 },
196
+ parameters: { display_name: 'replaced' }, extra: 1 })
197
+ .to_return(reply({ job_id: 'j-1', upload_url: upload_url }, 201))
198
+
199
+ memories.create_import('a.tmx', 12, display_name: 'My memory',
200
+ extra_body_parameters: {
201
+ parameters: { display_name: 'replaced' }, extra: 1
202
+ })
203
+ end
204
+ end
205
+
206
+ describe '#upload_file' do
207
+ it 'sends the content as the PUT body without credentials' do
208
+ stub_request(:put, upload_url)
209
+ .with(body: Fixtures::EXAMPLE_TMX, headers: { 'Content-Type' => 'application/xml' },
210
+ &no_authorization).to_return(status: 200)
211
+
212
+ memories.upload_file(upload_url, Fixtures::EXAMPLE_TMX)
213
+ end
214
+
215
+ it 'sends the User-Agent of config.headers to the storage URL' do
216
+ DeepL.configuration.headers = { 'user-agent' => 'mine/1' }
217
+ stub_request(:put, upload_url).with(headers: { 'User-Agent' => 'mine/1' }, &no_authorization)
218
+ .to_return(status: 200)
219
+
220
+ memories.upload_file(upload_url, Fixtures::EXAMPLE_TMX)
221
+ end
222
+
223
+ it 'takes the URL from an import and sends the given content type and bytes' do
224
+ created = DeepL::Resources::TranslationMemoryImport.new({ 'upload_url' => upload_url },
225
+ nil, nil)
226
+ stub_request(:put, upload_url)
227
+ .with(body: Fixtures::BINARY_CONTENT, headers: { 'Content-Type' => 'text/xml' },
228
+ &no_authorization).to_return(status: 200)
229
+
230
+ memories.upload_file(created, Fixtures::BINARY_CONTENT, content_type: 'text/xml')
231
+ end
232
+
233
+ it 'raises AuthorizationFailed for a storage reply of 403' do
234
+ stub_request(:put, upload_url).to_return(status: 403, body: 'Request has expired')
235
+
236
+ expect { memories.upload_file(upload_url, 'tmx') }
237
+ .to raise_error(DeepL::Exceptions::AuthorizationFailed)
238
+ end
239
+ end
240
+
241
+ describe '#find_job' do
242
+ it 'parses the job parameters, status and result' do
243
+ stub_job(reply(job_json('completed', translation_memory_id: 'tm-1', skipped_segment_count: 2)
244
+ .merge(parameters: { translation_memory_id: 'tm-1',
245
+ display_name: 'memory' })))
246
+
247
+ job = memories.find_job('j-1')
248
+
249
+ expect([job.status, job.finished?, job.error?, job.translation_memory_id, job.display_name])
250
+ .to eq(['completed', true, false, 'tm-1', 'memory'])
251
+ end
252
+
253
+ {
254
+ 'awaiting_input' => [false, true, false, false, true],
255
+ 'processing' => [false, false, false, false, true],
256
+ 'completed' => [true, false, false, true, true],
257
+ 'downloaded' => [true, false, false, false, true],
258
+ 'failed' => [true, false, true, false, false],
259
+ 'expired' => [true, false, true, false, false]
260
+ }.each do |status, flags|
261
+ it "parses the #{status} status as finished, awaiting input, error, done, ok: " \
262
+ "#{flags.inspect}" do
263
+ stub_job(reply(job_json(status)))
264
+
265
+ job = memories.find_job('j-1')
266
+
267
+ expect([job.finished?, job.awaiting_input?, job.error?, job.done?, job.ok?]).to eq(flags)
268
+ end
269
+ end
270
+
271
+ it 'rejects an empty job ID without sending a request' do
272
+ expect { memories.find_job('') }.to raise_error(DeepL::Exceptions::Error, /must not be empty/)
273
+ end
274
+ end
275
+
276
+ describe '#wait_until_job_done' do
277
+ it 'returns a finished job without sleeping' do
278
+ stub = stub_job(reply(job_json('completed')))
279
+
280
+ expect(memories.wait_until_job_done('j-1').status).to eq('completed')
281
+ expect(stub).to have_been_requested.once
282
+ end
283
+
284
+ it 'polls until the job is completed' do
285
+ stub = stub_job(reply(job_json('awaiting_input')), reply(job_json('completed')))
286
+
287
+ job = memories.wait_until_job_done(DeepL::Resources::TranslationMemoryJob.new(
288
+ { 'job_id' => 'j-1' }, nil, nil
289
+ ))
290
+
291
+ expect(job.status).to eq('completed')
292
+ expect(stub).to have_been_requested.twice
293
+ end
294
+
295
+ %w[failed expired].each do |status|
296
+ it "raises Error with the API message when the job is #{status}" do
297
+ stub_job(reply(job_json(status, error: { message: 'bad file' })))
298
+
299
+ expect { memories.wait_until_job_done('j-1') }
300
+ .to raise_error(DeepL::Exceptions::Error, /import: bad file/)
301
+ end
302
+ end
303
+
304
+ it 'raises Error with the status when the job has no error message' do
305
+ stub_job(reply(job_json('failed')))
306
+
307
+ expect do
308
+ memories.wait_until_job_done('j-1')
309
+ end.to raise_error(DeepL::Exceptions::Error, /: failed/)
310
+ end
311
+
312
+ it 'raises Error when timeout_s is exceeded while the job is not finished' do
313
+ stub_job(reply(job_json('processing')))
314
+
315
+ expect { memories.wait_until_job_done('j-1', timeout_s: 0) }
316
+ .to raise_error(DeepL::Exceptions::Error, /timeout of 0s exceeded/)
317
+ end
318
+
319
+ it 'cuts the last sleep to the deadline of timeout_s' do
320
+ stub_job(reply(job_json('processing')))
321
+ slept = []
322
+ allow(memories).to receive(:sleep) do |seconds|
323
+ slept << seconds
324
+ Kernel.sleep(seconds)
325
+ end
326
+
327
+ expect { memories.wait_until_job_done('j-1', timeout_s: 0.2) }
328
+ .to raise_error(DeepL::Exceptions::Error, /timeout of 0.2s exceeded/)
329
+ expect(slept).to all(be <= 0.2)
330
+ end
331
+ end
332
+
333
+ describe '#create_export' do
334
+ it 'sends a POST with an empty body and parses a new export' do
335
+ stub_request(:post, url('/v3/translation_memories/tm-1/export'))
336
+ .with(body: '{}', headers: json_headers)
337
+ .to_return(reply({ job_id: 'j-1', parameters: { translation_memory_id: 'tm-1' } }, 202))
338
+
339
+ export = memories.create_export('tm-1')
340
+
341
+ expect([export.translation_memory_id, export.reused_existing?]).to eq(['tm-1', false])
342
+ end
343
+
344
+ it 'reports an export that the API reused' do
345
+ stub_request(:post, url('/v3/translation_memories/tm-1/export'))
346
+ .to_return(reply({ job_id: 'j-1' }, 200))
347
+
348
+ expect(memories.create_export('tm-1').reused_existing?).to be(true)
349
+ end
350
+ end
351
+
352
+ describe '#download_export' do
353
+ let(:output) { File.join(temp_dir, 'export.tmx') }
354
+
355
+ include_context 'with a temporary directory'
356
+
357
+ it 'writes the download to the file byte for byte, without credentials' do
358
+ stub_request(:get, download_url).with(&no_authorization)
359
+ .to_return(status: 200, body: Fixtures::BINARY_CONTENT)
360
+
361
+ memories.download_export(download_url, output)
362
+
363
+ expect(File.binread(output)).to eq(Fixtures::BINARY_CONTENT)
364
+ end
365
+
366
+ it 'takes the URL from a finished export job' do
367
+ stub_request(:get, download_url).to_return(status: 200, body: 'tmx')
368
+
369
+ memories.download_export(job_resource('completed', download_url: download_url), output)
370
+
371
+ expect(File.read(output)).to eq('tmx')
372
+ end
373
+
374
+ it 'raises Error for a job without a download URL' do
375
+ expect { memories.download_export(job_resource('processing'), output) }
376
+ .to raise_error(DeepL::Exceptions::Error, /no download URL/)
377
+ end
378
+
379
+ it 'raises the Errno error when the file cannot be written' do
380
+ stub_request(:get, download_url).to_return(status: 200, body: 'tmx')
381
+
382
+ expect { memories.download_export(download_url, File.join(temp_dir, 'missing', 'x.tmx')) }
383
+ .to raise_error(Errno::ENOENT)
384
+ end
385
+
386
+ it 'raises AuthorizationFailed for a storage reply of 403' do
387
+ stub_request(:get, download_url).to_return(status: 403, body: 'Request has expired')
388
+
389
+ expect { memories.download_export(download_url, output) }
390
+ .to raise_error(DeepL::Exceptions::AuthorizationFailed)
391
+ end
392
+
393
+ it 'retries a storage reply of 503 and raises ServerError' do
394
+ stub = stub_request(:get, download_url).to_return(status: 503)
395
+
396
+ expect { memories.download_export(download_url, output) }
397
+ .to raise_error(DeepL::Exceptions::ServerError)
398
+ expect(stub).to have_been_requested.times(3)
399
+ end
400
+ end
401
+
402
+ describe 'convenience methods' do
403
+ include_context 'with a temporary directory'
404
+
405
+ it 'imports a file: creates the import, uploads the bytes and waits for the job' do # rubocop:disable RSpec/ExampleLength
406
+ path = File.join(temp_dir, 'memory.tmx')
407
+ File.write(path, Fixtures::EXAMPLE_TMX)
408
+ stub_request(:post, url('/v3/translation_memories/import'))
409
+ .with(body: { source_file: { file_name: 'memory.tmx',
410
+ content_length: Fixtures::EXAMPLE_TMX.bytesize },
411
+ parameters: { display_name: 'imported' } })
412
+ .to_return(reply({ job_id: 'j-1', upload_url: upload_url }, 201))
413
+ stub_request(:put, upload_url).with(body: Fixtures::EXAMPLE_TMX).to_return(status: 200)
414
+ stub_job(reply(job_json('completed', translation_memory_id: 'tm-1')))
415
+
416
+ job = memories.import_from_filepath(path, display_name: 'imported')
417
+
418
+ expect(job.finished?).to be(true)
419
+ end
420
+
421
+ it 'imports a file with a content type and extra body parameters' do # rubocop:disable RSpec/ExampleLength
422
+ path = File.join(temp_dir, 'memory.tmx')
423
+ File.write(path, Fixtures::EXAMPLE_TMX)
424
+ stub_request(:post, url('/v3/translation_memories/import'))
425
+ .with(body: { source_file: { file_name: 'memory.tmx',
426
+ content_length: Fixtures::EXAMPLE_TMX.bytesize,
427
+ content_type: 'text/xml' }, extra: 'x' })
428
+ .to_return(reply({ job_id: 'j-1', upload_url: upload_url }, 201))
429
+ stub_request(:put, upload_url).with(headers: { 'Content-Type' => 'text/xml' })
430
+ .to_return(status: 200)
431
+ stub_job(reply(job_json('completed')))
432
+
433
+ memories.import_from_filepath(path, content_type: 'text/xml',
434
+ extra_body_parameters: { extra: 'x' })
435
+ end
436
+
437
+ it 'raises Error for a missing import file without sending a request' do
438
+ expect { memories.import_from_filepath(File.join(temp_dir, 'missing.tmx')) }
439
+ .to raise_error(DeepL::Exceptions::Error, /No file found/)
440
+ end
441
+
442
+ it 'raises the Errno error for an unreadable import file without sending a request' do
443
+ expect { memories.import_from_filepath(temp_dir) }.to raise_error(Errno::EISDIR)
444
+ end
445
+
446
+ it 'raises Errno::ENOENT for a missing output directory before creating the export job' do
447
+ expect { memories.export_to_filepath('tm-1', File.join(temp_dir, 'missing', 'x.tmx')) }
448
+ .to raise_error(Errno::ENOENT, /missing/)
449
+ end
450
+
451
+ it 'exports to a file: creates the export, waits for the job and downloads the result' do
452
+ output = File.join(temp_dir, 'export.tmx')
453
+ stub_request(:post, url('/v3/translation_memories/tm-1/export'))
454
+ .to_return(reply({ job_id: 'j-1' }, 202))
455
+ stub_job(reply(job_json('completed', operation: 'export', download_url: download_url)))
456
+ stub_request(:get, download_url).to_return(status: 200, body: 'tmx')
457
+
458
+ job = memories.export_to_filepath('tm-1', output)
459
+
460
+ expect([File.read(output), job.status]).to eq(%w[tmx completed])
461
+ end
462
+ end
463
+ end
@@ -0,0 +1,151 @@
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 file.
4
+ # frozen_string_literal: true
5
+
6
+ require_relative 'offline_helper'
7
+
8
+ describe 'usage' do
9
+ describe 'DeepL.usage' do
10
+ it 'sends a GET without a body or Content-Type' do
11
+ stub_request(:get, url('/v2/usage')).with(headers: auth_headers) { bodyless?(_1) }
12
+ .to_return(usage_reply)
13
+ DeepL.usage
14
+ end
15
+
16
+ it 'reports no exceeded quota while the count is below the limit' do
17
+ stub_request(:get, url('/v2/usage'))
18
+ .to_return(reply(character_count: 42, character_limit: 100))
19
+
20
+ expect(DeepL.usage.quota_exceeded?).to be(false)
21
+ end
22
+
23
+ it 'reports an exceeded quota when the count reaches the limit' do
24
+ stub_request(:get,
25
+ url('/v2/usage')).to_return(reply(character_count: 100, character_limit: 100))
26
+
27
+ expect(DeepL.usage.quota_exceeded?).to be(true)
28
+ end
29
+
30
+ context 'with an API Pro response' do
31
+ let(:pro_body) do
32
+ {
33
+ character_count: 5_947_223, character_limit: 1_000_000_000_000,
34
+ products: [
35
+ { product_type: 'write', billing_unit: 'characters', api_key_unit_count: 0,
36
+ account_unit_count: 5643, api_key_character_count: 0, character_count: 5643 },
37
+ { product_type: 'translate', billing_unit: 'characters', api_key_unit_count: 636,
38
+ account_unit_count: 5_941_580, api_key_character_count: 636,
39
+ character_count: 5_941_580 },
40
+ { product_type: 'speechToText', billing_unit: 'minutes', api_key_unit_count: 30,
41
+ account_unit_count: 30, api_key_character_count: 0, character_count: 0 },
42
+ { product_type: 'speechToSpeech', billing_unit: 'minutes', api_key_unit_count: 12,
43
+ account_unit_count: 12, api_key_character_count: 0, character_count: 0 }
44
+ ],
45
+ api_key_character_count: 636, api_key_character_limit: 1_000_000_000_000,
46
+ speech_to_text_milliseconds_count: 0, speech_to_text_milliseconds_limit: 0,
47
+ speech_to_text_minutes_count: 30, speech_to_text_minutes_limit: 600,
48
+ speech_to_speech_minutes_count: 12, speech_to_speech_minutes_limit: 600,
49
+ start_time: '2025-05-13T09:18:42Z', end_time: '2025-06-13T09:18:42Z'
50
+ }
51
+ end
52
+
53
+ before { stub_request(:get, url('/v2/usage')).to_return(reply(pro_body)) }
54
+
55
+ it 'parses the per-key, speech and billing period fields' do
56
+ usage = DeepL.usage
57
+
58
+ expect(usage).to have_attributes(
59
+ character_count: 5_947_223, character_limit: 1_000_000_000_000,
60
+ start_time: Time.utc(2025, 5, 13, 9, 18, 42), end_time: Time.utc(2025, 6, 13, 9, 18, 42),
61
+ to_s: '5947223 / 1000000000000'
62
+ )
63
+ expect(usage.character).to have_attributes(count: 5_947_223, limit: 1_000_000_000_000)
64
+ expect(usage.api_key_character).to have_attributes(count: 636, limit: 1_000_000_000_000)
65
+ expect([usage.speech_to_text_minutes, usage.speech_to_speech_minutes].map(&:to_s))
66
+ .to eq(['30 of 600', '12 of 600'])
67
+ end
68
+
69
+ it 'parses the per-product usage' do
70
+ products = DeepL.usage.products
71
+
72
+ expect(products).to all(be_a(DeepL::Resources::UsageProduct))
73
+ expect(products.map(&:product_type)).to eq(%w[write translate speechToText speechToSpeech])
74
+ expect(products.map(&:billing_unit)).to eq(%w[characters characters minutes minutes])
75
+ expect(products.map(&:api_key_unit_count)).to eq([0, 636, 30, 12])
76
+ expect(products.map(&:account_unit_count)).to eq([5643, 5_941_580, 30, 12])
77
+ end
78
+ end
79
+
80
+ it 'leaves the API Pro fields nil for a Free response' do
81
+ stub_request(:get, url('/v2/usage')).to_return(reply(character_count: 180_118,
82
+ character_limit: 1_250_000))
83
+ usage = DeepL.usage
84
+
85
+ expect(usage.character).to have_attributes(count: 180_118, limit: 1_250_000)
86
+ expect(usage.to_s).to eq('180118 / 1250000')
87
+ expect(usage.quota_exceeded?).to be(false)
88
+ DeepL::Resources::Usage::KEYWORD_FIELDS.each do |field|
89
+ expect(usage.public_send(field)).to be_nil
90
+ end
91
+ end
92
+
93
+ it 'parses the document and team document usage' do
94
+ stub_request(:get, url('/v2/usage')).to_return(
95
+ reply(character_count: 1, character_limit: 100, document_count: 5, document_limit: 5,
96
+ team_document_count: 2, team_document_limit: 10)
97
+ )
98
+ usage = DeepL.usage
99
+
100
+ expect(usage.document).to have_attributes(count: 5, limit: 5, limit_reached?: true)
101
+ expect(usage.team_document).to have_attributes(count: 2, limit: 10, limit_reached?: false)
102
+ expect(usage.any_limit_reached?).to be(true)
103
+ end
104
+
105
+ it 'does not count the API Pro usage types in any_limit_reached?' do
106
+ stub_request(:get, url('/v2/usage')).to_return(
107
+ reply(character_count: 1, character_limit: 100, api_key_character_count: 50,
108
+ api_key_character_limit: 50)
109
+ )
110
+ usage = DeepL.usage
111
+
112
+ expect(usage.api_key_character.limit_reached?).to be(true)
113
+ expect(usage.any_limit_reached?).to be(false)
114
+ end
115
+
116
+ it 'ignores an unparseable billing period and malformed products' do
117
+ stub_request(:get, url('/v2/usage')).to_return(
118
+ reply(character_count: 20, character_limit: 500_000, start_time: 'not a timestamp',
119
+ end_time: '', products: [{ product_type: 'translate' }, 'not an object'])
120
+ )
121
+ usage = DeepL.usage
122
+
123
+ expect(usage.character_count).to eq(20)
124
+ expect([usage.start_time, usage.end_time]).to eq([nil, nil])
125
+ expect(usage.products.map(&:product_type)).to eq(['translate'])
126
+ end
127
+
128
+ it 'keeps the positional constructor working' do
129
+ usage = DeepL::Resources::Usage.new(1, 10, nil, nil)
130
+
131
+ expect([usage.character_count, usage.character_limit, usage.products]).to eq([1, 10, nil])
132
+ end
133
+ end
134
+
135
+ describe DeepL::Client do
136
+ let(:client) { described_class.new('client-key', server_url: 'https://client.example.test') }
137
+
138
+ it '#get_usage sends GET /v2/usage and parses the usage' do
139
+ stub_request(:get, 'https://client.example.test/v2/usage')
140
+ .with(headers: { 'Authorization' => 'DeepL-Auth-Key client-key' })
141
+ .to_return(reply(character_count: 42, character_limit: 100, document_count: 5,
142
+ document_limit: 5))
143
+
144
+ usage = client.get_usage
145
+
146
+ expect(usage.character).to have_attributes(count: 42, limit: 100, limit_reached?: false)
147
+ expect(usage.document).to have_attributes(count: 5, limit: 5)
148
+ expect([usage.any_limit_reached?, usage.team_document]).to eq([true, nil])
149
+ end
150
+ end
151
+ end