matterbridge-lg-thinq-plugin 0.1.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 (210) hide show
  1. package/.claude/CLAUDE.md +43 -0
  2. package/.claude/agents/compiler.md +91 -0
  3. package/.claude/agents/direct-executor.md +98 -0
  4. package/.claude/agents/documenter.md +78 -0
  5. package/.claude/agents/finalizer.md +233 -0
  6. package/.claude/agents/implementer.md +112 -0
  7. package/.claude/agents/investigator.md +100 -0
  8. package/.claude/agents/release-manager.md +203 -0
  9. package/.claude/agents/reviewer.md +131 -0
  10. package/.claude/agents/technical-architect.md +403 -0
  11. package/.claude/agents/test-writer.md +159 -0
  12. package/.claude/agents/wiki-manager.md +147 -0
  13. package/.claude/instructions/agent-prompts.md +90 -0
  14. package/.claude/instructions/shared-rules.md +11 -0
  15. package/.claude/instructions/team-orchestrator-policy.md +165 -0
  16. package/.claude/memory.md +186 -0
  17. package/.claude/output-styles/engineer-manager.md +38 -0
  18. package/.claude/settings.json +40 -0
  19. package/.claude/settings.local.json +30 -0
  20. package/.claude/skills/load-policy/SKILL.md +13 -0
  21. package/.claude/skills/ref-idea/SKILL.md +44 -0
  22. package/.claude/skills/status-of/SKILL.md +38 -0
  23. package/.claude/skills/workspace-console/SKILL.md +49 -0
  24. package/.claude/statusline-command.sh +130 -0
  25. package/.claude/statusline.md +53 -0
  26. package/.claude/templates/approval-packet.template.html +171 -0
  27. package/.claude/templates/reference-workspaces.md +16 -0
  28. package/.claude/tools/cli-runner-server.cjs +220 -0
  29. package/.claude/tools/discord-send-server.cjs +188 -0
  30. package/.claude/tools/github-release-server.cjs +181 -0
  31. package/.claude/tools/glob-grep-server.cjs +267 -0
  32. package/.claude/tools/read-log-server.cjs +175 -0
  33. package/.github/codeql/codeql-config.yml +14 -0
  34. package/.github/workflows/build.yml +54 -0
  35. package/.github/workflows/codeql.yml +59 -0
  36. package/.github/workflows/coverage.yml +70 -0
  37. package/.github/workflows/publish.yml +103 -0
  38. package/.mcp.json +34 -0
  39. package/CHANGELOG.md +195 -0
  40. package/LICENSE +21 -0
  41. package/dist/cli/commands/devices.js +52 -0
  42. package/dist/cli/commands/energy.js +122 -0
  43. package/dist/cli/commands/login.js +54 -0
  44. package/dist/cli/help.js +50 -0
  45. package/dist/cli/main.js +56 -0
  46. package/dist/cli/session.js +13 -0
  47. package/dist/cli/types.js +1 -0
  48. package/dist/cli/utils.js +29 -0
  49. package/dist/cli.js +2 -0
  50. package/dist/constants/index.js +1 -0
  51. package/dist/core/domain/entities/ThinqDevice.js +43 -0
  52. package/dist/core/domain/value-objects/AirConditionerCapabilities.js +22 -0
  53. package/dist/core/domain/value-objects/ThinqFilterState.js +35 -0
  54. package/dist/core/domain/value-objects/ThinqSnapshot.js +125 -0
  55. package/dist/errors/AuthenticationError.js +26 -0
  56. package/dist/errors/BaseError.js +23 -0
  57. package/dist/errors/index.js +2 -0
  58. package/dist/model/LgThinkqPluginPlatformConfig.js +23 -0
  59. package/dist/model/LgThinqPluginPlatformConfig.js +23 -0
  60. package/dist/module.js +212 -0
  61. package/dist/platform/deviceRegistry.js +26 -0
  62. package/dist/platform/platformConfigManager.js +89 -0
  63. package/dist/platform/platformState.js +9 -0
  64. package/dist/platform/thinq/thinqAirConditionerCommandHandlers.js +190 -0
  65. package/dist/platform/thinq/thinqAirConditionerEndpointFactory.js +53 -0
  66. package/dist/platform/thinq/thinqAirConditionerFilterResetCommandHandler.js +54 -0
  67. package/dist/platform/thinq/thinqAirConditionerFilterStateSync.js +19 -0
  68. package/dist/platform/thinq/thinqAirConditionerStateSync.js +205 -0
  69. package/dist/platform/thinq/thinqDeviceConfigReconciler.js +26 -0
  70. package/dist/platform/thinq/thinqDeviceConfigurator.js +124 -0
  71. package/dist/platform/thinq/thinqDeviceDiscovery.js +33 -0
  72. package/dist/platform/thinq/thinqWasherCommandHandlers.js +54 -0
  73. package/dist/platform/thinq/thinqWasherEndpointFactory.js +6 -0
  74. package/dist/platform/thinq/thinqWasherStateSync.js +31 -0
  75. package/dist/platform/thinq/thinqWasherStopCommandResolver.js +38 -0
  76. package/dist/services/authentication/AccountAuthStrategy.js +135 -0
  77. package/dist/services/authentication/AuthContext.js +1 -0
  78. package/dist/services/authentication/AuthenticationCoordinator.js +29 -0
  79. package/dist/services/authentication/IAuthStrategy.js +1 -0
  80. package/dist/services/authentication/TokenAuthStrategy.js +27 -0
  81. package/dist/services/authentication/UserDataRepository.js +32 -0
  82. package/dist/services/thinq/gateway.js +61 -0
  83. package/dist/services/thinq/mqtt/mqttCertificate.js +63 -0
  84. package/dist/services/thinq/mqtt/mqttConnectionEvents.js +37 -0
  85. package/dist/services/thinq/mqtt/mqttKeyRepository.js +33 -0
  86. package/dist/services/thinq/mqtt/mqttRetry.js +25 -0
  87. package/dist/services/thinq/mqtt/thinqMqttListener.js +97 -0
  88. package/dist/services/thinq/serviceContainer.js +73 -0
  89. package/dist/services/thinq/session.js +48 -0
  90. package/dist/services/thinq/thinqApiClient.js +258 -0
  91. package/dist/services/thinq/thinqDeviceService.js +75 -0
  92. package/dist/services/thinq/thinqFilterMonitoringService.js +40 -0
  93. package/dist/settings.js +9 -0
  94. package/dist/tests/helpers/testUtils.js +87 -0
  95. package/dist/utils/mask.js +39 -0
  96. package/docs/README_CLI.md +166 -0
  97. package/eslint.config.js +194 -0
  98. package/matterbridge-lg-thinq-plugin.config.json +27 -0
  99. package/matterbridge-lg-thinq-plugin.schema.json +520 -0
  100. package/package.json +107 -0
  101. package/prettier.config.js +49 -0
  102. package/scripts/parse-junit-failures.mjs +45 -0
  103. package/scripts/parse-tsc-failures.mjs +48 -0
  104. package/scripts/run-build-local-summary.mjs +29 -0
  105. package/scripts/run-format-summary.mjs +36 -0
  106. package/scripts/run-lint-fix-summary.mjs +38 -0
  107. package/scripts/run-precommit-summary.mjs +94 -0
  108. package/src/cli/commands/devices.ts +67 -0
  109. package/src/cli/commands/energy.ts +160 -0
  110. package/src/cli/commands/login.ts +74 -0
  111. package/src/cli/help.ts +53 -0
  112. package/src/cli/main.ts +66 -0
  113. package/src/cli/session.ts +15 -0
  114. package/src/cli/types.ts +10 -0
  115. package/src/cli/utils.ts +30 -0
  116. package/src/cli.ts +3 -0
  117. package/src/constants/index.ts +8 -0
  118. package/src/core/domain/entities/ThinqDevice.test.ts +224 -0
  119. package/src/core/domain/entities/ThinqDevice.ts +74 -0
  120. package/src/core/domain/value-objects/AirConditionerCapabilities.ts +50 -0
  121. package/src/core/domain/value-objects/ThinqFilterState.ts +50 -0
  122. package/src/core/domain/value-objects/ThinqSnapshot.ts +162 -0
  123. package/src/errors/AuthenticationError.ts +37 -0
  124. package/src/errors/BaseError.ts +38 -0
  125. package/src/errors/index.ts +24 -0
  126. package/src/model/LgThinqPluginPlatformConfig.ts +94 -0
  127. package/src/module.ts +298 -0
  128. package/src/platform/deviceRegistry.ts +58 -0
  129. package/src/platform/platformConfigManager.ts +133 -0
  130. package/src/platform/platformState.ts +16 -0
  131. package/src/platform/thinq/thinqAirConditionerCommandHandlers.ts +291 -0
  132. package/src/platform/thinq/thinqAirConditionerEndpointFactory.ts +145 -0
  133. package/src/platform/thinq/thinqAirConditionerFilterResetCommandHandler.ts +96 -0
  134. package/src/platform/thinq/thinqAirConditionerFilterStateSync.ts +42 -0
  135. package/src/platform/thinq/thinqAirConditionerStateSync.ts +343 -0
  136. package/src/platform/thinq/thinqDeviceConfigReconciler.ts +42 -0
  137. package/src/platform/thinq/thinqDeviceConfigurator.test.ts +90 -0
  138. package/src/platform/thinq/thinqDeviceConfigurator.ts +191 -0
  139. package/src/platform/thinq/thinqDeviceDiscovery.test.ts +173 -0
  140. package/src/platform/thinq/thinqDeviceDiscovery.ts +51 -0
  141. package/src/platform/thinq/thinqWasherCommandHandlers.ts +120 -0
  142. package/src/platform/thinq/thinqWasherEndpointFactory.ts +37 -0
  143. package/src/platform/thinq/thinqWasherStateSync.ts +60 -0
  144. package/src/platform/thinq/thinqWasherStopCommandResolver.ts +62 -0
  145. package/src/services/authentication/AccountAuthStrategy.test.ts +358 -0
  146. package/src/services/authentication/AccountAuthStrategy.ts +209 -0
  147. package/src/services/authentication/AuthContext.ts +19 -0
  148. package/src/services/authentication/AuthenticationCoordinator.test.ts +217 -0
  149. package/src/services/authentication/AuthenticationCoordinator.ts +59 -0
  150. package/src/services/authentication/IAuthStrategy.ts +10 -0
  151. package/src/services/authentication/TokenAuthStrategy.test.ts +167 -0
  152. package/src/services/authentication/TokenAuthStrategy.ts +40 -0
  153. package/src/services/authentication/UserDataRepository.test.ts +149 -0
  154. package/src/services/authentication/UserDataRepository.ts +46 -0
  155. package/src/services/thinq/gateway.ts +86 -0
  156. package/src/services/thinq/mqtt/mqttCertificate.test.ts +344 -0
  157. package/src/services/thinq/mqtt/mqttCertificate.ts +102 -0
  158. package/src/services/thinq/mqtt/mqttConnectionEvents.test.ts +276 -0
  159. package/src/services/thinq/mqtt/mqttConnectionEvents.ts +76 -0
  160. package/src/services/thinq/mqtt/mqttKeyRepository.test.ts +164 -0
  161. package/src/services/thinq/mqtt/mqttKeyRepository.ts +43 -0
  162. package/src/services/thinq/mqtt/mqttRetry.test.ts +239 -0
  163. package/src/services/thinq/mqtt/mqttRetry.ts +43 -0
  164. package/src/services/thinq/mqtt/thinqMqttListener.test.ts +349 -0
  165. package/src/services/thinq/mqtt/thinqMqttListener.ts +120 -0
  166. package/src/services/thinq/serviceContainer.test.ts +203 -0
  167. package/src/services/thinq/serviceContainer.ts +105 -0
  168. package/src/services/thinq/session.test.ts +287 -0
  169. package/src/services/thinq/session.ts +69 -0
  170. package/src/services/thinq/thinqApiClient.test.ts +702 -0
  171. package/src/services/thinq/thinqApiClient.ts +418 -0
  172. package/src/services/thinq/thinqDeviceService.test.ts +415 -0
  173. package/src/services/thinq/thinqDeviceService.ts +101 -0
  174. package/src/services/thinq/thinqFilterMonitoringService.test.ts +264 -0
  175. package/src/services/thinq/thinqFilterMonitoringService.ts +53 -0
  176. package/src/settings.ts +18 -0
  177. package/src/tests/cli/commands/devices.test.ts +293 -0
  178. package/src/tests/cli/commands/login.test.ts +240 -0
  179. package/src/tests/cli/help.test.ts +46 -0
  180. package/src/tests/cli/main.test.ts +214 -0
  181. package/src/tests/cli/session.test.ts +198 -0
  182. package/src/tests/cli/utils.test.ts +194 -0
  183. package/src/tests/core/domain/value-objects/AirConditionerCapabilities.test.ts +685 -0
  184. package/src/tests/core/domain/value-objects/ThinqFilterState.test.ts +210 -0
  185. package/src/tests/core/domain/value-objects/ThinqSnapshot.test.ts +1035 -0
  186. package/src/tests/helpers/testUtils.ts +118 -0
  187. package/src/tests/model/LgThinqPluginPlatformConfig.test.ts +81 -0
  188. package/src/tests/platform/deviceRegistry.test.ts +177 -0
  189. package/src/tests/platform/platformConfigManager.test.ts +790 -0
  190. package/src/tests/platform/platformState.test.ts +41 -0
  191. package/src/tests/platform/thinq/thinqAirConditionerCommandHandlers.test.ts +879 -0
  192. package/src/tests/platform/thinq/thinqAirConditionerEndpointFactory.test.ts +993 -0
  193. package/src/tests/platform/thinq/thinqAirConditionerFilterResetCommandHandler.test.ts +508 -0
  194. package/src/tests/platform/thinq/thinqAirConditionerFilterStateSync.test.ts +212 -0
  195. package/src/tests/platform/thinq/thinqAirConditionerStateSync.test.ts +1586 -0
  196. package/src/tests/platform/thinq/thinqDeviceConfigReconciler.test.ts +269 -0
  197. package/src/tests/platform/thinq/thinqDeviceConfigurator.test.ts +492 -0
  198. package/src/tests/platform/thinq/thinqWasherCommandHandlers.test.ts +594 -0
  199. package/src/tests/platform/thinq/thinqWasherEndpointFactory.test.ts +84 -0
  200. package/src/tests/platform/thinq/thinqWasherStateSync.test.ts +373 -0
  201. package/src/tests/platform/thinq/thinqWasherStopCommandResolver.test.ts +392 -0
  202. package/src/tests/settings.test.ts +33 -0
  203. package/src/utils/mask.ts +50 -0
  204. package/tsconfig.base.json +25 -0
  205. package/tsconfig.json +9 -0
  206. package/tsconfig.production.json +13 -0
  207. package/tsconfig.vitest.json +5 -0
  208. package/vite.config.ts +43 -0
  209. package/wiki/memory-archive.md +12 -0
  210. package/wiki/reference-workspaces.md +23 -0
