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,606 @@
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 'open3'
7
+ require_relative 'offline_helper'
8
+
9
+ describe DeepL::DocumentApi do
10
+ let(:documents) { DeepL.document }
11
+ let(:handle) { DeepL::Resources::DocumentHandle.new('doc-1', 'key-1') }
12
+ let(:upload_reply) { reply(document_id: 'doc-1', document_key: 'key-1') }
13
+ let(:document_key_body) { { document_key: 'key-1' } }
14
+
15
+ include_context 'with a temporary directory'
16
+
17
+ def stub_upload
18
+ stub_request(:post, url('/v2/document')).with(
19
+ headers: { 'Authorization' => json_headers['Authorization'],
20
+ 'Content-Type' => %r{\Amultipart/form-data} }
21
+ )
22
+ end
23
+
24
+ # Net::HTTP writes multipart parts straight to the socket, so WebMock sees no request body.
25
+ # The form fields are read from the argument of `set_form` instead.
26
+ def capture_forms
27
+ forms = []
28
+ allow(Net::HTTP::Post).to receive(:new).and_wrap_original do |original, *args|
29
+ original.call(*args).tap do |request|
30
+ allow(request).to receive(:set_form).and_wrap_original do |set_form, form, *rest|
31
+ forms << form
32
+ set_form.call(form, *rest)
33
+ end
34
+ end
35
+ end
36
+ forms
37
+ end
38
+
39
+ # Uploads a text document with the given options and returns the form that was sent.
40
+ def sent_form(filename = nil, **options)
41
+ input, = create_text_document(temp_dir)
42
+ stub_upload.to_return(upload_reply)
43
+ forms = capture_forms
44
+ documents.upload(input, 'EN', 'DE', filename, **options)
45
+ forms.first
46
+ end
47
+
48
+ def resource_options
49
+ resources = DeepL::Resources
50
+ memory = resources::TranslationMemory.new({ 'translation_memory_id' => 'tm-obj' }, nil, nil)
51
+ glossary = resources::Glossary.new({ 'glossary_id' => 'g-obj' }, nil, nil)
52
+ multilingual = resources::MultilingualGlossary.new({ 'glossary_id' => 'g-ml', 'name' => 'n' },
53
+ nil, nil)
54
+ { style_rule: resources::StyleRule.new({ 'style_id' => 's-obj' }, nil, nil),
55
+ style_profile: resources::StyleProfile.new({ 'style_profile_id' => 'sp-obj' }, nil, nil),
56
+ translation_memory: memory, glossary_ids: [glossary, multilingual, 'g-str'] }
57
+ end
58
+
59
+ def form_fields(form)
60
+ form.reject { |name, _| name == 'file' }.to_h
61
+ end
62
+
63
+ def stub_status(*statuses)
64
+ stub_request(:post, url('/v2/document/doc-1')).with(body: document_key_body,
65
+ headers: json_headers)
66
+ .to_return(*statuses)
67
+ end
68
+
69
+ def status_reply(status, extra = {})
70
+ reply({ document_id: 'doc-1', status: status }.merge(extra))
71
+ end
72
+
73
+ describe '#upload' do
74
+ it 'raises Errno::ENOENT for a missing input file' do
75
+ expect { documents.upload(File.join(temp_dir, 'missing.txt'), nil, 'DE') }
76
+ .to raise_error(Errno::ENOENT)
77
+ end
78
+
79
+ it 'sends a multipart POST with the credentials and the file, languages and filename' do
80
+ input, = create_text_document(temp_dir)
81
+
82
+ form = sent_form
83
+
84
+ expect(form.assoc('file').last.path).to eq(input)
85
+ expect(form_fields(form)).to eq('source_lang' => 'EN', 'target_lang' => 'DE',
86
+ 'filename' => 'example_document.txt')
87
+ end
88
+
89
+ it 'sends no source_lang when the source language is nil' do
90
+ input, = create_text_document(temp_dir)
91
+ stub_upload.to_return(upload_reply)
92
+ forms = capture_forms
93
+
94
+ documents.upload(input, nil, 'DE')
95
+
96
+ expect(forms.first.assoc('file').last.path).to eq(input)
97
+ expect(form_fields(forms.first)).to eq('target_lang' => 'DE',
98
+ 'filename' => 'example_document.txt')
99
+ end
100
+
101
+ it 'sends every option as its own field, serializing IDs, the threshold and glossary_ids' do
102
+ form = sent_form('name.txt', formality: 'more', output_format: 'txt',
103
+ glossary_ids: %w[g-1 g-2], style_rule: 's-1',
104
+ style_profile: 'sp-1', translation_memory: 'tm-1',
105
+ translation_memory_threshold: 80)
106
+
107
+ expect(form_fields(form)).to eq(
108
+ 'source_lang' => 'EN', 'target_lang' => 'DE', 'filename' => 'name.txt',
109
+ 'formality' => 'more', 'output_format' => 'txt', 'glossary_ids' => 'g-1,g-2',
110
+ 'style_id' => 's-1', 'style_profile_id' => 'sp-1', 'translation_memory_id' => 'tm-1',
111
+ 'translation_memory_threshold' => '80'
112
+ )
113
+ end
114
+
115
+ it 'sends enable_watermark as true or false, combined with output_format' do
116
+ expect(form_fields(sent_form(enable_watermark: true, output_format: 'pdf')))
117
+ .to include('enable_watermark' => 'true', 'output_format' => 'pdf')
118
+ expect(form_fields(sent_form(enable_watermark: false)))
119
+ .to include('enable_watermark' => 'false')
120
+ end
121
+
122
+ it 'does not send enable_watermark when it is not set' do
123
+ expect(form_fields(sent_form)).not_to have_key('enable_watermark')
124
+ end
125
+
126
+ it 'does not send style_profile_id when it is not set' do
127
+ expect(form_fields(sent_form(style_rule: 's-1'))).not_to have_key('style_profile_id')
128
+ end
129
+
130
+ it 'does not send style_profile_id when it is nil' do
131
+ expect(form_fields(sent_form(style_profile: nil))).not_to have_key('style_profile_id')
132
+ end
133
+
134
+ it 'does not send style_id when style_rule is nil' do
135
+ expect(form_fields(sent_form(style_rule: nil))).not_to have_key('style_id')
136
+ end
137
+
138
+ it 'takes the IDs of style rule, style profile, translation memory and glossary objects' do
139
+ form = sent_form(**resource_options)
140
+
141
+ expect(form_fields(form)).to include(
142
+ 'style_id' => 's-obj', 'style_profile_id' => 'sp-obj', 'translation_memory_id' => 'tm-obj',
143
+ 'glossary_ids' => 'g-obj,g-ml,g-str'
144
+ )
145
+ end
146
+
147
+ it 'does not send translation_memory_id or the threshold when they are nil' do
148
+ expect(form_fields(sent_form(translation_memory: nil, translation_memory_threshold: nil)))
149
+ .to eq('source_lang' => 'EN', 'target_lang' => 'DE', 'filename' => 'example_document.txt')
150
+ end
151
+
152
+ it 'sends the glossary option, an ID or a glossary object, as glossary_id' do
153
+ glossary = DeepL::Resources::MultilingualGlossary.new({ 'glossary_id' => 'g-ml',
154
+ 'name' => 'n' }, nil, nil)
155
+ expect(form_fields(sent_form(glossary: 'g-1'))).to include('glossary_id' => 'g-1')
156
+ expect(form_fields(sent_form(glossary: glossary))).to include('glossary_id' => 'g-ml')
157
+ end
158
+
159
+ it 'treats glossary_id: nil as unset next to glossary or glossary_ids' do
160
+ expect(form_fields(sent_form(glossary: 'g-1', glossary_id: nil)))
161
+ .to include('glossary_id' => 'g-1')
162
+ expect(form_fields(sent_form(glossary_ids: %w[g-1 g-2], glossary_id: nil)))
163
+ .to include('glossary_ids' => 'g-1,g-2').and(satisfy { |f| !f.key?('glossary_id') })
164
+ end
165
+
166
+ it 'lets extra_body_parameters add a field and override an existing one' do
167
+ form = sent_form(formality: 'more', extra_body_parameters: { formality: 'less', beta: true })
168
+
169
+ expect(form.count { |name, _| name == 'formality' }).to eq(1)
170
+ expect(form_fields(form)).to include('formality' => 'less', 'beta' => 'true')
171
+ end
172
+
173
+ it 'raises ArgumentError for invalid glossary_ids without sending a request' do
174
+ input, = create_text_document(temp_dir)
175
+
176
+ expect { documents.upload(input, nil, 'DE', nil, glossary_ids: ['g-1']) }
177
+ .to raise_error(ArgumentError, /requires `source_lang`/)
178
+ expect { documents.upload(input, 'EN', 'DE', nil, glossary_ids: ['g-1'], glossary_id: 'g') }
179
+ .to raise_error(ArgumentError, /cannot be used together/)
180
+ expect { documents.upload(input, 'EN', 'DE', nil, glossary_ids: %w[1 2 3 4 5 6]) }
181
+ .to raise_error(ArgumentError, /maximum of 5/)
182
+ end
183
+
184
+ it 'retries after a 429 reply' do
185
+ input, = create_text_document(temp_dir)
186
+ stub = stub_upload.to_return(reply({}, 429), upload_reply)
187
+
188
+ documents.upload(input, 'EN', 'DE')
189
+
190
+ expect(stub).to have_been_requested.twice
191
+ end
192
+ end
193
+
194
+ describe '#get_status' do
195
+ it 'sends the document key and derives that a translating document is not finished' do
196
+ stub_status(status_reply('translating', seconds_remaining: 3, billed_characters: 11,
197
+ error_message: 'none'))
198
+
199
+ status = documents.get_status(handle)
200
+
201
+ expect(status.finished?).to be(false)
202
+ end
203
+
204
+ it 'parses done as done and error as failed' do
205
+ stub_status(status_reply('done'), status_reply('error'))
206
+
207
+ statuses = Array.new(2) { documents.get_status(handle) }
208
+
209
+ expect(statuses.map { |s| [s.done?, s.error?, s.finished?] })
210
+ .to eq([[true, false, true], [false, true, true]])
211
+ end
212
+
213
+ it 'derives ok? as not failed, also while queued or translating' do
214
+ codes = DeepL::Constants::DocumentStatusCode
215
+ stub_status(*[codes::QUEUED, codes::TRANSLATING, codes::DONE, codes::ERROR].map do |code|
216
+ status_reply(code)
217
+ end)
218
+
219
+ statuses = Array.new(4) { documents.get_status(handle) }
220
+
221
+ expect(statuses.map { |s| [s.status, s.ok?] }).to eq(
222
+ [['queued', true], ['translating', true], ['done', true], ['error', false]]
223
+ )
224
+ end
225
+
226
+ it 'URL-encodes the document ID in the path' do
227
+ stub_request(:post, url('/v2/document/a%20b%2Fc')).to_return(status_reply('done'))
228
+
229
+ documents.get_status(DeepL::Resources::DocumentHandle.new('a b/c', 'key-1', nil, nil))
230
+ end
231
+ end
232
+
233
+ describe '#download' do
234
+ it 'writes the response body to the output file byte for byte' do
235
+ output = File.join(temp_dir, 'result.docx')
236
+ stub_request(:post, url('/v2/document/doc-1/result'))
237
+ .with(body: document_key_body, headers: json_headers)
238
+ .to_return(status: 200, body: Fixtures::BINARY_CONTENT)
239
+
240
+ documents.download(handle, output)
241
+
242
+ expect(File.binread(output)).to eq(Fixtures::BINARY_CONTENT)
243
+ end
244
+
245
+ it 'writes the bytes unchanged when Encoding.default_internal is set' do
246
+ output = File.join(temp_dir, 'result.docx')
247
+ stub_request(:post, url('/v2/document/doc-1/result'))
248
+ .to_return(status: 200, body: Fixtures::BINARY_CONTENT)
249
+ previous = Encoding.default_internal
250
+ Encoding.default_internal = Encoding::UTF_8
251
+
252
+ documents.download(handle, output)
253
+
254
+ expect(File.binread(output)).to eq(Fixtures::BINARY_CONTENT)
255
+ ensure
256
+ Encoding.default_internal = previous
257
+ end
258
+
259
+ it 'URL-encodes the document ID in the path' do
260
+ stub_request(:post, url('/v2/document/a%20b%2Fc/result')).to_return(status: 200, body: 'x')
261
+
262
+ documents.download(DeepL::Resources::DocumentHandle.new('a b/c', 'key-1', nil, nil),
263
+ File.join(temp_dir, 'result.txt'))
264
+ end
265
+
266
+ it 'raises DocumentNotReadyError, a ServerError, for a 503 reply' do
267
+ stub = stub_request(:post, url('/v2/document/doc-1/result'))
268
+ .to_return(reply({ message: 'not ready' }, 503))
269
+ DeepL.configuration.max_network_retries = 0
270
+
271
+ expect { documents.download(handle, File.join(temp_dir, 'result.txt')) }
272
+ .to raise_error(DeepL::Exceptions::DocumentNotReadyError, /not ready/) { |e|
273
+ expect([e, e.http_status_code]).to match([be_a(DeepL::Exceptions::ServerError), 503])
274
+ }
275
+ expect(stub).to have_been_requested.once
276
+ end
277
+ end
278
+
279
+ describe DeepL::Resources::DocumentHandle, '#wait_until_document_translation_finished' do
280
+ it 'polls the status until the translation is done' do
281
+ stub = stub_status(status_reply('queued'), status_reply('done'))
282
+
283
+ status = handle.wait_until_document_translation_finished
284
+
285
+ expect(stub).to have_been_requested.twice
286
+ expect(status.done?).to be(true)
287
+ end
288
+
289
+ it 'polls through the connection and configuration the handle was created with' do
290
+ configuration = DeepL::Configuration.new(auth_key: 'other', host: 'https://other.example.test',
291
+ max_doc_status_queries: 0)
292
+ other = DeepL::DocumentApi.new(DeepL::API.new(configuration))
293
+ stub_request(:post, 'https://other.example.test/v2/document').to_return(upload_reply)
294
+ stub = stub_request(:post, 'https://other.example.test/v2/document/doc-1')
295
+ .with(headers: { 'Authorization' => 'DeepL-Auth-Key other' })
296
+ .to_return(status_reply('queued'))
297
+ input, = create_text_document(temp_dir)
298
+
299
+ other.upload(input, 'EN', 'DE').wait_until_document_translation_finished
300
+
301
+ expect(stub).to have_been_requested.once
302
+ end
303
+
304
+ it 'does not show the auth key in inspect' do
305
+ configuration = DeepL::Configuration.new(auth_key: 'sdktest-secret-key')
306
+ other = described_class.new('doc-1', 'key-1', nil, nil,
307
+ api: DeepL::API.new(configuration))
308
+
309
+ expect(other.inspect).not_to include('sdktest-secret-key')
310
+ expect(configuration.inspect).not_to include('sdktest-secret-key')
311
+ end
312
+
313
+ it 'stops after max_doc_status_queries polls' do
314
+ DeepL.configuration.max_doc_status_queries = 0
315
+ stub = stub_status(status_reply('queued'))
316
+
317
+ status = handle.wait_until_document_translation_finished
318
+
319
+ expect(stub).to have_been_requested.once
320
+ expect(status.finished?).to be(false)
321
+ end
322
+ end
323
+
324
+ describe '#translate_document' do
325
+ it 'uploads, waits and writes the translated document' do
326
+ input, output = create_text_document(temp_dir)
327
+ stub_upload.to_return(upload_reply)
328
+ stub_status(status_reply('done'))
329
+ stub_request(:post, url('/v2/document/doc-1/result'))
330
+ .with(body: document_key_body).to_return(status: 200, body: 'Protonenstrahl')
331
+
332
+ documents.translate_document(input, output, 'EN', 'DE')
333
+
334
+ expect(File.read(output)).to eq('Protonenstrahl')
335
+ end
336
+
337
+ it 'passes enable_watermark on to the upload' do
338
+ input, output = create_text_document(temp_dir)
339
+ stub_upload.to_return(upload_reply)
340
+ stub_status(status_reply('done'))
341
+ stub_request(:post, url('/v2/document/doc-1/result')).to_return(status: 200, body: 'x')
342
+ forms = capture_forms
343
+
344
+ documents.translate_document(input, output, 'EN', 'DE', nil, enable_watermark: true)
345
+
346
+ expect(form_fields(forms.first)).to include('enable_watermark' => 'true')
347
+ end
348
+
349
+ it 'returns the number of bytes written' do
350
+ input, output = create_text_document(temp_dir)
351
+ stub_upload.to_return(upload_reply)
352
+ stub_status(status_reply('done', billed_characters: 11))
353
+ stub_request(:post, url('/v2/document/doc-1/result')).to_return(status: 200, body: 'x')
354
+
355
+ expect(documents.translate_document(input, output, 'EN', 'DE')).to eq(1)
356
+ end
357
+
358
+ it 'polls until the translation is done, beyond the former limit of 11 status requests' do
359
+ allow(DeepL::Utils::DocumentPoller).to receive(:sleep)
360
+ input, output = create_text_document(temp_dir)
361
+ stub_upload.to_return(upload_reply)
362
+ replies = Array.new(12) { status_reply('translating') } << status_reply('done')
363
+ stub = stub_status(*replies)
364
+ stub_request(:post, url('/v2/document/doc-1/result')).to_return(status: 200, body: 'x')
365
+
366
+ documents.translate_document(input, output, 'EN', 'DE')
367
+
368
+ expect(stub).to have_been_requested.times(13)
369
+ end
370
+
371
+ it 'raises DocumentTranslationError instead of downloading after max_doc_status_queries' do
372
+ DeepL.configuration.max_doc_status_queries = 0
373
+ input, output = create_text_document(temp_dir)
374
+ stub_upload.to_return(upload_reply)
375
+ stub_status(status_reply('translating'))
376
+
377
+ expect { documents.translate_document(input, output, 'EN', 'DE') }
378
+ .to raise_error(DeepL::Exceptions::DocumentTranslationError, /not done/)
379
+ end
380
+
381
+ it 'raises DocumentTranslationError and keeps the file when the output file already exists' do
382
+ input, output = create_text_document(temp_dir)
383
+ File.write(output, 'keep me')
384
+
385
+ expect { documents.translate_document(input, output, 'EN', 'DE') }
386
+ .to raise_error(DeepL::Exceptions::DocumentTranslationError,
387
+ 'File already exists at output path') { |e|
388
+ expect(e.document_handle).to be_nil
389
+ }
390
+ expect(File.read(output)).to eq('keep me')
391
+ expect(a_request(:any, /.*/)).not_to have_been_made
392
+ end
393
+
394
+ it 'raises DocumentTranslationError for an unreadable input without other libraries loaded' do
395
+ script = <<~RUBY
396
+ DeepL.configure { |config| config.auth_key = 'k' }
397
+ begin
398
+ DeepL.document.translate_document('/nonexistent/in.txt', '/nonexistent/out.txt', 'EN', 'DE')
399
+ rescue StandardError => e
400
+ puts e.class
401
+ end
402
+ RUBY
403
+ lib = File.expand_path('../../lib', __dir__)
404
+
405
+ # stdout only: stderr carries the deprecation warning of the module API.
406
+ output, = Open3.capture3({ 'PATH' => ENV.fetch('PATH') }, RbConfig.ruby, '-I', lib, '-r',
407
+ 'deepl', '-e', script, unsetenv_others: true)
408
+
409
+ expect(output.strip).to eq('DeepL::Exceptions::DocumentTranslationError')
410
+ end
411
+
412
+ it 'raises DocumentTranslationError with the API message when the translation fails' do
413
+ input, output = create_text_document(temp_dir)
414
+ stub_upload.to_return(upload_reply)
415
+ stub_status(status_reply('error', error_message: 'Unsupported file'))
416
+
417
+ expect { documents.translate_document(input, output, 'EN', 'DE') }
418
+ .to raise_error(DeepL::Exceptions::DocumentTranslationError, /Unsupported file/) { |e|
419
+ expect(e.document_handle.document_id).to eq('doc-1')
420
+ }
421
+ end
422
+
423
+ it 'raises DocumentTranslationError and leaves no output file when the download fails' do
424
+ input, output = create_text_document(temp_dir)
425
+ stub_upload.to_return(upload_reply)
426
+ stub_status(status_reply('done'))
427
+ stub_request(:post,
428
+ url('/v2/document/doc-1/result')).to_return(reply({ message: 'gone' }, 404))
429
+
430
+ expect { documents.translate_document(input, output, 'EN', 'DE') }
431
+ .to raise_error(DeepL::Exceptions::DocumentTranslationError, /gone/)
432
+ expect(File.exist?(output)).to be(false)
433
+ end
434
+ end
435
+
436
+ # The client has its own key and server, different from the module configuration, so a
437
+ # request that falls back to module state hits no stub.
438
+ describe 'DeepL::Client document translation' do
439
+ let(:client) { DeepL::Client.new('client-key', server_url: 'https://client.example.test') }
440
+ let(:poller) { DeepL::Utils::DocumentPoller }
441
+
442
+ def url(path)
443
+ "https://client.example.test#{path}"
444
+ end
445
+
446
+ def json_headers
447
+ { 'Authorization' => 'DeepL-Auth-Key client-key', 'Content-Type' => 'application/json' }
448
+ end
449
+
450
+ def stub_download(body = 'Protonenstrahl')
451
+ stub_request(:post, url('/v2/document/doc-1/result'))
452
+ .with(body: document_key_body, headers: json_headers).to_return(status: 200, body: body)
453
+ end
454
+
455
+ describe '#translate_document_upload' do
456
+ it 'sends the filename option as the filename field and returns the handle' do
457
+ input, = create_text_document(temp_dir)
458
+ stub_upload.to_return(upload_reply)
459
+ forms = capture_forms
460
+
461
+ result = client.translate_document_upload(input, 'EN', 'DE', filename: 'name.docx',
462
+ formality: 'more')
463
+
464
+ expect(form_fields(forms.first)).to eq('source_lang' => 'EN', 'target_lang' => 'DE',
465
+ 'filename' => 'name.docx', 'formality' => 'more')
466
+ expect([result.document_id, result.document_key]).to eq(%w[doc-1 key-1])
467
+ end
468
+
469
+ it 'raises ArgumentError for a missing input file before any request' do
470
+ expect { client.translate_document_upload(File.join(temp_dir, 'missing.txt'), nil, 'DE') }
471
+ .to raise_error(ArgumentError, /Input file does not exist/)
472
+ expect(a_request(:any, /.*/)).not_to have_been_made
473
+ end
474
+ end
475
+
476
+ describe '#translate_document_get_status' do
477
+ it 'sends the document key and parses the status' do
478
+ stub_status(status_reply('translating', seconds_remaining: 3, billed_characters: 11))
479
+
480
+ status = client.translate_document_get_status(handle)
481
+
482
+ expect([status.status, status.seconds_remaining, status.billed_characters, status.ok?])
483
+ .to eq(['translating', 3, 11, true])
484
+ end
485
+ end
486
+
487
+ describe '#translate_document_wait_until_done' do
488
+ before { allow(poller).to receive(:sleep) }
489
+
490
+ it 'polls every 5 seconds, past unknown statuses, until done and returns the status' do
491
+ stub = stub_status(status_reply('queued'), status_reply('new_status'),
492
+ status_reply('done', billed_characters: 11))
493
+ expect(poller).to receive(:sleep).with(5).twice
494
+
495
+ status = client.translate_document_wait_until_done(handle)
496
+
497
+ expect(stub).to have_been_requested.times(3)
498
+ expect([status.status, status.billed_characters]).to eq(['done', 11])
499
+ end
500
+
501
+ it 'raises Error with the API message when the translation fails' do
502
+ stub_status(status_reply('error', error_message: 'Unsupported file'))
503
+
504
+ expect { client.translate_document_wait_until_done(handle) }
505
+ .to raise_error(DeepL::Exceptions::Error, /Unsupported file/)
506
+ end
507
+
508
+ it 'raises Error when timeout_s expires, sleeping no longer than the time left' do
509
+ allow(poller).to receive(:monotonic_now).and_return(0, 3, 8)
510
+ stub = stub_status(status_reply('translating'))
511
+ expect(poller).to receive(:sleep).with(4).once
512
+
513
+ expect { client.translate_document_wait_until_done(handle, timeout_s: 7) }
514
+ .to raise_error(DeepL::Exceptions::Error, /within 7 seconds/)
515
+ expect(stub).to have_been_requested.twice
516
+ end
517
+ end
518
+
519
+ describe '#translate_document_download' do
520
+ it 'writes the translated document and returns nil' do
521
+ output = File.join(temp_dir, 'result.docx')
522
+ stub_download(Fixtures::BINARY_CONTENT)
523
+
524
+ expect(client.translate_document_download(handle, output)).to be_nil
525
+ expect(File.binread(output)).to eq(Fixtures::BINARY_CONTENT)
526
+ end
527
+ end
528
+
529
+ describe '#translate_document' do
530
+ before { allow(poller).to receive(:sleep) }
531
+
532
+ it 'uploads with the options, waits, downloads and returns the final status' do # rubocop:disable RSpec/ExampleLength
533
+ input, output = create_text_document(temp_dir)
534
+ stub_upload.to_return(upload_reply)
535
+ stub_status(status_reply('translating'), status_reply('done', billed_characters: 11))
536
+ stub_download
537
+ forms = capture_forms
538
+
539
+ status = client.translate_document(input, output, 'EN', 'DE', filename: 'name.txt',
540
+ glossary: 'g-1')
541
+
542
+ expect(form_fields(forms.first))
543
+ .to include('filename' => 'name.txt', 'glossary_id' => 'g-1')
544
+ expect([status.status, status.billed_characters]).to eq(['done', 11])
545
+ expect(File.read(output)).to eq('Protonenstrahl')
546
+ end
547
+
548
+ it 'raises DocumentTranslationError with the handle when timeout_s expires' do
549
+ allow(poller).to receive(:monotonic_now).and_return(0, 2)
550
+ input, output = create_text_document(temp_dir)
551
+ stub_upload.to_return(upload_reply)
552
+ stub_status(status_reply('queued'))
553
+
554
+ expect { client.translate_document(input, output, 'EN', 'DE', timeout_s: 1) }
555
+ .to raise_error(DeepL::Exceptions::DocumentTranslationError, /within 1 seconds/) { |e|
556
+ expect(e.document_handle.document_id).to eq('doc-1')
557
+ }
558
+ expect(File.exist?(output)).to be(false)
559
+ end
560
+
561
+ it 'raises DocumentTranslationError without a handle when the upload fails' do
562
+ input, output = create_text_document(temp_dir)
563
+ stub_upload.to_return(reply({ message: 'bad' }, 400))
564
+
565
+ expect { client.translate_document(input, output, 'EN', 'DE') }
566
+ .to raise_error(DeepL::Exceptions::DocumentTranslationError, /bad/) { |e|
567
+ expect(e.document_handle).to be_nil
568
+ }
569
+ end
570
+
571
+ it 'copies the HTTP status and retryability of the DeepL error it wraps' do
572
+ client = DeepL::Client.new('client-key', server_url: 'https://client.example.test',
573
+ max_retries: 0)
574
+ input, output = create_text_document(temp_dir)
575
+ stub_upload.to_return(reply({ message: 'unavailable' }, 503))
576
+
577
+ expect { client.translate_document(input, output, 'EN', 'DE') }
578
+ .to raise_error(DeepL::Exceptions::DocumentTranslationError) { |e|
579
+ expect([e.http_status_code, e.should_retry?]).to eq([503, true])
580
+ }
581
+ end
582
+
583
+ it 'raises DocumentTranslationError and keeps the file when the output file already exists' do
584
+ input, output = create_text_document(temp_dir)
585
+ File.write(output, 'keep me')
586
+
587
+ expect { client.translate_document(input, output, 'EN', 'DE') }
588
+ .to raise_error(DeepL::Exceptions::DocumentTranslationError,
589
+ 'File already exists at output path') { |e|
590
+ expect([e.document_handle, e.http_status_code, e.should_retry?])
591
+ .to eq([nil, nil, false])
592
+ }
593
+ expect(File.read(output)).to eq('keep me')
594
+ expect(a_request(:any, /.*/)).not_to have_been_made
595
+ end
596
+
597
+ it 'raises ArgumentError for a missing input file before any request' do
598
+ output = File.join(temp_dir, 'out.txt')
599
+
600
+ expect { client.translate_document(File.join(temp_dir, 'missing.txt'), output, 'EN', 'DE') }
601
+ .to raise_error(ArgumentError, /Input file does not exist/)
602
+ expect(a_request(:any, /.*/)).not_to have_been_made
603
+ end
604
+ end
605
+ end
606
+ end