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,286 @@
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.translate' do
9
+ let(:translation_reply) { reply(translations: [{ text: 'Hallo' }]) }
10
+
11
+ # Every translate request asks for the billed characters.
12
+ def expect_body(body)
13
+ stub_request(:post, url('/v2/translate'))
14
+ .with(body: { show_billed_characters: true }.merge(body), headers: json_headers)
15
+ .to_return(translation_reply)
16
+ end
17
+
18
+ it 'sends no source language for auto-detection' do
19
+ expect_body(text: ['Hello'], target_lang: 'DE')
20
+ DeepL.translate('Hello', nil, 'DE')
21
+ end
22
+
23
+ it 'always sends show_billed_characters' do
24
+ stub_request(:post, url('/v2/translate'))
25
+ .with(body: { text: ['Hello'], source_lang: 'EN', target_lang: 'DE',
26
+ show_billed_characters: true })
27
+ .to_return(translation_reply)
28
+
29
+ DeepL.translate('Hello', 'EN', 'DE')
30
+ end
31
+
32
+ it 'sends no options set to nil' do
33
+ expect_body(text: ['Hello'], target_lang: 'DE')
34
+ DeepL.translate('Hello', nil, 'DE', formality: nil, 'context' => nil, glossary: nil,
35
+ translation_memory: nil,
36
+ translation_memory_threshold: nil)
37
+ end
38
+
39
+ it 'sends the source language and several texts' do
40
+ stub_request(:post, url('/v2/translate'))
41
+ .with(body: { text: %w[Hello Bye], source_lang: 'EN', target_lang: 'DE',
42
+ show_billed_characters: true })
43
+ .to_return(reply(translations: [{ text: 'Hallo' }, { text: 'Tschüss' }]))
44
+
45
+ expect(DeepL.translate(%w[Hello Bye], 'EN', 'DE').map(&:class))
46
+ .to eq([DeepL::Resources::Text] * 2)
47
+ end
48
+
49
+ it 'sends the additional headers' do
50
+ stub_request(:post, url('/v2/translate'))
51
+ .with(headers: json_headers.merge('X-DeepL-Reporting-Tag' => 'tag'))
52
+ .to_return(translation_reply)
53
+
54
+ DeepL.translate('Hello', 'EN', 'DE', {}, { 'X-DeepL-Reporting-Tag' => 'tag' })
55
+ end
56
+
57
+ it 'sends options given with string keys' do
58
+ expect_body(text: ['Hello'], source_lang: 'EN', target_lang: 'DE', split_sentences: '1',
59
+ formality: 'less')
60
+ DeepL.translate('Hello', 'EN', 'DE', 'formality' => 'less', 'split_sentences' => true)
61
+ end
62
+
63
+ describe 'options passed through unchanged' do
64
+ {
65
+ formality: 'prefer_more',
66
+ tag_handling: 'xml',
67
+ tag_handling_version: 'v2',
68
+ context: 'A greeting',
69
+ model_type: 'latency_optimized',
70
+ glossary_id: 'g-1',
71
+ show_billed_characters: true
72
+ }.each do |option, value|
73
+ it "sends #{option}" do
74
+ expect_body({ text: ['Hello'], source_lang: 'EN', target_lang: 'DE', option => value })
75
+ DeepL.translate('Hello', 'EN', 'DE', option => value)
76
+ end
77
+ end
78
+ end
79
+
80
+ describe 'option conversions' do
81
+ { true => '1', false => '0', 'nonewlines' => 'nonewlines' }.each do |value, sent|
82
+ it "sends split_sentences #{value.inspect} as #{sent.inspect}" do
83
+ expect_body(text: ['a'], target_lang: 'DE', split_sentences: sent)
84
+ DeepL.translate('a', nil, 'DE', split_sentences: value)
85
+ end
86
+ end
87
+
88
+ it 'converts split_sentences false given with a string key' do
89
+ expect_body(text: ['a'], target_lang: 'DE', split_sentences: '0')
90
+ DeepL.translate('a', nil, 'DE', 'split_sentences' => false)
91
+ end
92
+
93
+ { '1' => true, '0' => false, true => true }.each do |value, sent|
94
+ %i[preserve_formatting outline_detection].each do |option|
95
+ it "sends #{option} #{value.inspect} as #{sent.inspect}" do
96
+ expect_body({ text: ['a'], target_lang: 'DE', option => sent })
97
+ DeepL.translate('a', nil, 'DE', option => value)
98
+ end
99
+ end
100
+ end
101
+
102
+ it 'sends a custom_instructions string as one instruction' do
103
+ expect_body(text: ['a'], target_lang: 'DE', custom_instructions: ['Be formal, and short'])
104
+ DeepL.translate('a', nil, 'DE', custom_instructions: 'Be formal, and short')
105
+ end
106
+
107
+ it 'sends a custom_instructions array unchanged' do
108
+ expect_body(text: ['a'], target_lang: 'DE', custom_instructions: %w[x y])
109
+ DeepL.translate('a', nil, 'DE', custom_instructions: %w[x y])
110
+ end
111
+
112
+ %i[splitting_tags non_splitting_tags ignore_tags].each do |option|
113
+ it "sends a comma separated #{option} string as an array" do
114
+ expect_body({ text: ['a'], target_lang: 'DE', option => %w[x y] })
115
+ DeepL.translate('a', nil, 'DE', option => 'x,y')
116
+ end
117
+
118
+ it "sends a #{option} array unchanged" do
119
+ expect_body({ text: ['a'], target_lang: 'DE', option => %w[x y] })
120
+ DeepL.translate('a', nil, 'DE', option => %w[x y])
121
+ end
122
+ end
123
+ end
124
+
125
+ describe 'style rules and translation memories' do
126
+ let(:base) { { text: ['a'], source_lang: 'EN', target_lang: 'DE' } }
127
+
128
+ it 'sends a style rule ID as style_id' do
129
+ expect_body(base.merge(style_id: 's-1'))
130
+ DeepL.translate('a', 'EN', 'DE', style_rule: 's-1')
131
+ end
132
+
133
+ it 'sends the ID of a style rule object as style_id' do
134
+ expect_body(base.merge(style_id: 's-2'))
135
+ rule = DeepL::Resources::StyleRule.new({ 'style_id' => 's-2' }, nil, nil)
136
+ DeepL.translate('a', 'EN', 'DE', style_rule: rule)
137
+ end
138
+
139
+ it 'sends no style_id when the style rule is nil' do
140
+ expect_body(base)
141
+ DeepL.translate('a', 'EN', 'DE', style_rule: nil)
142
+ end
143
+
144
+ it 'sends a style profile ID as style_profile_id' do
145
+ expect_body(base.merge(style_profile_id: 'sp-1'))
146
+ DeepL.translate('a', 'EN', 'DE', style_profile: 'sp-1')
147
+ end
148
+
149
+ it 'sends the ID of a style profile object as style_profile_id' do
150
+ expect_body(base.merge(style_profile_id: 'sp-2'))
151
+ profile = DeepL::Resources::StyleProfile.new({ 'style_profile_id' => 'sp-2' }, nil, nil)
152
+ DeepL.translate('a', 'EN', 'DE', style_profile: profile)
153
+ end
154
+
155
+ it 'sends no style_profile_id when the option is not set' do
156
+ expect_body(base)
157
+ DeepL.translate('a', 'EN', 'DE')
158
+ end
159
+
160
+ it 'sends no style_profile_id when the option is nil' do
161
+ expect_body(base)
162
+ DeepL.translate('a', 'EN', 'DE', style_profile: nil)
163
+ end
164
+
165
+ it 'sends a translation memory ID with its threshold' do
166
+ expect_body(base.merge(translation_memory_id: 'tm-1', translation_memory_threshold: 80))
167
+ DeepL.translate('a', 'EN', 'DE', translation_memory: 'tm-1',
168
+ translation_memory_threshold: 80)
169
+ end
170
+
171
+ it 'sends the ID of a translation memory object' do
172
+ memory = DeepL::Resources::TranslationMemory.new({ 'translation_memory_id' => 'tm-2' },
173
+ nil, nil)
174
+ expect_body(base.merge(translation_memory_id: 'tm-2'))
175
+ DeepL.translate('a', 'EN', 'DE', translation_memory: memory)
176
+ end
177
+ end
178
+
179
+ describe 'glossary' do
180
+ let(:base) { { text: ['a'], source_lang: 'EN', target_lang: 'DE' } }
181
+
182
+ it 'sends a glossary ID as glossary_id' do
183
+ expect_body(base.merge(glossary_id: 'g-1'))
184
+ DeepL.translate('a', 'EN', 'DE', glossary: 'g-1')
185
+ end
186
+
187
+ it 'sends the ID of a glossary or multilingual glossary object as glossary_id' do
188
+ glossary = DeepL::Resources::Glossary.new({ 'glossary_id' => 'g-2' }, nil, nil)
189
+ multilingual = DeepL::Resources::MultilingualGlossary.new({ 'glossary_id' => 'g-3' }, nil,
190
+ nil)
191
+ expect_body(base.merge(glossary_id: 'g-2'))
192
+ expect_body(base.merge(glossary_id: 'g-3'))
193
+
194
+ DeepL.translate('a', 'EN', 'DE', glossary: glossary)
195
+ DeepL.translate('a', 'EN', 'DE', 'glossary' => multilingual)
196
+ end
197
+
198
+ it 'raises ArgumentError together with glossary_id or glossary_ids' do
199
+ expect { DeepL.translate('a', 'EN', 'DE', glossary: 'g-1', glossary_id: 'g-2') }
200
+ .to raise_error(ArgumentError, /`glossary` option cannot be used together/)
201
+ expect { DeepL.translate('a', 'EN', 'DE', glossary: 'g-1', glossary_ids: ['g-2']) }
202
+ .to raise_error(ArgumentError, /together with the `glossary` option/)
203
+ end
204
+ end
205
+
206
+ describe 'result' do
207
+ def stub_translation(translation)
208
+ stub_request(:post, url('/v2/translate')).to_return(reply(translations: [translation]))
209
+ end
210
+
211
+ it 'parses billed_characters and tag_handling_version' do
212
+ stub_translation(text: 'Hallo', detected_source_language: 'EN', billed_characters: 5,
213
+ tag_handling_version: 'v2')
214
+
215
+ result = DeepL.translate('Hello', nil, 'DE', tag_handling: 'xml')
216
+ expect([result.billed_characters, result.tag_handling_version]).to eq([5, 'v2'])
217
+ end
218
+
219
+ it 'leaves malformed or missing fields nil and keeps the other fields' do
220
+ stub_translation(text: 'Hallo', detected_source_language: 'EN', billed_characters: 'five',
221
+ tag_handling_version: 2)
222
+
223
+ result = DeepL.translate('Hello', nil, 'DE')
224
+ expect([result.text, result.detected_source_language, result.billed_characters,
225
+ result.tag_handling_version]).to eq(['Hallo', 'EN', nil, nil])
226
+ end
227
+ end
228
+
229
+ describe 'glossary_ids' do
230
+ let(:base) { { text: ['a'], source_lang: 'EN', target_lang: 'DE' } }
231
+
232
+ it 'sends glossary IDs, glossary objects and multilingual glossary objects as IDs' do
233
+ glossary = DeepL::Resources::Glossary.new({ 'glossary_id' => 'g-2' }, nil, nil)
234
+ multilingual = DeepL::Resources::MultilingualGlossary.new({ 'glossary_id' => 'g-3' }, nil,
235
+ nil)
236
+ expect_body(base.merge(glossary_ids: %w[g-1 g-2 g-3]))
237
+
238
+ DeepL.translate('a', 'EN', 'DE', glossary_ids: ['g-1', glossary, multilingual])
239
+ end
240
+
241
+ it 'sends glossary_ids without a source language' do
242
+ expect_body(text: ['a'], target_lang: 'DE', glossary_ids: ['g-1'])
243
+
244
+ DeepL.translate('a', nil, 'DE', glossary_ids: ['g-1'])
245
+ end
246
+
247
+ it 'raises ArgumentError together with glossary_id' do
248
+ expect { DeepL.translate('a', 'EN', 'DE', glossary_ids: ['g-1'], glossary_id: 'g-2') }
249
+ .to raise_error(ArgumentError, /cannot be used together/)
250
+ end
251
+
252
+ it 'raises ArgumentError for more than 5 glossaries' do
253
+ expect { DeepL.translate('a', 'EN', 'DE', glossary_ids: %w[1 2 3 4 5 6]) }
254
+ .to raise_error(ArgumentError, /maximum of 5/)
255
+ end
256
+ end
257
+
258
+ describe 'extra_body_parameters' do
259
+ it 'keeps the type of each value' do
260
+ extra = { flag: true, off: false, count: 42, label: 'x', list: [1, 'a'], nested: { a: 1 } }
261
+ expect_body({ text: ['a'], target_lang: 'DE' }.merge(extra))
262
+
263
+ DeepL.translate('a', nil, 'DE', extra_body_parameters: extra)
264
+ end
265
+
266
+ it 'adds new parameters and overrides built-in ones' do
267
+ expect_body(text: ['a'], target_lang: 'FR', beta_feature: 'on')
268
+ DeepL.translate('a', nil, 'DE', extra_body_parameters: { target_lang: 'FR',
269
+ beta_feature: 'on' })
270
+ end
271
+
272
+ it 'overrides an option of the same name' do
273
+ expect_body(text: ['a'], target_lang: 'DE', formality: 'more')
274
+ DeepL.translate('a', nil, 'DE', formality: 'less',
275
+ extra_body_parameters: { formality: 'more' })
276
+ end
277
+
278
+ it 'replaces a built-in parameter given with a string key instead of sending it twice' do
279
+ stub_request(:post, url('/v2/translate'))
280
+ .with { |request| request.body.scan('"target_lang"').size == 1 }
281
+ .to_return(translation_reply)
282
+
283
+ DeepL.translate('a', nil, 'DE', extra_body_parameters: { 'target_lang' => 'FR' })
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,256 @@
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::Client translation memories' do
9
+ let(:client) { DeepL::Client.new('client-key', server_url: 'https://client.example.test') }
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) do
13
+ DeepL::Resources::TranslationMemory.new({ 'translation_memory_id' => 'tm-1' }, nil, nil)
14
+ end
15
+ let(:memory_json) do
16
+ { translation_memory_id: 'tm-1', name: 'memory', source_language: 'de',
17
+ target_languages: ['en'], segment_count: 3 }
18
+ end
19
+
20
+ def url(path)
21
+ "https://client.example.test#{path}"
22
+ end
23
+
24
+ def json_headers
25
+ { 'Authorization' => 'DeepL-Auth-Key client-key', 'Content-Type' => 'application/json' }
26
+ end
27
+
28
+ def job_json(status, **result)
29
+ { job_id: 'j-1', product: 'translation_memory', operation: 'export',
30
+ results: [{ status: status }.merge(result)] }
31
+ end
32
+
33
+ def stub_job(*replies)
34
+ stub_request(:get, url('/v3/translation_memories/jobs/j-1')).with(headers: json_headers)
35
+ .to_return(*replies)
36
+ end
37
+
38
+ describe '#list_translation_memories' do
39
+ it 'sends no query by default' do
40
+ stub_request(:get, url('/v3/translation_memories')).with(headers: json_headers)
41
+ .to_return(reply(translation_memories:
42
+ [memory_json]))
43
+
44
+ expect(client.list_translation_memories.map(&:translation_memory_id)).to eq(['tm-1'])
45
+ end
46
+
47
+ it 'sends page and page_size in the query' do
48
+ stub_request(:get, url('/v3/translation_memories?page=1&page_size=5'))
49
+ .to_return(reply(translation_memories: []))
50
+
51
+ client.list_translation_memories(page: 1, page_size: 5)
52
+ end
53
+ end
54
+
55
+ describe 'per-memory operations' do
56
+ it 'sends get and delete for an ID or a TranslationMemory' do
57
+ stub_request(:get, url('/v3/translation_memories/tm-1')).with(headers: json_headers)
58
+ .to_return(reply(memory_json))
59
+ stub_request(:delete, url('/v3/translation_memories/tm-1')).to_return(status: 204)
60
+
61
+ expect(client.get_translation_memory(memory).name).to eq('memory')
62
+ expect(client.delete_translation_memory('tm-1')).to be_nil
63
+ end
64
+
65
+ it 'sends the segment options in the query' do
66
+ query = 'page_size=5&page_cursor=c-1&filter_text=a%20b&filter_case_sensitive=false'
67
+ stub_request(:get, url("/v3/translation_memories/tm-1/segments?#{query}"))
68
+ .with(headers: json_headers).to_return(reply(segment_count: 1, segments: []))
69
+
70
+ page = client.list_translation_memory_segments(memory, page_size: 5, page_cursor: 'c-1',
71
+ filter_text: 'a b',
72
+ filter_case_sensitive: false)
73
+
74
+ expect(page.segment_count).to eq(1)
75
+ end
76
+ end
77
+
78
+ describe 'import' do
79
+ include_context 'with a temporary directory'
80
+
81
+ it 'sends the import job with the content type, display name and extra body parameters' do # rubocop:disable RSpec/ExampleLength
82
+ stub_request(:post, url('/v3/translation_memories/import'))
83
+ .with(body: { source_file: { file_name: 'a.tmx', content_length: 12,
84
+ content_type: 'text/xml' },
85
+ parameters: { display_name: 'Legal' }, extra: true },
86
+ headers: json_headers)
87
+ .to_return(reply({ job_id: 'j-1', upload_url: upload_url }, 201))
88
+
89
+ created = client.create_translation_memory_import('a.tmx', 12, content_type: 'text/xml',
90
+ display_name: 'Legal',
91
+ extra_body_parameters:
92
+ { extra: true })
93
+
94
+ expect(created.upload_url).to eq(upload_url)
95
+ end
96
+
97
+ it 'uploads the file without credentials, by default as application/xml' do
98
+ no_authorization = ->(request) { !request.headers.key?('Authorization') }
99
+ stub_request(:put, upload_url)
100
+ .with(body: 'tmx', headers: { 'Content-Type' => 'application/xml' }, &no_authorization)
101
+ .to_return(status: 200)
102
+ stub_request(:put, upload_url).with(headers: { 'Content-Type' => 'text/xml' })
103
+ .to_return(status: 200)
104
+
105
+ expect(client.upload_translation_memory_file(upload_url, 'tmx')).to be_nil
106
+ client.upload_translation_memory_file(upload_url, 'tmx', content_type: 'text/xml')
107
+ end
108
+
109
+ it 'raises a plain Error with the status for a storage reply of 403' do
110
+ stub_request(:put, upload_url).to_return(status: 403, body: 'Request has expired')
111
+
112
+ expect { client.upload_translation_memory_file(upload_url, 'tmx') }.to(raise_error do |error|
113
+ expect([error.class, error.http_status_code, error.should_retry?])
114
+ .to eq([DeepL::Exceptions::Error, 403, false])
115
+ end)
116
+ end
117
+
118
+ it 'imports a file: creates the job, uploads with the content type and waits' do # rubocop:disable RSpec/ExampleLength
119
+ path = File.join(temp_dir, 'memory.tmx')
120
+ File.write(path, 'tmx')
121
+ stub_request(:post, url('/v3/translation_memories/import'))
122
+ .with(body: { source_file: { file_name: 'memory.tmx', content_length: 3,
123
+ content_type: 'text/xml' },
124
+ parameters: { display_name: 'Legal' }, extra: 'x' })
125
+ .to_return(reply({ job_id: 'j-1', upload_url: upload_url }, 201))
126
+ stub_request(:put, upload_url).with(body: 'tmx', headers: { 'Content-Type' => 'text/xml' })
127
+ .to_return(status: 200)
128
+ stub_job(reply(job_json('completed', translation_memory_id: 'tm-2')))
129
+
130
+ job = client.import_translation_memory_from_filepath(
131
+ path, display_name: 'Legal', content_type: 'text/xml', timeout_s: 10,
132
+ extra_body_parameters: { extra: 'x' }
133
+ )
134
+
135
+ expect([job.done?, job.result.translation_memory_id]).to eq([true, 'tm-2'])
136
+ end
137
+
138
+ it 'raises ArgumentError for a missing import file without sending a request' do
139
+ expect { client.import_translation_memory_from_filepath(File.join(temp_dir, 'missing.tmx')) }
140
+ .to raise_error(ArgumentError, /No file found/)
141
+ end
142
+
143
+ it 'raises Error with the cause for an unreadable import file without sending a request' do
144
+ expect { client.import_translation_memory_from_filepath(temp_dir) }
145
+ .to raise_error(DeepL::Exceptions::Error, /Could not read/) { |error|
146
+ expect(error.cause).to be_a(Errno::EISDIR)
147
+ }
148
+ end
149
+ end
150
+
151
+ describe 'export' do
152
+ include_context 'with a temporary directory'
153
+
154
+ let(:output) { File.join(temp_dir, 'export.tmx') }
155
+
156
+ it 'creates the export, gets the job and downloads the file' do
157
+ stub_request(:post, url('/v3/translation_memories/tm-1/export'))
158
+ .with(body: '{}', headers: json_headers)
159
+ .to_return(reply({ job_id: 'j-1', parameters: { translation_memory_id: 'tm-1' } }, 202))
160
+ stub_job(reply(job_json('completed', download_url: download_url)))
161
+ stub_request(:get, download_url).to_return(status: 200, body: 'tmx')
162
+
163
+ export = client.create_translation_memory_export(memory)
164
+ job = client.get_translation_memory_job(export.job_id)
165
+
166
+ expect([export.translation_memory_id, export.reused_existing?]).to eq(['tm-1', false])
167
+ expect(client.download_translation_memory_export(job, output)).to be_nil
168
+ expect(File.read(output)).to eq('tmx')
169
+ end
170
+
171
+ it 'exports to a file: creates the export, waits and downloads' do
172
+ stub_request(:post, url('/v3/translation_memories/tm-1/export'))
173
+ .to_return(reply({ job_id: 'j-1' }, 200))
174
+ stub_job(reply(job_json('completed', download_url: download_url)))
175
+ stub_request(:get, download_url).to_return(status: 200, body: 'tmx')
176
+
177
+ job = client.export_translation_memory_to_filepath('tm-1', output, timeout_s: 10)
178
+
179
+ expect([job.status, File.read(output)]).to eq(%w[completed tmx])
180
+ end
181
+
182
+ it 'raises Error for a missing output directory before creating the export job' do
183
+ missing = File.join(temp_dir, 'missing', 'x.tmx')
184
+
185
+ expect { client.export_translation_memory_to_filepath('tm-1', missing) }
186
+ .to raise_error(DeepL::Exceptions::Error, /does not exist/)
187
+ end
188
+
189
+ it 'raises Error with the cause when the file cannot be written' do
190
+ stub_request(:get, download_url).to_return(status: 200, body: 'tmx')
191
+
192
+ expect do
193
+ client.download_translation_memory_export(download_url,
194
+ File.join(temp_dir, 'missing', 'x.tmx'))
195
+ end.to raise_error(DeepL::Exceptions::Error, /Could not write/) { |error|
196
+ expect(error.cause).to be_a(Errno::ENOENT)
197
+ }
198
+ end
199
+ end
200
+
201
+ describe '#wait_until_translation_memory_job_done' do
202
+ it 'returns the completed job for an ID or a job' do
203
+ stub = stub_job(reply(job_json('completed')))
204
+ job = DeepL::Resources::TranslationMemoryJob.new({ 'job_id' => 'j-1' }, nil, nil)
205
+
206
+ expect(client.wait_until_translation_memory_job_done(job).status).to eq('completed')
207
+ expect(stub).to have_been_requested.once
208
+ end
209
+
210
+ it 'raises Error with the API message when the job failed' do
211
+ stub_job(reply(job_json('failed', error: { message: 'bad file' })))
212
+
213
+ expect { client.wait_until_translation_memory_job_done('j-1') }
214
+ .to raise_error(DeepL::Exceptions::Error, /bad file/)
215
+ end
216
+
217
+ it 'raises Error when timeout_s expires' do
218
+ stub_job(reply(job_json('processing')))
219
+
220
+ expect { client.wait_until_translation_memory_job_done('j-1', timeout_s: 0) }
221
+ .to raise_error(DeepL::Exceptions::Error, /timeout of 0s exceeded/)
222
+ end
223
+ end
224
+
225
+ describe 'storage connection' do
226
+ it 'uses the proxy of the client for the pre-signed URLs' do
227
+ client = DeepL::Client.new('client-key', server_url: 'https://client.example.test',
228
+ proxy: 'http://proxy.example.test:8080')
229
+ stub_request(:put, upload_url).to_return(status: 200)
230
+
231
+ expect(Net::HTTP).to receive(:new)
232
+ .with('storage.example.test', 443, 'proxy.example.test', 8080, nil, nil)
233
+ .and_call_original
234
+
235
+ client.upload_translation_memory_file(upload_url, 'tmx')
236
+ end
237
+
238
+ it 'applies the timeouts and SSL verification of the http_client_options' do # rubocop:disable RSpec/ExampleLength
239
+ options = DeepL::HTTPClientOptions.new({}, nil, enable_ssl_verification: false,
240
+ read_timeout: 33)
241
+ client = DeepL::Client.new('client-key', server_url: 'https://client.example.test',
242
+ http_client_options: options)
243
+ connections = []
244
+ allow(Net::HTTP).to receive(:new).and_wrap_original do |original, *args|
245
+ original.call(*args).tap { |connection| connections << connection }
246
+ end
247
+ stub_request(:get, download_url).to_return(status: 200, body: 'tmx')
248
+
249
+ client.download_translation_memory_export(download_url, File::NULL)
250
+
251
+ expect(connections.last)
252
+ .to have_attributes(address: 'storage.example.test', read_timeout: 33,
253
+ verify_mode: OpenSSL::SSL::VERIFY_NONE)
254
+ end
255
+ end
256
+ end