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,215 @@
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
+ # The client has its own key and server, different from the module configuration, so a request
9
+ # that falls back to module state hits no stub.
10
+ describe 'DeepL::Client spoken terms' do
11
+ let(:client) { DeepL::Client.new('client-key', server_url: 'https://client.example.test') }
12
+ let(:collection_json) do
13
+ { spoken_terms_id: 'st-1', name: 'terms', creation_time: '2026-10-23T17:15:53.434769Z',
14
+ term_lists: [{ lang: 'en', entry_count: 2 }] }
15
+ end
16
+
17
+ def url(path)
18
+ "https://client.example.test#{path}"
19
+ end
20
+
21
+ def json_headers
22
+ { 'Authorization' => 'DeepL-Auth-Key client-key', 'Content-Type' => 'application/json' }
23
+ end
24
+
25
+ def no_body
26
+ ->(body) { body.to_s.empty? }
27
+ end
28
+
29
+ def collection(json = collection_json)
30
+ stub_request(:get, url('/v3/spoken-terms/st-1')).to_return(reply(json))
31
+ client.get_spoken_terms('st-1')
32
+ end
33
+
34
+ describe '#create_spoken_terms' do
35
+ it 'sends the entries of each language joined by newlines' do
36
+ stub_request(:post, url('/v3/spoken-terms')).with(
37
+ body: { name: 'terms', term_lists: [{ lang: 'en', entries: "a\nb" }] },
38
+ headers: json_headers
39
+ ).to_return(reply(collection_json, 201))
40
+
41
+ client.create_spoken_terms('terms', [DeepL::Resources::SpokenTermsEntries.new('en', %w[a b])])
42
+ end
43
+
44
+ it 'accepts term lists given as hashes and sends the extra body parameters' do
45
+ stub_request(:post, url('/v3/spoken-terms'))
46
+ .with(body: { name: 'terms', term_lists: [{ lang: 'de', entries: 'x' }], beta: true })
47
+ .to_return(reply(collection_json, 201))
48
+
49
+ client.create_spoken_terms('terms', [{ lang: 'de', entries: ['x'] }],
50
+ extra_body_parameters: { beta: true })
51
+ end
52
+
53
+ it 'parses the collection, including a creation time with microseconds' do
54
+ stub_request(:post, url('/v3/spoken-terms')).to_return(reply(collection_json, 201))
55
+
56
+ created = client.create_spoken_terms('terms', [])
57
+
58
+ expect(created.spoken_terms_id).to eq('st-1')
59
+ expect(created.creation_time)
60
+ .to eq(Time.utc(2026, 10, 23, 17, 15, Rational(53_434_769, 1_000_000)))
61
+ expect(created.term_lists.map { |l| [l.lang, l.entry_count] }).to eq([['en', 2]])
62
+ end
63
+ end
64
+
65
+ describe 'SpokenTerms#creation_time' do
66
+ it 'is nil for a malformed timestamp' do
67
+ expect(collection(collection_json.merge(creation_time: 'yesterday')).creation_time).to be_nil
68
+ end
69
+
70
+ it 'reads a timestamp without an offset as UTC, not local time' do
71
+ original_tz = ENV.fetch('TZ', nil)
72
+ ENV['TZ'] = 'America/New_York'
73
+ expect(collection(collection_json.merge(creation_time: '2026-10-23T17:15:53')).creation_time)
74
+ .to eq(Time.utc(2026, 10, 23, 17, 15, 53))
75
+ ensure
76
+ ENV['TZ'] = original_tz
77
+ end
78
+ end
79
+
80
+ describe 'term check' do
81
+ it 'strips surrounding whitespace from each term before sending it' do
82
+ stub_request(:post, url('/v3/spoken-terms'))
83
+ .with(body: { name: 'terms', term_lists: [{ lang: 'en', entries: "API\nDeepL" }] })
84
+ .to_return(reply(collection_json, 201))
85
+
86
+ client.create_spoken_terms('terms', [{ lang: 'en', entries: [' API ', "DeepL\n"] }])
87
+ end
88
+
89
+ [["a\nb"], [' ']].each do |bad|
90
+ it "raises ValidationError for #{bad.inspect} and sends no request" do
91
+ expect { client.create_spoken_terms('terms', [{ lang: 'en', entries: bad }]) }
92
+ .to raise_error(DeepL::Exceptions::ValidationError)
93
+ expect { client.update_spoken_terms('st-1', term_lists: [{ lang: 'en', entries: bad }]) }
94
+ .to raise_error(DeepL::Exceptions::ValidationError)
95
+ expect { client.replace_spoken_terms_list('st-1', { lang: 'en', entries: bad }) }
96
+ .to raise_error(DeepL::Exceptions::ValidationError)
97
+ expect(a_request(:any, /.*/)).not_to have_been_made
98
+ end
99
+ end
100
+ end
101
+
102
+ describe '#list_spoken_terms and #get_spoken_terms' do
103
+ it 'send GET requests without a body' do
104
+ stub_request(:get, url('/v3/spoken-terms')).with(headers: json_headers, body: no_body)
105
+ .to_return(reply(spoken_terms: [collection_json]))
106
+ stub_request(:get, url('/v3/spoken-terms/st-1')).with(headers: json_headers, body: no_body)
107
+ .to_return(reply(collection_json))
108
+
109
+ expect(client.list_spoken_terms.map(&:spoken_terms_id)).to eq(['st-1'])
110
+ expect(client.get_spoken_terms('st-1').name).to eq('terms')
111
+ end
112
+
113
+ it 'turns a missing term_lists into an empty list' do
114
+ stub_request(:get, url('/v3/spoken-terms'))
115
+ .to_return(reply(spoken_terms: [collection_json.reject { |k, _| k == :term_lists }]))
116
+
117
+ expect(client.list_spoken_terms.map(&:term_lists)).to eq([[]])
118
+ end
119
+ end
120
+
121
+ describe '#update_spoken_terms' do
122
+ it 'sends only the given arguments' do
123
+ stub_request(:patch, url('/v3/spoken-terms/st-1'))
124
+ .with(body: { term_lists: [{ lang: 'en', entries: 'c' }] }, headers: json_headers)
125
+ .to_return(reply(collection_json))
126
+ stub_request(:patch, url('/v3/spoken-terms/st-1')).with(body: { name: 'new' })
127
+ .to_return(reply(collection_json))
128
+
129
+ client.update_spoken_terms('st-1', term_lists: [{ lang: 'en', entries: ['c'] }])
130
+ client.update_spoken_terms('st-1', name: 'new')
131
+ end
132
+
133
+ it 'sends the extra body parameters' do
134
+ stub_request(:patch, url('/v3/spoken-terms/st-1'))
135
+ .with(body: { name: 'new', beta: true }).to_return(reply(collection_json))
136
+
137
+ client.update_spoken_terms('st-1', name: 'new', extra_body_parameters: { beta: true })
138
+ end
139
+
140
+ it 'sends an empty object when no argument is given' do
141
+ stub_request(:patch, url('/v3/spoken-terms/st-1')).with(body: '{}')
142
+ .to_return(reply(collection_json))
143
+
144
+ client.update_spoken_terms('st-1')
145
+ end
146
+ end
147
+
148
+ describe '#get_spoken_terms_entries' do
149
+ it 'sends the language in the query and splits the entries on newlines' do
150
+ stub_request(:get, url('/v3/spoken-terms/st-1/entries?lang=en'))
151
+ .with(headers: json_headers).to_return(reply(lang: 'en', entries: "a\nb\n"))
152
+
153
+ result = client.get_spoken_terms_entries('st-1', 'en')
154
+
155
+ expect([result.lang, result.entries]).to eq(['en', %w[a b]])
156
+ end
157
+ end
158
+
159
+ describe '#replace_spoken_terms_list' do
160
+ it 'takes a SpokenTermsEntries and a SpokenTerms object' do
161
+ stub_request(:put, url('/v3/spoken-terms/st-1/term-lists'))
162
+ .with(body: { lang: 'en', entries: "a\nb" }, headers: json_headers)
163
+ .to_return(reply(lang: 'en', entry_count: 2))
164
+
165
+ list = client.replace_spoken_terms_list(
166
+ collection, DeepL::Resources::SpokenTermsEntries.new('en', %w[a b])
167
+ )
168
+
169
+ expect([list.lang, list.entry_count]).to eq(['en', 2])
170
+ end
171
+
172
+ it 'takes a hash and sends the extra body parameters' do
173
+ stub_request(:put, url('/v3/spoken-terms/st-1/term-lists'))
174
+ .with(body: { lang: 'de', entries: 'x', beta: true })
175
+ .to_return(reply(lang: 'de', entry_count: 1))
176
+
177
+ client.replace_spoken_terms_list('st-1', { lang: 'de', entries: ['x'] },
178
+ extra_body_parameters: { beta: true })
179
+ end
180
+ end
181
+
182
+ describe '#delete_spoken_terms and #delete_spoken_terms_list' do
183
+ it 'send DELETE requests without a body and return nil' do
184
+ stub_request(:delete, url('/v3/spoken-terms/st-1/term-lists?lang=de'))
185
+ .with(headers: json_headers, body: no_body).to_return(status: 204)
186
+ stub_request(:delete, url('/v3/spoken-terms/st-1'))
187
+ .with(headers: json_headers, body: no_body).to_return(status: 204)
188
+
189
+ expect(client.delete_spoken_terms_list('st-1', 'de')).to be_nil
190
+ expect(client.delete_spoken_terms('st-1')).to be_nil
191
+ end
192
+ end
193
+
194
+ describe 'collection IDs' do
195
+ before do
196
+ encoded = url('/v3/spoken-terms/a%20b%2Fc')
197
+ stub_request(:get, encoded).to_return(reply(collection_json))
198
+ stub_request(:patch, encoded).to_return(reply(collection_json))
199
+ stub_request(:delete, encoded).to_return(status: 204)
200
+ stub_request(:get, "#{encoded}/entries?lang=en").to_return(reply(lang: 'en', entries: ''))
201
+ stub_request(:put, "#{encoded}/term-lists").to_return(reply(lang: 'en', entry_count: 0))
202
+ stub_request(:delete, "#{encoded}/term-lists?lang=en").to_return(status: 204)
203
+ end
204
+
205
+ it 'are URL-encoded in every path' do
206
+ id = 'a b/c'
207
+ client.get_spoken_terms(id)
208
+ client.update_spoken_terms(id, name: 'x')
209
+ client.delete_spoken_terms(id)
210
+ client.get_spoken_terms_entries(id, 'en')
211
+ client.replace_spoken_terms_list(id, { lang: 'en', entries: ['x'] })
212
+ client.delete_spoken_terms_list(id, 'en')
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,266 @@
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
+ # The client has its own key and server, different from the module configuration, so a request
9
+ # that falls back to module state hits no stub.
10
+ describe 'DeepL::Client style profiles' do
11
+ let(:client) { DeepL::Client.new('client-key', server_url: 'https://client.example.test') }
12
+ let(:profile_id) { '3f1c9d0e-7a24-4b58-9c31-8f0e5d2a1b47' }
13
+ let(:profile_path) { "/v3/style_profiles/#{profile_id}" }
14
+ # The full example of the API specification.
15
+ let(:profile_json) do
16
+ { style_profile_id: profile_id, name: 'Legal, German',
17
+ description: 'Contract wording for the German market.', industry: 'Legal',
18
+ glossary_ids: ['4d0a2e1c-6b93-4f7a-8d15-2c6e9a3b7f81'],
19
+ style_rule_ids: ['bd0a38f3-1831-440b-a8dd-2c702e2325ab'],
20
+ translation_memory_ids: ['a74d88fb-ed2a-4943-a664-a4512398b994'],
21
+ glossary_priority_order_enabled: true,
22
+ creation_time: '2026-04-01T16:34:25.223Z', updated_time: '2026-08-06T09:12:44.108Z' }
23
+ end
24
+ let(:bare_profile_json) do
25
+ { style_profile_id: 'c58e2a71-90b4-4d6f-a2e3-71d5c8b4f902', name: 'Marketing, Japanese',
26
+ glossary_ids: [], style_rule_ids: [], translation_memory_ids: [],
27
+ glossary_priority_order_enabled: false,
28
+ creation_time: '2026-07-14T11:02:09.640Z', updated_time: '2026-07-14T11:02:09.640Z' }
29
+ end
30
+
31
+ def url(path)
32
+ "https://client.example.test#{path}"
33
+ end
34
+
35
+ def json_headers
36
+ { 'Authorization' => 'DeepL-Auth-Key client-key', 'Content-Type' => 'application/json' }
37
+ end
38
+
39
+ def profile_object
40
+ DeepL::Resources::StyleProfile.new(profile_json.transform_keys(&:to_s), nil, nil)
41
+ end
42
+
43
+ def glossary
44
+ DeepL::Resources::Glossary.new({ 'glossary_id' => 'g1', 'name' => 'n' }, nil, nil)
45
+ end
46
+
47
+ def multilingual_glossary
48
+ DeepL::Resources::MultilingualGlossary.new({ 'glossary_id' => 'g3', 'name' => 'n' }, nil, nil)
49
+ end
50
+
51
+ def style_rule
52
+ DeepL::Resources::StyleRule.new({ 'style_id' => 's1', 'name' => 'n' }, nil, nil)
53
+ end
54
+
55
+ def translation_memory
56
+ DeepL::Resources::TranslationMemory.new({ 'translation_memory_id' => 't2', 'name' => 'n' },
57
+ nil, nil)
58
+ end
59
+
60
+ describe '#list_style_profiles' do
61
+ it 'sends no query without paging options and returns the profiles as an array' do
62
+ list_json = { style_profiles: [profile_json, bare_profile_json], total_count: 2 }
63
+ stub_request(:get, url('/v3/style_profiles')).with(headers: json_headers)
64
+ .to_return(reply(list_json))
65
+
66
+ profiles = client.list_style_profiles
67
+
68
+ expect(profiles.map(&:name)).to eq(['Legal, German', 'Marketing, Japanese'])
69
+ end
70
+
71
+ it 'sends page and page_size as query parameters' do
72
+ stub_request(:get, url('/v3/style_profiles?page=2&page_size=100'))
73
+ .to_return(reply(style_profiles: [], total_count: 0))
74
+
75
+ expect(client.list_style_profiles(page: 2, page_size: 100)).to eq([])
76
+ end
77
+
78
+ it 'returns an empty array when style_profiles is missing' do
79
+ stub_request(:get, url('/v3/style_profiles')).to_return(reply(total_count: 0))
80
+
81
+ expect(client.list_style_profiles).to eq([])
82
+ end
83
+ end
84
+
85
+ describe '#create_style_profile' do
86
+ it 'sends only the name when no component is given' do
87
+ stub_request(:post, url('/v3/style_profiles'))
88
+ .with(body: { name: 'profile' }.to_json, headers: json_headers)
89
+ .to_return(reply(bare_profile_json, 201))
90
+
91
+ client.create_style_profile('profile')
92
+ end
93
+
94
+ it 'sends every field, the ID arrays in order and the boolean as JSON true' do
95
+ body = { name: 'profile', description: 'd', industry: 'Legal',
96
+ glossary_ids: %w[g2 g1], style_rule_ids: %w[s1], translation_memory_ids: %w[t1],
97
+ glossary_priority_order_enabled: true }
98
+ stub_request(:post, url('/v3/style_profiles'))
99
+ .with { |request| JSON.parse(request.body) == JSON.parse(body.to_json) }
100
+ .to_return(reply(profile_json, 201))
101
+
102
+ client.create_style_profile('profile', description: 'd', industry: 'Legal',
103
+ glossary_ids: %w[g2 g1], style_rule_ids: %w[s1],
104
+ translation_memory_ids: %w[t1],
105
+ glossary_priority_order_enabled: true)
106
+ end
107
+
108
+ it 'sends the IDs of glossary, style rule and translation memory objects' do
109
+ body = { name: 'profile', glossary_ids: %w[g1 g2 g3], style_rule_ids: %w[s1 s2],
110
+ translation_memory_ids: %w[t1 t2] }
111
+ stub_request(:post, url('/v3/style_profiles')).with(body: body.to_json)
112
+ .to_return(reply(profile_json, 201))
113
+
114
+ client.create_style_profile('profile', glossary_ids: [glossary, 'g2', multilingual_glossary],
115
+ style_rule_ids: [style_rule, 's2'],
116
+ translation_memory_ids: ['t1', translation_memory])
117
+ end
118
+
119
+ it 'applies extra body parameters' do
120
+ stub_request(:post, url('/v3/style_profiles'))
121
+ .with(body: { name: 'profile', beta: 'x' }.to_json)
122
+ .to_return(reply(bare_profile_json, 201))
123
+
124
+ client.create_style_profile('profile', extra_body_parameters: { beta: 'x' })
125
+ end
126
+ end
127
+
128
+ describe '#get_style_profile' do
129
+ it 'accepts an ID string or a style profile object' do
130
+ stub_request(:get, url(profile_path)).with(headers: json_headers)
131
+ .to_return(reply(profile_json)).times(2)
132
+
133
+ expect(client.get_style_profile(profile_id).style_profile_id).to eq(profile_id)
134
+ expect(client.get_style_profile(profile_object).style_profile_id).to eq(profile_id)
135
+ end
136
+
137
+ it 'escapes the ID in the path' do
138
+ stub_request(:get, url('/v3/style_profiles/a%2Fb%20c')).to_return(reply(profile_json))
139
+
140
+ client.get_style_profile('a/b c')
141
+ end
142
+
143
+ it 'raises NotFound on 404' do
144
+ stub_request(:get, url(profile_path))
145
+ .to_return(reply({ message: 'Style profile not found' }, 404))
146
+
147
+ expect { client.get_style_profile(profile_id) }.to raise_error(DeepL::Exceptions::NotFound)
148
+ end
149
+ end
150
+
151
+ describe '#update_style_profile' do
152
+ it 'sends only the set fields, including an empty string and an empty array' do
153
+ stub_request(:patch, url(profile_path))
154
+ .with(body: { name: 'new', description: '', style_rule_ids: [] }.to_json,
155
+ headers: json_headers)
156
+ .to_return(reply(profile_json))
157
+
158
+ client.update_style_profile(profile_id, name: 'new', description: '', style_rule_ids: [])
159
+ end
160
+
161
+ it 'sends an empty object when nothing is set, also for a style profile object' do
162
+ stub_request(:patch, url(profile_path)).with(body: '{}').to_return(reply(profile_json))
163
+ .times(2)
164
+
165
+ client.update_style_profile(profile_id)
166
+ client.update_style_profile(profile_object)
167
+ end
168
+
169
+ it 'sends the IDs of component objects' do
170
+ body = { glossary_ids: %w[g1 g3], style_rule_ids: %w[s1], translation_memory_ids: %w[t2] }
171
+ stub_request(:patch, url(profile_path)).with(body: body.to_json)
172
+ .to_return(reply(profile_json))
173
+
174
+ client.update_style_profile(profile_id, glossary_ids: [glossary, multilingual_glossary],
175
+ style_rule_ids: [style_rule],
176
+ translation_memory_ids: [translation_memory])
177
+ end
178
+
179
+ it 'sends glossary_priority_order_enabled false as JSON false' do
180
+ stub_request(:patch, url(profile_path))
181
+ .with(body: { glossary_priority_order_enabled: false }.to_json)
182
+ .to_return(reply(profile_json))
183
+
184
+ client.update_style_profile(profile_id, glossary_priority_order_enabled: false)
185
+ end
186
+ end
187
+
188
+ describe '#delete_style_profile' do
189
+ it 'sends DELETE for an ID string or a style profile object and returns nil' do
190
+ stub_request(:delete, url(profile_path)).with(headers: json_headers)
191
+ .to_return(status: 204).times(2)
192
+
193
+ expect(client.delete_style_profile(profile_id)).to be_nil
194
+ expect(client.delete_style_profile(profile_object)).to be_nil
195
+ end
196
+ end
197
+
198
+ describe 'empty ID' do
199
+ it 'raises ArgumentError and sends no request' do
200
+ ['', nil].each do |id|
201
+ expect { client.get_style_profile(id) }.to raise_error(ArgumentError)
202
+ expect { client.update_style_profile(id, name: 'x') }.to raise_error(ArgumentError)
203
+ expect { client.delete_style_profile(id) }.to raise_error(ArgumentError)
204
+ end
205
+ expect(a_request(:any, /.*/)).not_to have_been_made
206
+ end
207
+ end
208
+
209
+ describe 'parsing' do
210
+ it 'parses every field of the specification example' do
211
+ stub_request(:get, url(profile_path)).to_return(reply(profile_json))
212
+
213
+ times = { creation_time: Time.utc(2026, 4, 1, 16, 34, Rational(25_223, 1000)),
214
+ updated_time: Time.utc(2026, 8, 6, 9, 12, Rational(44_108, 1000)) }
215
+ expected = profile_json.merge(times)
216
+ expect(client.get_style_profile(profile_id)).to have_attributes(expected)
217
+ end
218
+
219
+ it 'parses a profile without description and industry, and ignores unknown fields' do
220
+ stub_request(:get, url(profile_path)).to_return(
221
+ reply(bare_profile_json.merge(example_document_ids: ['e1'], spoken_terms_ids: ['s1']))
222
+ )
223
+
224
+ profile = client.get_style_profile(profile_id)
225
+
226
+ expect(profile.description).to be_nil
227
+ expect(profile.industry).to be_nil
228
+ expect(profile.glossary_priority_order_enabled).to be(false)
229
+ expect(profile).not_to respond_to(:example_document_ids)
230
+ expect(profile).not_to respond_to(:spoken_terms_ids)
231
+ end
232
+
233
+ it 'parses a timestamp with 7 fractional digits' do
234
+ stub_request(:get, url(profile_path))
235
+ .to_return(reply(bare_profile_json.merge(creation_time: '2026-10-26T16:27:10.2982869Z')))
236
+
237
+ expect(client.get_style_profile(profile_id).creation_time)
238
+ .to eq(Time.utc(2026, 10, 26, 16, 27, Rational(102_982_869, 10_000_000)))
239
+ end
240
+
241
+ it 'defaults missing ID lists and the boolean' do
242
+ stub_request(:get, url(profile_path))
243
+ .to_return(reply(style_profile_id: profile_id, name: 'n', glossary_ids: nil))
244
+
245
+ profile = client.get_style_profile(profile_id)
246
+
247
+ expect([profile.glossary_ids, profile.style_rule_ids, profile.translation_memory_ids])
248
+ .to eq([[], [], []])
249
+ expect(profile.glossary_priority_order_enabled).to be(false)
250
+ end
251
+
252
+ it 'turns malformed values into nil or skips them without failing the call' do
253
+ stub_request(:get, url(profile_path)).to_return(
254
+ reply(profile_json.merge(creation_time: 'not a timestamp', glossary_ids: ['g1', 7, nil]))
255
+ )
256
+
257
+ profile = client.get_style_profile(profile_id)
258
+
259
+ expect(profile.creation_time).to be_nil
260
+ expect(profile.glossary_ids).to eq(['g1'])
261
+ expect(profile.name).to eq('Legal, German')
262
+ expect(profile.style_rule_ids).to eq(['bd0a38f3-1831-440b-a8dd-2c702e2325ab'])
263
+ expect(profile.updated_time).to eq(Time.utc(2026, 8, 6, 9, 12, Rational(44_108, 1000)))
264
+ end
265
+ end
266
+ end