@@ -0,0 +1,702 @@
1
+ import axios from 'axios';
2
+ import MockAdapter from 'axios-mock-adapter';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { TokenExpiredError } from '../../errors/index.js';
6
+ import { buildGatewayResponse, buildThinqDeviceData, createMockLogger } from '../../tests/helpers/testUtils.js';
7
+ import { ThinqSession } from './session.js';
8
+ import { ThinqApiClient } from './thinqApiClient.js';
9
+
10
+ describe('ThinqApiClient', () => {
11
+ let mockAxios: MockAdapter;
12
+ let mockLogger: ReturnType<typeof createMockLogger>;
13
+ let apiClient: ThinqApiClient;
14
+ let session: ThinqSession;
15
+
16
+ const gatewayData = buildGatewayResponse();
17
+
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ mockAxios = new MockAdapter(axios as any);
22
+ mockLogger = createMockLogger();
23
+ session = new ThinqSession('access-token-123', 'refresh-token-123', Math.floor(Date.now() / 1000) + 3600);
24
+ apiClient = new ThinqApiClient(session, 'US', 'en', mockLogger);
25
+ });
26
+
27
+ afterEach(() => {
28
+ mockAxios.reset();
29
+ vi.clearAllMocks();
30
+ });
31
+
32
+ describe('getGateway', () => {
33
+ it('should cache gateway after first call', async () => {
34
+ mockAxios
35
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
36
+ .reply(200, { result: gatewayData });
37
+
38
+ const result1 = await apiClient.getGateway();
39
+ const result2 = await apiClient.getGateway();
40
+
41
+ expect(result1).toEqual(gatewayData);
42
+ expect(result2).toEqual(gatewayData);
43
+ expect(mockAxios.history.get).toHaveLength(1);
44
+ });
45
+
46
+ it('should return gateway data on successful request', async () => {
47
+ mockAxios
48
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
49
+ .reply(200, { result: gatewayData });
50
+
51
+ const result = await apiClient.getGateway();
52
+
53
+ expect(result).toEqual(gatewayData);
54
+ });
55
+ });
56
+
57
+ describe('getListHomes', () => {
58
+ it('should cache homes after first call', async () => {
59
+ const homes = [{ homeId: 'home-1' }, { homeId: 'home-2' }];
60
+ mockAxios
61
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
62
+ .reply(200, { result: gatewayData });
63
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`).reply(200, { result: { item: homes } });
64
+
65
+ const result1 = await apiClient.getListHomes();
66
+ const result2 = await apiClient.getListHomes();
67
+
68
+ expect(result1).toEqual(homes);
69
+ expect(result2).toEqual(homes);
70
+ });
71
+
72
+ it('should return empty array when no homes found', async () => {
73
+ mockAxios
74
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
75
+ .reply(200, { result: gatewayData });
76
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`).reply(200, { result: { item: undefined } });
77
+
78
+ const result = await apiClient.getListHomes();
79
+
80
+ expect(result).toEqual([]);
81
+ });
82
+ });
83
+
84
+ describe('getListDevices', () => {
85
+ it('should fetch devices from all homes', async () => {
86
+ const homes = [{ homeId: 'home-1' }, { homeId: 'home-2' }];
87
+ const devicesHome1 = [buildThinqDeviceData({ deviceId: 'device-1' })];
88
+ const devicesHome2 = [buildThinqDeviceData({ deviceId: 'device-2' })];
89
+
90
+ mockAxios
91
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
92
+ .reply(200, { result: gatewayData });
93
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`).reply(200, { result: { item: homes } });
94
+ mockAxios
95
+ .onGet(`${gatewayData.thinq2Uri}/service/homes/home-1`)
96
+ .reply(200, { result: { devices: devicesHome1 } });
97
+ mockAxios
98
+ .onGet(`${gatewayData.thinq2Uri}/service/homes/home-2`)
99
+ .reply(200, { result: { devices: devicesHome2 } });
100
+
101
+ const result = await apiClient.getListDevices();
102
+
103
+ expect(result).toHaveLength(2);
104
+ expect(result[0].deviceId).toBe('device-1');
105
+ expect(result[1].deviceId).toBe('device-2');
106
+ });
107
+
108
+ it('should skip homes with no devices', async () => {
109
+ const homes = [{ homeId: 'home-1' }, { homeId: 'home-2' }];
110
+ const devices = [buildThinqDeviceData({ deviceId: 'device-1' })];
111
+
112
+ mockAxios
113
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
114
+ .reply(200, { result: gatewayData });
115
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`).reply(200, { result: { item: homes } });
116
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes/home-1`).reply(200, { result: { devices } });
117
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes/home-2`).reply(200, { result: { devices: undefined } });
118
+
119
+ const result = await apiClient.getListDevices();
120
+
121
+ expect(result).toHaveLength(1);
122
+ expect(result[0].deviceId).toBe('device-1');
123
+ });
124
+ });
125
+
126
+ describe('sendCommand', () => {
127
+ it('should throw error when deviceId is empty', async () => {
128
+ mockAxios
129
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
130
+ .reply(200, { result: gatewayData });
131
+
132
+ await expect(apiClient.sendCommand('', { dataKey: 'test' })).rejects.toThrow('Invalid deviceId');
133
+ });
134
+
135
+ it('should throw error when deviceId is whitespace only', async () => {
136
+ mockAxios
137
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
138
+ .reply(200, { result: gatewayData });
139
+
140
+ await expect(apiClient.sendCommand(' ', { dataKey: 'test' })).rejects.toThrow('Invalid deviceId');
141
+ });
142
+
143
+ it('should send command with payload', async () => {
144
+ mockAxios
145
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
146
+ .reply(200, { result: gatewayData });
147
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control-sync`).reply(200);
148
+
149
+ await apiClient.sendCommand('device-123', { dataKey: 'test', dataValue: 'value' });
150
+
151
+ expect(mockAxios.history.post).toHaveLength(1);
152
+ });
153
+ });
154
+
155
+ describe('sendKeepAlive', () => {
156
+ it('should throw error when deviceId is empty', async () => {
157
+ mockAxios
158
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
159
+ .reply(200, { result: gatewayData });
160
+
161
+ await expect(apiClient.sendKeepAlive('')).rejects.toThrow('Invalid deviceId');
162
+ });
163
+
164
+ it('should throw error when deviceId is whitespace only', async () => {
165
+ mockAxios
166
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
167
+ .reply(200, { result: gatewayData });
168
+
169
+ await expect(apiClient.sendKeepAlive(' ')).rejects.toThrow('Invalid deviceId');
170
+ });
171
+
172
+ it('should send keep-alive with correct payload', async () => {
173
+ mockAxios
174
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
175
+ .reply(200, { result: gatewayData });
176
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control`).reply(200);
177
+
178
+ await apiClient.sendKeepAlive('device-123');
179
+
180
+ expect(mockAxios.history.post).toHaveLength(1);
181
+ const request = mockAxios.history.post[0];
182
+ const body = JSON.parse(request.data as string);
183
+ expect(body).toEqual({
184
+ ctrlKey: 'allEventEnable',
185
+ command: 'Set',
186
+ dataKey: 'airState.mon.timeout',
187
+ dataValue: '70',
188
+ });
189
+ });
190
+
191
+ it('should propagate rejection when POST fails', async () => {
192
+ mockAxios
193
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
194
+ .reply(200, { result: gatewayData });
195
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control`).reply(500);
196
+
197
+ await expect(apiClient.sendKeepAlive('device-123')).rejects.toThrow();
198
+ });
199
+ });
200
+
201
+ describe('refreshToken', () => {
202
+ it('should refresh the session access token', async () => {
203
+ const oldSession = new ThinqSession('old-access', 'refresh-token-123', Math.floor(Date.now() / 1000));
204
+ mockAxios
205
+ .onPost(new RegExp('https://.*.lgeapi.com/oauth/1.0/oauth2/token'))
206
+ .reply(200, { access_token: 'new-access-token', expires_in: '3600' });
207
+
208
+ const result = await apiClient.refreshToken(oldSession);
209
+
210
+ expect(result.accessToken).toBe('new-access-token');
211
+ expect(result.refreshToken).toBe('refresh-token-123');
212
+ });
213
+
214
+ it('should throw TokenExpiredError on refresh failure', async () => {
215
+ const oldSession = new ThinqSession('old-access', 'refresh-token-123', Math.floor(Date.now() / 1000));
216
+ mockAxios.onPost(new RegExp('https://.*.lgeapi.com/oauth/1.0/oauth2/token')).reply(500);
217
+
218
+ await expect(apiClient.refreshToken(oldSession)).rejects.toThrow(TokenExpiredError);
219
+ });
220
+ });
221
+
222
+ describe('setSession', () => {
223
+ it('should replace the active session', () => {
224
+ const newSession = new ThinqSession('new-access', 'new-refresh', Math.floor(Date.now() / 1000) + 7200);
225
+
226
+ apiClient.setSession(newSession);
227
+
228
+ expect(apiClient['session']).toBe(newSession);
229
+ });
230
+ });
231
+
232
+ describe('setUserNumber', () => {
233
+ it('should set the user number and compute client ID', () => {
234
+ apiClient.setUserNumber('user-123');
235
+
236
+ expect(apiClient['userNumber']).toBe('user-123');
237
+ expect(apiClient['clientId']).toBeDefined();
238
+ expect(apiClient['clientId']).not.toBe('');
239
+ });
240
+ });
241
+
242
+ describe('getUserNumber', () => {
243
+ it('should return user number from profile response', async () => {
244
+ mockAxios
245
+ .onGet(new RegExp('https://.*.lgeapi.com/users/profile'))
246
+ .reply(200, { status: undefined, account: { userNo: 'user-number-123' } });
247
+
248
+ const result = await apiClient.getUserNumber('access-token-123');
249
+
250
+ expect(result).toBe('user-number-123');
251
+ });
252
+
253
+ it('should throw error when status is 2', async () => {
254
+ mockAxios.onGet(new RegExp('https://.*.lgeapi.com/users/profile')).reply(200, {
255
+ status: 2,
256
+ message: 'Profile lookup failed',
257
+ account: { userNo: '' },
258
+ });
259
+
260
+ await expect(apiClient.getUserNumber('access-token-123')).rejects.toThrow('Profile lookup failed');
261
+ });
262
+ });
263
+
264
+ describe('request 401 retry logic', () => {
265
+ it('should retry once on 401 response', async () => {
266
+ const homes = [{ homeId: 'home-1' }];
267
+ mockAxios
268
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
269
+ .reply(200, { result: gatewayData });
270
+
271
+ // First call returns 401, second call returns 200
272
+ const homesAdapter = mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`);
273
+ homesAdapter.replyOnce(401);
274
+ homesAdapter.replyOnce(200, { result: { item: homes } });
275
+
276
+ mockAxios.onPost(new RegExp('https://.*.lgeapi.com/oauth/1.0/oauth2/token')).reply(200, {
277
+ access_token: 'new-access-token',
278
+ expires_in: '3600',
279
+ });
280
+
281
+ const result = await apiClient.getListHomes();
282
+
283
+ expect(result).toEqual(homes);
284
+ });
285
+
286
+ it('should throw TokenExpiredError on 401 after retry', async () => {
287
+ mockAxios
288
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
289
+ .reply(200, { result: gatewayData });
290
+
291
+ // Both calls return 401
292
+ const homesAdapter = mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`);
293
+ homesAdapter.replyOnce(401);
294
+ homesAdapter.replyOnce(401);
295
+
296
+ mockAxios.onPost(new RegExp('https://.*.lgeapi.com/oauth/1.0/oauth2/token')).reply(200, {
297
+ access_token: 'new-access-token',
298
+ expires_in: '3600',
299
+ });
300
+
301
+ await expect(apiClient.getListHomes()).rejects.toThrow(TokenExpiredError);
302
+ });
303
+
304
+ it('should not retry on non-401 errors', async () => {
305
+ mockAxios
306
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
307
+ .reply(200, { result: gatewayData });
308
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`).reply(500);
309
+
310
+ await expect(apiClient.getListHomes()).rejects.toThrow();
311
+ expect(mockAxios.history.get.filter((h) => h.url?.includes('/service/homes'))).toHaveLength(1);
312
+ });
313
+ });
314
+
315
+ describe('request resultCode 0102 retry logic', () => {
316
+ it('should retry once on 400 response with resultCode 0102', async () => {
317
+ const homes = [{ homeId: 'home-1' }];
318
+ mockAxios
319
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
320
+ .reply(200, { result: gatewayData });
321
+
322
+ // First call returns 400 with resultCode 0102, second call returns 200
323
+ const homesAdapter = mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`);
324
+ homesAdapter.replyOnce(400, { resultCode: '0102', result: '' });
325
+ homesAdapter.replyOnce(200, { result: { item: homes } });
326
+
327
+ mockAxios.onPost(new RegExp('https://.*.lgeapi.com/oauth/1.0/oauth2/token')).reply(200, {
328
+ access_token: 'new-access-token',
329
+ expires_in: '3600',
330
+ });
331
+
332
+ const result = await apiClient.getListHomes();
333
+
334
+ expect(result).toEqual(homes);
335
+ });
336
+
337
+ it('should throw TokenExpiredError on 400 resultCode 0102 after retry', async () => {
338
+ mockAxios
339
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
340
+ .reply(200, { result: gatewayData });
341
+
342
+ // Both calls return 400 with resultCode 0102
343
+ const homesAdapter = mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`);
344
+ homesAdapter.replyOnce(400, { resultCode: '0102', result: '' });
345
+ homesAdapter.replyOnce(400, { resultCode: '0102', result: '' });
346
+
347
+ mockAxios.onPost(new RegExp('https://.*.lgeapi.com/oauth/1.0/oauth2/token')).reply(200, {
348
+ access_token: 'new-access-token',
349
+ expires_in: '3600',
350
+ });
351
+
352
+ await expect(apiClient.getListHomes()).rejects.toThrow(TokenExpiredError);
353
+ });
354
+
355
+ it('should self-heal sendCommand on 400 resultCode 0102 retry', async () => {
356
+ mockAxios
357
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
358
+ .reply(200, { result: gatewayData });
359
+
360
+ // First call to control-sync returns 400 with resultCode 0102, second call returns 200
361
+ const controlSyncAdapter = mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control-sync`);
362
+ controlSyncAdapter.replyOnce(400, { resultCode: '0102', result: '' });
363
+ controlSyncAdapter.replyOnce(200);
364
+
365
+ mockAxios.onPost(new RegExp('https://.*.lgeapi.com/oauth/1.0/oauth2/token')).reply(200, {
366
+ access_token: 'new-access-token',
367
+ expires_in: '3600',
368
+ });
369
+
370
+ await apiClient.sendCommand('device-123', {
371
+ command: 'Operation',
372
+ dataKey: 'airState.operation',
373
+ dataValue: 1,
374
+ });
375
+
376
+ // Verify two POST calls to control-sync: original + retry
377
+ expect(
378
+ mockAxios.history.post.filter((h) => h.url?.includes('/service/devices/device-123/control-sync')),
379
+ ).toHaveLength(2);
380
+ });
381
+
382
+ it('should not retry on different resultCode under 400', async () => {
383
+ mockAxios
384
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
385
+ .reply(200, { result: gatewayData });
386
+
387
+ // Reply with 400 and different resultCode (not 0102)
388
+ mockAxios
389
+ .onGet(`${gatewayData.thinq2Uri}/service/homes`)
390
+ .reply(400, { resultCode: '0110', result: 'some other error' });
391
+
392
+ await expect(apiClient.getListHomes()).rejects.toThrow();
393
+
394
+ // Verify only one GET call to /service/homes (no retry)
395
+ expect(mockAxios.history.get.filter((h) => h.url?.includes('/service/homes'))).toHaveLength(1);
396
+
397
+ // Verify no token refresh was attempted
398
+ expect(mockAxios.history.post.filter((h) => h.url?.includes('oauth2/token'))).toHaveLength(0);
399
+ });
400
+
401
+ it('should not crash on 400 with no response body', async () => {
402
+ mockAxios
403
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
404
+ .reply(200, { result: gatewayData });
405
+
406
+ // Reply with 400 and no body (undefined data)
407
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`).reply(400);
408
+
409
+ await expect(apiClient.getListHomes()).rejects.toThrow();
410
+
411
+ // Verify only one GET call to /service/homes (no retry)
412
+ expect(mockAxios.history.get.filter((h) => h.url?.includes('/service/homes'))).toHaveLength(1);
413
+
414
+ // Verify no token refresh was attempted
415
+ expect(mockAxios.history.post.filter((h) => h.url?.includes('oauth2/token'))).toHaveLength(0);
416
+ });
417
+
418
+ it('should not crash on 400 with non-JSON string body', async () => {
419
+ mockAxios
420
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
421
+ .reply(200, { result: gatewayData });
422
+
423
+ // Reply with 400 and non-JSON string body
424
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`).reply(400, 'Bad Request');
425
+
426
+ await expect(apiClient.getListHomes()).rejects.toThrow();
427
+
428
+ // Verify only one GET call to /service/homes (no retry)
429
+ expect(mockAxios.history.get.filter((h) => h.url?.includes('/service/homes'))).toHaveLength(1);
430
+
431
+ // Verify no token refresh was attempted
432
+ expect(mockAxios.history.post.filter((h) => h.url?.includes('oauth2/token'))).toHaveLength(0);
433
+ });
434
+ });
435
+
436
+ describe('getMqttRouteInfo', () => {
437
+ it('should fetch MQTT route info from the correct URL', async () => {
438
+ const mqttRouteInfo = { mqttServer: 'mqtt.example.com:8883' };
439
+ mockAxios
440
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
441
+ .reply(200, { result: gatewayData });
442
+ mockAxios.onGet('https://common.lgthinq.com/route').reply(200, { result: mqttRouteInfo });
443
+
444
+ const result = await apiClient.getMqttRouteInfo();
445
+
446
+ expect(result).toEqual(mqttRouteInfo);
447
+ });
448
+
449
+ it('should include auth headers in the request', async () => {
450
+ apiClient.setUserNumber('user-123');
451
+ const mqttRouteInfo = { mqttServer: 'mqtt.example.com:8883' };
452
+ mockAxios
453
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
454
+ .reply(200, { result: gatewayData });
455
+ mockAxios.onGet('https://common.lgthinq.com/route').reply(200, { result: mqttRouteInfo });
456
+
457
+ await apiClient.getMqttRouteInfo();
458
+
459
+ const request = mockAxios.history.get.find((h) => h.url?.includes('common.lgthinq.com'));
460
+ expect(request?.headers?.['x-api-key']).toBeDefined();
461
+ expect(request?.headers?.['x-emp-token']).toBe('access-token-123');
462
+ });
463
+ });
464
+
465
+ describe('registerMqttClient', () => {
466
+ it('should register MQTT client with empty body', async () => {
467
+ mockAxios
468
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
469
+ .reply(200, { result: gatewayData });
470
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/users/client`).reply(200, { result: {} });
471
+
472
+ await apiClient.registerMqttClient();
473
+
474
+ const request = mockAxios.history.post.find((h) => h.url?.includes('/service/users/client'));
475
+ expect(JSON.parse(request?.data as string)).toEqual({});
476
+ });
477
+
478
+ it('should include auth headers in the request', async () => {
479
+ apiClient.setUserNumber('user-123');
480
+ mockAxios
481
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
482
+ .reply(200, { result: gatewayData });
483
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/users/client`).reply(200, { result: {} });
484
+
485
+ await apiClient.registerMqttClient();
486
+
487
+ const request = mockAxios.history.post.find((h) => h.url?.includes('/service/users/client'));
488
+ expect(request?.headers?.['x-api-key']).toBeDefined();
489
+ expect(request?.headers?.['x-emp-token']).toBe('access-token-123');
490
+ });
491
+ });
492
+
493
+ describe('requestMqttCertificate', () => {
494
+ it('should request certificate with CSR body', async () => {
495
+ const csrBody = 'MIICpDCCAYwCAQAwEzERMA8GA1UEAwwIVGVzdCBDU1I=';
496
+ const certificateResponse = {
497
+ certificatePem: 'cert-pem-data',
498
+ subscriptions: ['topic/1', 'topic/2'],
499
+ };
500
+ mockAxios
501
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
502
+ .reply(200, { result: gatewayData });
503
+ mockAxios
504
+ .onPost(`${gatewayData.thinq2Uri}/service/users/client/certificate`)
505
+ .reply(200, { result: certificateResponse });
506
+
507
+ const result = await apiClient.requestMqttCertificate(csrBody);
508
+
509
+ expect(result).toEqual(certificateResponse);
510
+ });
511
+
512
+ it('should send CSR body in correct format', async () => {
513
+ const csrBody = 'MIICpDCCAYwCAQAwEzERMA8GA1UEAwwIVGVzdCBDU1I=';
514
+ mockAxios
515
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
516
+ .reply(200, { result: gatewayData });
517
+ mockAxios
518
+ .onPost(`${gatewayData.thinq2Uri}/service/users/client/certificate`)
519
+ .reply(200, { result: { certificatePem: 'cert', subscriptions: [] } });
520
+
521
+ await apiClient.requestMqttCertificate(csrBody);
522
+
523
+ const request = mockAxios.history.post.find((h) => h.url?.includes('/service/users/client/certificate'));
524
+ expect(JSON.parse(request?.data as string)).toEqual({ csr: csrBody });
525
+ });
526
+
527
+ it('should include auth headers in the request', async () => {
528
+ apiClient.setUserNumber('user-123');
529
+ const csrBody = 'test-csr';
530
+ mockAxios
531
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
532
+ .reply(200, { result: gatewayData });
533
+ mockAxios
534
+ .onPost(`${gatewayData.thinq2Uri}/service/users/client/certificate`)
535
+ .reply(200, { result: { certificatePem: 'cert', subscriptions: [] } });
536
+
537
+ await apiClient.requestMqttCertificate(csrBody);
538
+
539
+ const request = mockAxios.history.post.find((h) => h.url?.includes('/service/users/client/certificate'));
540
+ expect(request?.headers?.['x-api-key']).toBeDefined();
541
+ expect(request?.headers?.['x-emp-token']).toBe('access-token-123');
542
+ });
543
+ });
544
+
545
+ describe('getClientId', () => {
546
+ it('should return fallback API_CLIENT_ID when userNumber not set', () => {
547
+ const clientId = apiClient.getClientId();
548
+
549
+ expect(clientId).toBe('c713ea8e50f657534ff8b9d373dfebfc2ed70b88285c26b8ade49868c0b164d9');
550
+ });
551
+
552
+ it('should return computed clientId after setUserNumber', () => {
553
+ apiClient.setUserNumber('user-123');
554
+
555
+ const clientId = apiClient.getClientId();
556
+
557
+ expect(clientId).not.toBe('c713ea8e50f657534ff8b9d373dfebfc2ed70b88285c26b8ade49868c0b164d9');
558
+ expect(clientId).toBeDefined();
559
+ });
560
+
561
+ it('should return same value as x-client-id header after setUserNumber', async () => {
562
+ apiClient.setUserNumber('user-123');
563
+ const clientId = apiClient.getClientId();
564
+
565
+ mockAxios
566
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
567
+ .reply(200, { result: gatewayData });
568
+ mockAxios.onGet(`${gatewayData.thinq2Uri}/service/homes`).reply(200, { result: { item: [] } });
569
+
570
+ // Call an API method to get the headers
571
+ await apiClient.getListHomes();
572
+
573
+ const request = mockAxios.history.get.find((h) => h.url?.includes('/service/homes'));
574
+ expect(request?.headers?.['x-client-id']).toBe(clientId);
575
+ });
576
+ });
577
+
578
+ describe('getFilterState', () => {
579
+ it('should throw error when deviceId is empty', async () => {
580
+ mockAxios
581
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
582
+ .reply(200, { result: gatewayData });
583
+
584
+ await expect(apiClient.getFilterState('')).rejects.toThrow('Invalid deviceId');
585
+ });
586
+
587
+ it('should throw error when deviceId is whitespace only', async () => {
588
+ mockAxios
589
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
590
+ .reply(200, { result: gatewayData });
591
+
592
+ await expect(apiClient.getFilterState(' ')).rejects.toThrow('Invalid deviceId');
593
+ });
594
+
595
+ it('should return filter state data on successful response', async () => {
596
+ const filterData = {
597
+ 'airState.filterMngStates.useTime': 11,
598
+ 'airState.filterMngStates.maxTime': 720,
599
+ 'airState.filterMngStates.changeDate': 20260919,
600
+ };
601
+ mockAxios
602
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
603
+ .reply(200, { result: gatewayData });
604
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control-sync`).reply(200, {
605
+ resultCode: '0000',
606
+ result: { data: filterData },
607
+ });
608
+
609
+ const result = await apiClient.getFilterState('device-123');
610
+
611
+ expect(result).toEqual(filterData);
612
+ });
613
+
614
+ it('should return undefined when response has no result', async () => {
615
+ mockAxios
616
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
617
+ .reply(200, { result: gatewayData });
618
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control-sync`).reply(200, {
619
+ resultCode: '0000',
620
+ });
621
+
622
+ const result = await apiClient.getFilterState('device-123');
623
+
624
+ expect(result).toBeUndefined();
625
+ });
626
+
627
+ it('should return undefined when response result has no data', async () => {
628
+ mockAxios
629
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
630
+ .reply(200, { result: gatewayData });
631
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control-sync`).reply(200, {
632
+ resultCode: '0000',
633
+ result: {},
634
+ });
635
+
636
+ const result = await apiClient.getFilterState('device-123');
637
+
638
+ expect(result).toBeUndefined();
639
+ });
640
+
641
+ it('should use correct payload with ctrlKey filterMngStateCtrl', async () => {
642
+ const filterData = {
643
+ 'airState.filterMngStates.useTime': 11,
644
+ 'airState.filterMngStates.maxTime': 720,
645
+ };
646
+ mockAxios
647
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
648
+ .reply(200, { result: gatewayData });
649
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control-sync`).reply(200, {
650
+ resultCode: '0000',
651
+ result: { data: filterData },
652
+ });
653
+
654
+ await apiClient.getFilterState('device-123');
655
+
656
+ const request = mockAxios.history.post.find((h) =>
657
+ h.url?.includes('/service/devices/device-123/control-sync'),
658
+ ) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
659
+ const body = JSON.parse(request?.data as string);
660
+ expect(body.ctrlKey).toBe('filterMngStateCtrl');
661
+ expect(body.command).toBe('Get');
662
+ expect(Array.isArray(body.dataGetList)).toBe(true);
663
+ });
664
+
665
+ it('should use correct dataGetList field array', async () => {
666
+ const filterData = {
667
+ 'airState.filterMngStates.useTime': 11,
668
+ 'airState.filterMngStates.maxTime': 720,
669
+ };
670
+ mockAxios
671
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
672
+ .reply(200, { result: gatewayData });
673
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control-sync`).reply(200, {
674
+ resultCode: '0000',
675
+ result: { data: filterData },
676
+ });
677
+
678
+ await apiClient.getFilterState('device-123');
679
+
680
+ const request = mockAxios.history.post.find((h) =>
681
+ h.url?.includes('/service/devices/device-123/control-sync'),
682
+ ) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
683
+ const body = JSON.parse(request?.data as string);
684
+ expect(body.dataGetList).toEqual([
685
+ 'airState.filterMngState.useTime',
686
+ 'airState.filterMngState.remainTime',
687
+ 'airState.filterMngState.maxTime',
688
+ 'airState.filterMngState.changeDate',
689
+ 'airState.filterMngState.type',
690
+ ]);
691
+ });
692
+
693
+ it('should propagate network errors', async () => {
694
+ mockAxios
695
+ .onGet('https://route.lgthinq.com:46030/v1/service/application/gateway-uri')
696
+ .reply(200, { result: gatewayData });
697
+ mockAxios.onPost(`${gatewayData.thinq2Uri}/service/devices/device-123/control-sync`).reply(500);
698
+
699
+ await expect(apiClient.getFilterState('device-123')).rejects.toThrow();
700
+ });
701
+ });
702
+ });