@mp-consulting/homebridge-daikin-cloud 1.3.5 → 1.3.7
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.
- package/LICENSE +39 -1
- package/README.md +5 -3
- package/dist/src/accessories/air-conditioning-accessory.d.ts +2 -2
- package/dist/src/accessories/air-conditioning-accessory.d.ts.map +1 -1
- package/dist/src/accessories/air-conditioning-accessory.js.map +1 -1
- package/dist/src/accessories/altherma-accessory.d.ts +2 -2
- package/dist/src/accessories/altherma-accessory.d.ts.map +1 -1
- package/dist/src/accessories/altherma-accessory.js.map +1 -1
- package/dist/src/accessories/base-accessory.d.ts +6 -6
- package/dist/src/accessories/base-accessory.d.ts.map +1 -1
- package/dist/src/accessories/base-accessory.js +15 -15
- package/dist/src/accessories/base-accessory.js.map +1 -1
- package/dist/src/api/daikin-api.d.ts +26 -26
- package/dist/src/api/daikin-api.d.ts.map +1 -1
- package/dist/src/api/daikin-api.js +68 -42
- package/dist/src/api/daikin-api.js.map +1 -1
- package/dist/src/api/daikin-cloud.repository.d.ts.map +1 -1
- package/dist/src/api/daikin-cloud.repository.js +22 -14
- package/dist/src/api/daikin-cloud.repository.js.map +1 -1
- package/dist/src/api/daikin-controller.d.ts +41 -47
- package/dist/src/api/daikin-controller.d.ts.map +1 -1
- package/dist/src/api/daikin-controller.js +40 -64
- package/dist/src/api/daikin-controller.js.map +1 -1
- package/dist/src/api/daikin-device.d.ts +36 -31
- package/dist/src/api/daikin-device.d.ts.map +1 -1
- package/dist/src/api/daikin-device.js +45 -31
- package/dist/src/api/daikin-device.js.map +1 -1
- package/dist/src/api/daikin-mobile-oauth.d.ts +20 -20
- package/dist/src/api/daikin-mobile-oauth.d.ts.map +1 -1
- package/dist/src/api/daikin-mobile-oauth.js +49 -44
- package/dist/src/api/daikin-mobile-oauth.js.map +1 -1
- package/dist/src/api/daikin-oauth.d.ts +32 -32
- package/dist/src/api/daikin-oauth.d.ts.map +1 -1
- package/dist/src/api/daikin-oauth.js +64 -56
- package/dist/src/api/daikin-oauth.js.map +1 -1
- package/dist/src/api/daikin-schemas.d.ts +476 -351
- package/dist/src/api/daikin-schemas.d.ts.map +1 -1
- package/dist/src/api/daikin-schemas.js +11 -42
- package/dist/src/api/daikin-schemas.js.map +1 -1
- package/dist/src/api/daikin-types.d.ts +5 -1
- package/dist/src/api/daikin-types.d.ts.map +1 -1
- package/dist/src/api/daikin-types.js.map +1 -1
- package/dist/src/api/daikin-websocket.d.ts +31 -32
- package/dist/src/api/daikin-websocket.d.ts.map +1 -1
- package/dist/src/api/daikin-websocket.js +55 -35
- package/dist/src/api/daikin-websocket.js.map +1 -1
- package/dist/src/api/index.d.ts +2 -1
- package/dist/src/api/index.d.ts.map +1 -1
- package/dist/src/api/index.js +3 -1
- package/dist/src/api/index.js.map +1 -1
- package/dist/src/api/token-storage.d.ts +21 -0
- package/dist/src/api/token-storage.d.ts.map +1 -0
- package/dist/src/api/token-storage.js +90 -0
- package/dist/src/api/token-storage.js.map +1 -0
- package/dist/src/config/config-manager.d.ts +33 -33
- package/dist/src/config/config-manager.d.ts.map +1 -1
- package/dist/src/config/config-manager.js +33 -33
- package/dist/src/config/config-manager.js.map +1 -1
- package/dist/src/constants/api.constants.d.ts +4 -0
- package/dist/src/constants/api.constants.d.ts.map +1 -1
- package/dist/src/constants/api.constants.js +5 -1
- package/dist/src/constants/api.constants.js.map +1 -1
- package/dist/src/constants/device.constants.d.ts +4 -0
- package/dist/src/constants/device.constants.d.ts.map +1 -1
- package/dist/src/constants/device.constants.js +5 -1
- package/dist/src/constants/device.constants.js.map +1 -1
- package/dist/src/device/accessory-factory.d.ts +10 -10
- package/dist/src/device/accessory-factory.d.ts.map +1 -1
- package/dist/src/device/accessory-factory.js +7 -7
- package/dist/src/device/accessory-factory.js.map +1 -1
- package/dist/src/device/capability-detector.d.ts +8 -8
- package/dist/src/device/capability-detector.d.ts.map +1 -1
- package/dist/src/device/capability-detector.js +6 -6
- package/dist/src/device/capability-detector.js.map +1 -1
- package/dist/src/device/capability-docs.d.ts +1 -9
- package/dist/src/device/capability-docs.d.ts.map +1 -1
- package/dist/src/device/capability-docs.js +19 -73
- package/dist/src/device/capability-docs.js.map +1 -1
- package/dist/src/device/profiles/device-profile.d.ts +1 -1
- package/dist/src/device/profiles/device-profile.d.ts.map +1 -1
- package/dist/src/device/profiles/device-profile.js +4 -4
- package/dist/src/device/profiles/device-profile.js.map +1 -1
- package/dist/src/features/base-feature.d.ts +2 -2
- package/dist/src/features/base-feature.d.ts.map +1 -1
- package/dist/src/features/base-feature.js +2 -3
- package/dist/src/features/base-feature.js.map +1 -1
- package/dist/src/features/feature-manager.d.ts +8 -16
- package/dist/src/features/feature-manager.d.ts.map +1 -1
- package/dist/src/features/feature-manager.js +5 -17
- package/dist/src/features/feature-manager.js.map +1 -1
- package/dist/src/features/modes/dry-operation-mode.feature.d.ts +1 -1
- package/dist/src/features/modes/dry-operation-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/dry-operation-mode.feature.js.map +1 -1
- package/dist/src/features/modes/econo-mode.feature.d.ts +1 -1
- package/dist/src/features/modes/econo-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/econo-mode.feature.js.map +1 -1
- package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts +1 -1
- package/dist/src/features/modes/fan-only-operation-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/fan-only-operation-mode.feature.js.map +1 -1
- package/dist/src/features/modes/indoor-silent-mode.feature.d.ts +1 -1
- package/dist/src/features/modes/indoor-silent-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/indoor-silent-mode.feature.js.map +1 -1
- package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts +1 -1
- package/dist/src/features/modes/outdoor-silent-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/outdoor-silent-mode.feature.js.map +1 -1
- package/dist/src/features/modes/powerful-mode.feature.d.ts +1 -1
- package/dist/src/features/modes/powerful-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/powerful-mode.feature.js.map +1 -1
- package/dist/src/features/modes/streamer-mode.feature.d.ts +1 -1
- package/dist/src/features/modes/streamer-mode.feature.d.ts.map +1 -1
- package/dist/src/features/modes/streamer-mode.feature.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/platform.d.ts +11 -8
- package/dist/src/platform.d.ts.map +1 -1
- package/dist/src/platform.js +64 -15
- package/dist/src/platform.js.map +1 -1
- package/dist/src/services/climate-control.service.d.ts +8 -2
- package/dist/src/services/climate-control.service.d.ts.map +1 -1
- package/dist/src/services/climate-control.service.js +59 -58
- package/dist/src/services/climate-control.service.js.map +1 -1
- package/dist/src/services/hot-water-tank.service.d.ts +6 -2
- package/dist/src/services/hot-water-tank.service.d.ts.map +1 -1
- package/dist/src/services/hot-water-tank.service.js +33 -31
- package/dist/src/services/hot-water-tank.service.js.map +1 -1
- package/dist/src/types/daikin-enums.js +12 -12
- package/dist/src/types/daikin-enums.js.map +1 -1
- package/dist/src/types/device-capabilities.d.ts +1 -1
- package/dist/src/types/device-capabilities.d.ts.map +1 -1
- package/dist/src/utils/log-context.d.ts +23 -23
- package/dist/src/utils/log-context.d.ts.map +1 -1
- package/dist/src/utils/log-context.js +28 -28
- package/dist/src/utils/log-context.js.map +1 -1
- package/dist/src/utils/strings.d.ts.map +1 -1
- package/dist/src/utils/strings.js.map +1 -1
- package/dist/src/utils/update-mapper.d.ts +16 -16
- package/dist/src/utils/update-mapper.d.ts.map +1 -1
- package/dist/src/utils/update-mapper.js +14 -14
- package/dist/src/utils/update-mapper.js.map +1 -1
- package/homebridge-ui/public/index.html +2 -2
- package/homebridge-ui/public/script.js +957 -898
- package/homebridge-ui/server.js +746 -678
- package/package.json +29 -27
- package/.claude/settings.json +0 -3
- package/.claude/settings.local.json +0 -29
- package/CHANGELOG.md +0 -103
- package/CLAUDE.md +0 -269
- package/config.md +0 -2
- package/dist/src/api/daikin-device-tracker.d.ts +0 -97
- package/dist/src/api/daikin-device-tracker.d.ts.map +0 -1
- package/dist/src/api/daikin-device-tracker.js +0 -136
- package/dist/src/api/daikin-device-tracker.js.map +0 -1
- package/dist/src/api/http-interceptor.d.ts +0 -99
- package/dist/src/api/http-interceptor.d.ts.map +0 -1
- package/dist/src/api/http-interceptor.js +0 -177
- package/dist/src/api/http-interceptor.js.map +0 -1
- package/dist/src/di/service-container.d.ts +0 -92
- package/dist/src/di/service-container.d.ts.map +0 -1
- package/dist/src/di/service-container.js +0 -156
- package/dist/src/di/service-container.js.map +0 -1
- package/dist/src/features/feature-registry.d.ts +0 -100
- package/dist/src/features/feature-registry.d.ts.map +0 -1
- package/dist/src/features/feature-registry.js +0 -142
- package/dist/src/features/feature-registry.js.map +0 -1
- package/dist/src/services/service-factory.d.ts +0 -46
- package/dist/src/services/service-factory.d.ts.map +0 -1
- package/dist/src/services/service-factory.js +0 -72
- package/dist/src/services/service-factory.js.map +0 -1
- package/dist/src/utils/error-handler.d.ts +0 -101
- package/dist/src/utils/error-handler.d.ts.map +0 -1
- package/dist/src/utils/error-handler.js +0 -251
- package/dist/src/utils/error-handler.js.map +0 -1
- package/dist/src/utils/retry.d.ts +0 -42
- package/dist/src/utils/retry.d.ts.map +0 -1
- package/dist/src/utils/retry.js +0 -70
- package/dist/src/utils/retry.js.map +0 -1
- package/docs/ARCHITECTURE.md +0 -645
- package/docs/IMPLEMENTATION_GUIDE.md +0 -899
- package/docs/IMPROVEMENTS_SUMMARY.md +0 -415
- package/docs/NEXT_STEPS.md +0 -368
- package/docs/Screenshot 2024-07-04 at 18.41.28.png +0 -0
- package/docs/TROUBLESHOOTING.md +0 -475
- package/docs/api-response-for-BRP069A8x.json +0 -520
- package/docs/api-response-for-BRP069C4x-2.json +0 -881
- package/docs/api-response-for-BRP069C4x.json +0 -916
- package/docs/api-response-for-altherma.json +0 -759
- package/docs/api-response-for-altherma2.json +0 -2735
- package/docs/api-response-with-multiple-devices-incl-heatpump.json +0 -2544
- package/docs/cr-insance-altherma-id-0.json +0 -834
- package/docs/mock-air-to-air-dx23.json +0 -759
- package/docs/mock-air-to-air-dx4.json +0 -1134
- package/docs/mock-airpurifier-with-humidifier.json +0 -732
- package/docs/mock-airpurifier.json +0 -450
- package/docs/mock-altherma-air-to-water-lan.json +0 -845
- package/docs/mock-altherma-air-to-water-wlan.json +0 -845
- package/docs/mock-d2cnd-gas-boiler.json +0 -649
- package/docs/setpointmode-vs-controlmode-vs-setpoints-vs-sensorydata.txt +0 -6
- package/images/fan-speed.jpeg +0 -0
- package/images/homekit-controls.jpeg +0 -0
- package/images/homekit-settings.jpeg +0 -0
- package/images/swing-mode.png +0 -0
- package/jest.config.ts +0 -13
- package/test/fixtures/altherma-crSense-2.ts +0 -834
- package/test/fixtures/altherma-fraction.ts +0 -718
- package/test/fixtures/altherma-heat-pump-2.ts +0 -479
- package/test/fixtures/altherma-heat-pump.ts +0 -757
- package/test/fixtures/altherma-miladcerkic-off.ts +0 -524
- package/test/fixtures/altherma-miladcerkic.ts +0 -524
- package/test/fixtures/altherma-v1ckoeln.ts +0 -644
- package/test/fixtures/altherma-with-embedded-id-zero.ts +0 -834
- package/test/fixtures/dx23-airco-2.ts +0 -343
- package/test/fixtures/dx23-airco.ts +0 -518
- package/test/fixtures/dx4-airco.ts +0 -914
- package/test/fixtures/unknown-jan.ts +0 -488
- package/test/fixtures/unknown-kitchen-guests.ts +0 -488
- package/test/helpers/test-isolation.ts +0 -228
- package/test/integration/air-conditioning.test.ts +0 -410
- package/test/integration/altherma.test.ts +0 -289
- package/test/integration/platform.test.ts +0 -118
- package/test/mocks/index.ts +0 -27
- package/test/test-gigya-auth.js +0 -443
- package/test/test-mobile-oauth.js +0 -175
- package/test/test-websocket-mobile.js +0 -123
- package/test/test-websocket.js +0 -116
- package/test/unit/api/__snapshots__/daikinCloud.test.ts.snap +0 -1320
- package/test/unit/api/daikin-api.test.ts +0 -384
- package/test/unit/api/daikin-oauth.test.ts +0 -214
- package/test/unit/api/daikinCloud.test.ts +0 -12
- package/test/unit/config/config-manager.test.ts +0 -271
- package/test/unit/device/daikin-device.test.ts +0 -79
- package/test/unit/services/hot-water-tank.service.test.ts +0 -123
- package/test/unit/utils/error-handler.test.ts +0 -274
- package/test/unit/utils/log-context.test.ts +0 -271
|
@@ -1,384 +0,0 @@
|
|
|
1
|
-
import {DaikinApi, RateLimitedError, ApiTimeoutError} from '../../../src/api/daikin-api';
|
|
2
|
-
import {OAuthProvider, TokenSet} from '../../../src/api/daikin-types';
|
|
3
|
-
import {MAX_RETRY_ATTEMPTS} from '../../../src/constants';
|
|
4
|
-
import * as https from 'node:https';
|
|
5
|
-
|
|
6
|
-
jest.mock('node:https');
|
|
7
|
-
|
|
8
|
-
describe('DaikinApi', () => {
|
|
9
|
-
let mockOAuth: jest.Mocked<OAuthProvider>;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
jest.clearAllMocks();
|
|
13
|
-
jest.useFakeTimers();
|
|
14
|
-
mockOAuth = {
|
|
15
|
-
getAccessToken: jest.fn().mockResolvedValue('valid-token'),
|
|
16
|
-
isAuthenticated: jest.fn().mockReturnValue(true),
|
|
17
|
-
refreshToken: jest.fn().mockResolvedValue({
|
|
18
|
-
access_token: 'new-token',
|
|
19
|
-
refresh_token: 'new-refresh-token',
|
|
20
|
-
token_type: 'Bearer',
|
|
21
|
-
expires_in: 3600,
|
|
22
|
-
} as TokenSet),
|
|
23
|
-
};
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
jest.useRealTimers();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
// Helper to run async operations with fake timers
|
|
31
|
-
async function runWithTimers<T>(promise: Promise<T>): Promise<T> {
|
|
32
|
-
const result = promise;
|
|
33
|
-
// Run timers until all pending timers are exhausted
|
|
34
|
-
await jest.runAllTimersAsync();
|
|
35
|
-
return result;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function mockHttpsRequest(statusCode: number, body: string, headers: Record<string, string> = {}) {
|
|
39
|
-
const mockResponse: any = {
|
|
40
|
-
statusCode,
|
|
41
|
-
headers,
|
|
42
|
-
on: jest.fn((event, callback) => {
|
|
43
|
-
if (event === 'data') {
|
|
44
|
-
callback(body);
|
|
45
|
-
}
|
|
46
|
-
if (event === 'end') {
|
|
47
|
-
callback();
|
|
48
|
-
}
|
|
49
|
-
return mockResponse;
|
|
50
|
-
}),
|
|
51
|
-
};
|
|
52
|
-
const mockRequest = {
|
|
53
|
-
on: jest.fn().mockReturnThis(),
|
|
54
|
-
write: jest.fn(),
|
|
55
|
-
end: jest.fn(),
|
|
56
|
-
};
|
|
57
|
-
(https.request as jest.Mock).mockImplementation((options, callback) => {
|
|
58
|
-
callback(mockResponse);
|
|
59
|
-
return mockRequest;
|
|
60
|
-
});
|
|
61
|
-
return {mockRequest, mockResponse};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
describe('getDevices', () => {
|
|
65
|
-
it('should return devices on successful request', async () => {
|
|
66
|
-
const devices = [{id: 'device-1', managementPoints: []}];
|
|
67
|
-
mockHttpsRequest(200, JSON.stringify(devices));
|
|
68
|
-
|
|
69
|
-
const api = new DaikinApi(mockOAuth);
|
|
70
|
-
const result = await api.getDevices();
|
|
71
|
-
|
|
72
|
-
expect(result).toEqual(devices);
|
|
73
|
-
expect(mockOAuth.getAccessToken).toHaveBeenCalled();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should refresh token and retry on 401 Unauthorized with exponential backoff', async () => {
|
|
77
|
-
const devices = [{id: 'device-1', managementPoints: []}];
|
|
78
|
-
let callCount = 0;
|
|
79
|
-
|
|
80
|
-
const mockRequest = {
|
|
81
|
-
on: jest.fn().mockReturnThis(),
|
|
82
|
-
write: jest.fn(),
|
|
83
|
-
end: jest.fn(),
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
(https.request as jest.Mock).mockImplementation((options, callback) => {
|
|
87
|
-
callCount++;
|
|
88
|
-
const statusCode = callCount === 1 ? 401 : 200;
|
|
89
|
-
const body = callCount === 1 ? 'Unauthorized' : JSON.stringify(devices);
|
|
90
|
-
|
|
91
|
-
const mockResponse: any = {
|
|
92
|
-
statusCode,
|
|
93
|
-
headers: {},
|
|
94
|
-
on: jest.fn((event, cb) => {
|
|
95
|
-
if (event === 'data') {
|
|
96
|
-
cb(body);
|
|
97
|
-
}
|
|
98
|
-
if (event === 'end') {
|
|
99
|
-
cb();
|
|
100
|
-
}
|
|
101
|
-
return mockResponse;
|
|
102
|
-
}),
|
|
103
|
-
};
|
|
104
|
-
callback(mockResponse);
|
|
105
|
-
return mockRequest;
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
// After refresh, return a new token
|
|
109
|
-
mockOAuth.getAccessToken
|
|
110
|
-
.mockResolvedValueOnce('expired-token')
|
|
111
|
-
.mockResolvedValueOnce('new-token');
|
|
112
|
-
|
|
113
|
-
const api = new DaikinApi(mockOAuth);
|
|
114
|
-
const result = await runWithTimers(api.getDevices());
|
|
115
|
-
|
|
116
|
-
expect(result).toEqual(devices);
|
|
117
|
-
expect(mockOAuth.refreshToken).toHaveBeenCalledTimes(1);
|
|
118
|
-
expect(https.request).toHaveBeenCalledTimes(2);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('should throw error if refresh fails on 401', async () => {
|
|
122
|
-
mockHttpsRequest(401, 'Unauthorized');
|
|
123
|
-
mockOAuth.refreshToken.mockRejectedValue(new Error('Refresh failed'));
|
|
124
|
-
|
|
125
|
-
const api = new DaikinApi(mockOAuth);
|
|
126
|
-
|
|
127
|
-
await expect(api.getDevices()).rejects.toThrow(
|
|
128
|
-
'Unauthorized (401): Token refresh failed. Please re-authenticate.',
|
|
129
|
-
);
|
|
130
|
-
expect(mockOAuth.refreshToken).toHaveBeenCalledTimes(1);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('should throw error if retry after refresh still returns 401', async () => {
|
|
134
|
-
jest.useRealTimers(); // Use real timers for this test
|
|
135
|
-
// Always return 401
|
|
136
|
-
mockHttpsRequest(401, 'Unauthorized');
|
|
137
|
-
|
|
138
|
-
const api = new DaikinApi(mockOAuth);
|
|
139
|
-
|
|
140
|
-
// Temporarily override sleep to be instant for testing
|
|
141
|
-
const originalSleep = (api as unknown as { sleep: (ms: number) => Promise<void> }).sleep;
|
|
142
|
-
(api as unknown as { sleep: (ms: number) => Promise<void> }).sleep = () => Promise.resolve();
|
|
143
|
-
|
|
144
|
-
await expect(api.getDevices()).rejects.toThrow(
|
|
145
|
-
'Unauthorized (401): Token expired or invalid',
|
|
146
|
-
);
|
|
147
|
-
// Should retry MAX_RETRY_ATTEMPTS times
|
|
148
|
-
expect(mockOAuth.refreshToken).toHaveBeenCalledTimes(MAX_RETRY_ATTEMPTS);
|
|
149
|
-
// Initial request + MAX_RETRY_ATTEMPTS retries
|
|
150
|
-
expect(https.request).toHaveBeenCalledTimes(MAX_RETRY_ATTEMPTS + 1);
|
|
151
|
-
|
|
152
|
-
// Restore original sleep
|
|
153
|
-
(api as unknown as { sleep: (ms: number) => Promise<void> }).sleep = originalSleep;
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('should not retry more than MAX_RETRY_ATTEMPTS times on 401', async () => {
|
|
157
|
-
jest.useRealTimers(); // Use real timers for this test
|
|
158
|
-
// Always return 401
|
|
159
|
-
mockHttpsRequest(401, 'Unauthorized');
|
|
160
|
-
|
|
161
|
-
const api = new DaikinApi(mockOAuth);
|
|
162
|
-
|
|
163
|
-
// Temporarily override sleep to be instant for testing
|
|
164
|
-
(api as unknown as { sleep: (ms: number) => Promise<void> }).sleep = () => Promise.resolve();
|
|
165
|
-
|
|
166
|
-
await expect(api.getDevices()).rejects.toThrow('Unauthorized');
|
|
167
|
-
// Should only refresh MAX_RETRY_ATTEMPTS times
|
|
168
|
-
expect(mockOAuth.refreshToken).toHaveBeenCalledTimes(MAX_RETRY_ATTEMPTS);
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
describe('rate limiting', () => {
|
|
173
|
-
it('should throw RateLimitedError on 429', async () => {
|
|
174
|
-
mockHttpsRequest(429, 'Too Many Requests', {'retry-after': '60'});
|
|
175
|
-
|
|
176
|
-
const api = new DaikinApi(mockOAuth);
|
|
177
|
-
|
|
178
|
-
await expect(api.getDevices()).rejects.toThrow(RateLimitedError);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('should block subsequent requests after rate limit', async () => {
|
|
182
|
-
mockHttpsRequest(429, 'Too Many Requests', {'retry-after': '60'});
|
|
183
|
-
|
|
184
|
-
const api = new DaikinApi(mockOAuth);
|
|
185
|
-
|
|
186
|
-
await expect(api.getDevices()).rejects.toThrow(RateLimitedError);
|
|
187
|
-
expect(api.isRateLimited()).toBe(true);
|
|
188
|
-
|
|
189
|
-
// Reset mock to return 200, but should still be blocked
|
|
190
|
-
mockHttpsRequest(200, '[]');
|
|
191
|
-
|
|
192
|
-
await expect(api.getDevices()).rejects.toThrow(
|
|
193
|
-
'API request blocked due to rate limit',
|
|
194
|
-
);
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
describe('gateway errors', () => {
|
|
199
|
-
it('should retry on 504 Gateway Timeout and succeed', async () => {
|
|
200
|
-
const devices = [{id: 'device-1', managementPoints: []}];
|
|
201
|
-
let callCount = 0;
|
|
202
|
-
|
|
203
|
-
const mockRequest = {
|
|
204
|
-
on: jest.fn().mockReturnThis(),
|
|
205
|
-
write: jest.fn(),
|
|
206
|
-
end: jest.fn(),
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
(https.request as jest.Mock).mockImplementation((options, callback) => {
|
|
210
|
-
callCount++;
|
|
211
|
-
// First call returns 504, second returns 200
|
|
212
|
-
const statusCode = callCount === 1 ? 504 : 200;
|
|
213
|
-
const body = callCount === 1 ? 'Gateway Timeout' : JSON.stringify(devices);
|
|
214
|
-
|
|
215
|
-
const mockResponse: any = {
|
|
216
|
-
statusCode,
|
|
217
|
-
headers: {},
|
|
218
|
-
on: jest.fn((event, cb) => {
|
|
219
|
-
if (event === 'data') {
|
|
220
|
-
cb(body);
|
|
221
|
-
}
|
|
222
|
-
if (event === 'end') {
|
|
223
|
-
cb();
|
|
224
|
-
}
|
|
225
|
-
return mockResponse;
|
|
226
|
-
}),
|
|
227
|
-
};
|
|
228
|
-
callback(mockResponse);
|
|
229
|
-
return mockRequest;
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
const api = new DaikinApi(mockOAuth);
|
|
233
|
-
const result = await runWithTimers(api.getDevices());
|
|
234
|
-
|
|
235
|
-
expect(result).toEqual(devices);
|
|
236
|
-
expect(https.request).toHaveBeenCalledTimes(2);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('should retry on 502 Bad Gateway and succeed', async () => {
|
|
240
|
-
const devices = [{id: 'device-1', managementPoints: []}];
|
|
241
|
-
let callCount = 0;
|
|
242
|
-
|
|
243
|
-
const mockRequest = {
|
|
244
|
-
on: jest.fn().mockReturnThis(),
|
|
245
|
-
write: jest.fn(),
|
|
246
|
-
end: jest.fn(),
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
(https.request as jest.Mock).mockImplementation((options, callback) => {
|
|
250
|
-
callCount++;
|
|
251
|
-
const statusCode = callCount === 1 ? 502 : 200;
|
|
252
|
-
const body = callCount === 1 ? 'Bad Gateway' : JSON.stringify(devices);
|
|
253
|
-
|
|
254
|
-
const mockResponse: any = {
|
|
255
|
-
statusCode,
|
|
256
|
-
headers: {},
|
|
257
|
-
on: jest.fn((event, cb) => {
|
|
258
|
-
if (event === 'data') {
|
|
259
|
-
cb(body);
|
|
260
|
-
}
|
|
261
|
-
if (event === 'end') {
|
|
262
|
-
cb();
|
|
263
|
-
}
|
|
264
|
-
return mockResponse;
|
|
265
|
-
}),
|
|
266
|
-
};
|
|
267
|
-
callback(mockResponse);
|
|
268
|
-
return mockRequest;
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
const api = new DaikinApi(mockOAuth);
|
|
272
|
-
const result = await runWithTimers(api.getDevices());
|
|
273
|
-
|
|
274
|
-
expect(result).toEqual(devices);
|
|
275
|
-
expect(https.request).toHaveBeenCalledTimes(2);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
it('should retry on 503 Service Unavailable and succeed', async () => {
|
|
279
|
-
const devices = [{id: 'device-1', managementPoints: []}];
|
|
280
|
-
let callCount = 0;
|
|
281
|
-
|
|
282
|
-
const mockRequest = {
|
|
283
|
-
on: jest.fn().mockReturnThis(),
|
|
284
|
-
write: jest.fn(),
|
|
285
|
-
end: jest.fn(),
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
(https.request as jest.Mock).mockImplementation((options, callback) => {
|
|
289
|
-
callCount++;
|
|
290
|
-
const statusCode = callCount === 1 ? 503 : 200;
|
|
291
|
-
const body = callCount === 1 ? 'Service Unavailable' : JSON.stringify(devices);
|
|
292
|
-
|
|
293
|
-
const mockResponse: any = {
|
|
294
|
-
statusCode,
|
|
295
|
-
headers: {},
|
|
296
|
-
on: jest.fn((event, cb) => {
|
|
297
|
-
if (event === 'data') {
|
|
298
|
-
cb(body);
|
|
299
|
-
}
|
|
300
|
-
if (event === 'end') {
|
|
301
|
-
cb();
|
|
302
|
-
}
|
|
303
|
-
return mockResponse;
|
|
304
|
-
}),
|
|
305
|
-
};
|
|
306
|
-
callback(mockResponse);
|
|
307
|
-
return mockRequest;
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
const api = new DaikinApi(mockOAuth);
|
|
311
|
-
const result = await runWithTimers(api.getDevices());
|
|
312
|
-
|
|
313
|
-
expect(result).toEqual(devices);
|
|
314
|
-
expect(https.request).toHaveBeenCalledTimes(2);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it('should throw ApiTimeoutError after exhausting retries on 504', async () => {
|
|
318
|
-
// Always return 504
|
|
319
|
-
mockHttpsRequest(504, 'Gateway Timeout');
|
|
320
|
-
|
|
321
|
-
const api = new DaikinApi(mockOAuth);
|
|
322
|
-
|
|
323
|
-
// Temporarily override sleep to be instant for testing
|
|
324
|
-
(api as unknown as { sleep: (ms: number) => Promise<void> }).sleep = () => Promise.resolve();
|
|
325
|
-
|
|
326
|
-
await expect(api.getDevices()).rejects.toThrow(ApiTimeoutError);
|
|
327
|
-
// Initial request + MAX_RETRY_ATTEMPTS retries
|
|
328
|
-
expect(https.request).toHaveBeenCalledTimes(MAX_RETRY_ATTEMPTS + 1);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it('should include status code and attempts in ApiTimeoutError', async () => {
|
|
332
|
-
mockHttpsRequest(504, 'Gateway Timeout');
|
|
333
|
-
|
|
334
|
-
const api = new DaikinApi(mockOAuth);
|
|
335
|
-
(api as unknown as { sleep: (ms: number) => Promise<void> }).sleep = () => Promise.resolve();
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
await api.getDevices();
|
|
339
|
-
fail('Expected ApiTimeoutError to be thrown');
|
|
340
|
-
} catch (error) {
|
|
341
|
-
expect(error).toBeInstanceOf(ApiTimeoutError);
|
|
342
|
-
const timeoutError = error as ApiTimeoutError;
|
|
343
|
-
expect(timeoutError.statusCode).toBe(504);
|
|
344
|
-
expect(timeoutError.attemptsMade).toBe(MAX_RETRY_ATTEMPTS + 1);
|
|
345
|
-
expect(timeoutError.message).toContain('Gateway Timeout');
|
|
346
|
-
expect(timeoutError.message).toContain('504');
|
|
347
|
-
}
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
it('should throw ApiTimeoutError with correct name for 502', async () => {
|
|
351
|
-
mockHttpsRequest(502, 'Bad Gateway');
|
|
352
|
-
|
|
353
|
-
const api = new DaikinApi(mockOAuth);
|
|
354
|
-
(api as unknown as { sleep: (ms: number) => Promise<void> }).sleep = () => Promise.resolve();
|
|
355
|
-
|
|
356
|
-
try {
|
|
357
|
-
await api.getDevices();
|
|
358
|
-
fail('Expected ApiTimeoutError to be thrown');
|
|
359
|
-
} catch (error) {
|
|
360
|
-
expect(error).toBeInstanceOf(ApiTimeoutError);
|
|
361
|
-
const timeoutError = error as ApiTimeoutError;
|
|
362
|
-
expect(timeoutError.statusCode).toBe(502);
|
|
363
|
-
expect(timeoutError.message).toContain('Bad Gateway');
|
|
364
|
-
}
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it('should throw ApiTimeoutError with correct name for 503', async () => {
|
|
368
|
-
mockHttpsRequest(503, 'Service Unavailable');
|
|
369
|
-
|
|
370
|
-
const api = new DaikinApi(mockOAuth);
|
|
371
|
-
(api as unknown as { sleep: (ms: number) => Promise<void> }).sleep = () => Promise.resolve();
|
|
372
|
-
|
|
373
|
-
try {
|
|
374
|
-
await api.getDevices();
|
|
375
|
-
fail('Expected ApiTimeoutError to be thrown');
|
|
376
|
-
} catch (error) {
|
|
377
|
-
expect(error).toBeInstanceOf(ApiTimeoutError);
|
|
378
|
-
const timeoutError = error as ApiTimeoutError;
|
|
379
|
-
expect(timeoutError.statusCode).toBe(503);
|
|
380
|
-
expect(timeoutError.message).toContain('Service Unavailable');
|
|
381
|
-
}
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
});
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import {DaikinOAuth} from '../../../src/api/daikin-oauth';
|
|
2
|
-
import * as fs from 'node:fs';
|
|
3
|
-
import * as https from 'node:https';
|
|
4
|
-
|
|
5
|
-
jest.mock('node:fs');
|
|
6
|
-
jest.mock('node:https');
|
|
7
|
-
|
|
8
|
-
describe('DaikinOAuth', () => {
|
|
9
|
-
const mockConfig = {
|
|
10
|
-
clientId: 'test-client-id',
|
|
11
|
-
clientSecret: 'test-client-secret',
|
|
12
|
-
callbackServerExternalAddress: '192.168.1.1',
|
|
13
|
-
callbackServerPort: 8582,
|
|
14
|
-
tokenFilePath: '/tmp/test-token.json',
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
jest.clearAllMocks();
|
|
19
|
-
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe('getAccessToken', () => {
|
|
23
|
-
it('should return the access token if not expired', async () => {
|
|
24
|
-
const futureExpiry = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
|
|
25
|
-
const tokenSet = {
|
|
26
|
-
access_token: 'valid-token',
|
|
27
|
-
refresh_token: 'refresh-token',
|
|
28
|
-
token_type: 'Bearer',
|
|
29
|
-
expires_at: futureExpiry,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
33
|
-
(fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(tokenSet));
|
|
34
|
-
|
|
35
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
36
|
-
const token = await oauth.getAccessToken();
|
|
37
|
-
|
|
38
|
-
expect(token).toBe('valid-token');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('should refresh the token if expired', async () => {
|
|
42
|
-
const pastExpiry = Math.floor(Date.now() / 1000) - 100; // 100 seconds ago
|
|
43
|
-
const tokenSet = {
|
|
44
|
-
access_token: 'expired-token',
|
|
45
|
-
refresh_token: 'refresh-token',
|
|
46
|
-
token_type: 'Bearer',
|
|
47
|
-
expires_at: pastExpiry,
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const newTokenSet = {
|
|
51
|
-
access_token: 'new-token',
|
|
52
|
-
refresh_token: 'new-refresh-token',
|
|
53
|
-
token_type: 'Bearer',
|
|
54
|
-
expires_in: 3600,
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
58
|
-
(fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(tokenSet));
|
|
59
|
-
|
|
60
|
-
// Mock the HTTPS request for token refresh
|
|
61
|
-
const mockResponse = {
|
|
62
|
-
on: jest.fn((event, callback) => {
|
|
63
|
-
if (event === 'data') {
|
|
64
|
-
callback(JSON.stringify(newTokenSet));
|
|
65
|
-
}
|
|
66
|
-
if (event === 'end') {
|
|
67
|
-
callback();
|
|
68
|
-
}
|
|
69
|
-
}),
|
|
70
|
-
};
|
|
71
|
-
const mockRequest = {
|
|
72
|
-
on: jest.fn(),
|
|
73
|
-
write: jest.fn(),
|
|
74
|
-
end: jest.fn(),
|
|
75
|
-
};
|
|
76
|
-
(https.request as jest.Mock).mockImplementation((options, callback) => {
|
|
77
|
-
callback(mockResponse);
|
|
78
|
-
return mockRequest;
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
82
|
-
const token = await oauth.getAccessToken();
|
|
83
|
-
|
|
84
|
-
expect(token).toBe('new-token');
|
|
85
|
-
expect(https.request).toHaveBeenCalled();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('should refresh the token if about to expire (within 10 seconds)', async () => {
|
|
89
|
-
const soonExpiry = Math.floor(Date.now() / 1000) + 5; // 5 seconds from now
|
|
90
|
-
const tokenSet = {
|
|
91
|
-
access_token: 'soon-expiring-token',
|
|
92
|
-
refresh_token: 'refresh-token',
|
|
93
|
-
token_type: 'Bearer',
|
|
94
|
-
expires_at: soonExpiry,
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const newTokenSet = {
|
|
98
|
-
access_token: 'refreshed-token',
|
|
99
|
-
refresh_token: 'new-refresh-token',
|
|
100
|
-
token_type: 'Bearer',
|
|
101
|
-
expires_in: 3600,
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
105
|
-
(fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(tokenSet));
|
|
106
|
-
|
|
107
|
-
const mockResponse = {
|
|
108
|
-
on: jest.fn((event, callback) => {
|
|
109
|
-
if (event === 'data') {
|
|
110
|
-
callback(JSON.stringify(newTokenSet));
|
|
111
|
-
}
|
|
112
|
-
if (event === 'end') {
|
|
113
|
-
callback();
|
|
114
|
-
}
|
|
115
|
-
}),
|
|
116
|
-
};
|
|
117
|
-
const mockRequest = {
|
|
118
|
-
on: jest.fn(),
|
|
119
|
-
write: jest.fn(),
|
|
120
|
-
end: jest.fn(),
|
|
121
|
-
};
|
|
122
|
-
(https.request as jest.Mock).mockImplementation((options, callback) => {
|
|
123
|
-
callback(mockResponse);
|
|
124
|
-
return mockRequest;
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
128
|
-
const token = await oauth.getAccessToken();
|
|
129
|
-
|
|
130
|
-
expect(token).toBe('refreshed-token');
|
|
131
|
-
expect(https.request).toHaveBeenCalled();
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('should throw error if token expired and no refresh token', async () => {
|
|
135
|
-
const pastExpiry = Math.floor(Date.now() / 1000) - 100;
|
|
136
|
-
const tokenSet = {
|
|
137
|
-
access_token: 'expired-token',
|
|
138
|
-
token_type: 'Bearer',
|
|
139
|
-
expires_at: pastExpiry,
|
|
140
|
-
// No refresh_token
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
144
|
-
(fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(tokenSet));
|
|
145
|
-
|
|
146
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
147
|
-
|
|
148
|
-
await expect(oauth.getAccessToken()).rejects.toThrow(
|
|
149
|
-
'Token expired and no refresh token available. Please re-authenticate.',
|
|
150
|
-
);
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it('should throw error if not authenticated', async () => {
|
|
154
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
155
|
-
|
|
156
|
-
await expect(oauth.getAccessToken()).rejects.toThrow(
|
|
157
|
-
'Not authenticated. Please authenticate first.',
|
|
158
|
-
);
|
|
159
|
-
});
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
describe('isAuthenticated', () => {
|
|
163
|
-
it('should return true if token set exists', () => {
|
|
164
|
-
const tokenSet = {
|
|
165
|
-
access_token: 'token',
|
|
166
|
-
token_type: 'Bearer',
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
170
|
-
(fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(tokenSet));
|
|
171
|
-
|
|
172
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
173
|
-
expect(oauth.isAuthenticated()).toBe(true);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('should return false if no token set', () => {
|
|
177
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
178
|
-
expect(oauth.isAuthenticated()).toBe(false);
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe('getTokenExpiration', () => {
|
|
183
|
-
it('should return expiration date', () => {
|
|
184
|
-
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
|
185
|
-
const tokenSet = {
|
|
186
|
-
access_token: 'token',
|
|
187
|
-
token_type: 'Bearer',
|
|
188
|
-
expires_at: expiresAt,
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
192
|
-
(fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(tokenSet));
|
|
193
|
-
|
|
194
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
195
|
-
const expiration = oauth.getTokenExpiration();
|
|
196
|
-
|
|
197
|
-
expect(expiration).toBeInstanceOf(Date);
|
|
198
|
-
expect(expiration?.getTime()).toBe(expiresAt * 1000);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('should return null if no expiration', () => {
|
|
202
|
-
const tokenSet = {
|
|
203
|
-
access_token: 'token',
|
|
204
|
-
token_type: 'Bearer',
|
|
205
|
-
};
|
|
206
|
-
|
|
207
|
-
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
|
208
|
-
(fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(tokenSet));
|
|
209
|
-
|
|
210
|
-
const oauth = new DaikinOAuth(mockConfig);
|
|
211
|
-
expect(oauth.getTokenExpiration()).toBeNull();
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import {DaikinCloudRepo} from '../../../src/api/daikin-cloud.repository';
|
|
2
|
-
import {dx4Airco} from '../../fixtures/dx4-airco';
|
|
3
|
-
import {dx23Airco} from '../../fixtures/dx23-airco';
|
|
4
|
-
import {althermaHeatPump} from '../../fixtures/altherma-heat-pump';
|
|
5
|
-
|
|
6
|
-
test.each<Array<string | any>>([
|
|
7
|
-
['dx4', dx4Airco],
|
|
8
|
-
['dx23', dx23Airco],
|
|
9
|
-
['altherma', althermaHeatPump],
|
|
10
|
-
])('Clean cloud device data for %s device', (name, deviceJson) => {
|
|
11
|
-
expect(DaikinCloudRepo.maskSensitiveCloudDeviceData(deviceJson)).toMatchSnapshot();
|
|
12
|
-
});
|