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.
- package/.claude/CLAUDE.md +43 -0
- package/.claude/agents/compiler.md +91 -0
- package/.claude/agents/direct-executor.md +98 -0
- package/.claude/agents/documenter.md +78 -0
- package/.claude/agents/finalizer.md +233 -0
- package/.claude/agents/implementer.md +112 -0
- package/.claude/agents/investigator.md +100 -0
- package/.claude/agents/release-manager.md +203 -0
- package/.claude/agents/reviewer.md +131 -0
- package/.claude/agents/technical-architect.md +403 -0
- package/.claude/agents/test-writer.md +159 -0
- package/.claude/agents/wiki-manager.md +147 -0
- package/.claude/instructions/agent-prompts.md +90 -0
- package/.claude/instructions/shared-rules.md +11 -0
- package/.claude/instructions/team-orchestrator-policy.md +165 -0
- package/.claude/memory.md +186 -0
- package/.claude/output-styles/engineer-manager.md +38 -0
- package/.claude/settings.json +40 -0
- package/.claude/settings.local.json +30 -0
- package/.claude/skills/load-policy/SKILL.md +13 -0
- package/.claude/skills/ref-idea/SKILL.md +44 -0
- package/.claude/skills/status-of/SKILL.md +38 -0
- package/.claude/skills/workspace-console/SKILL.md +49 -0
- package/.claude/statusline-command.sh +130 -0
- package/.claude/statusline.md +53 -0
- package/.claude/templates/approval-packet.template.html +171 -0
- package/.claude/templates/reference-workspaces.md +16 -0
- package/.claude/tools/cli-runner-server.cjs +220 -0
- package/.claude/tools/discord-send-server.cjs +188 -0
- package/.claude/tools/github-release-server.cjs +181 -0
- package/.claude/tools/glob-grep-server.cjs +267 -0
- package/.claude/tools/read-log-server.cjs +175 -0
- package/.github/codeql/codeql-config.yml +14 -0
- package/.github/workflows/build.yml +54 -0
- package/.github/workflows/codeql.yml +59 -0
- package/.github/workflows/coverage.yml +70 -0
- package/.github/workflows/publish.yml +103 -0
- package/.mcp.json +34 -0
- package/CHANGELOG.md +195 -0
- package/LICENSE +21 -0
- package/dist/cli/commands/devices.js +52 -0
- package/dist/cli/commands/energy.js +122 -0
- package/dist/cli/commands/login.js +54 -0
- package/dist/cli/help.js +50 -0
- package/dist/cli/main.js +56 -0
- package/dist/cli/session.js +13 -0
- package/dist/cli/types.js +1 -0
- package/dist/cli/utils.js +29 -0
- package/dist/cli.js +2 -0
- package/dist/constants/index.js +1 -0
- package/dist/core/domain/entities/ThinqDevice.js +43 -0
- package/dist/core/domain/value-objects/AirConditionerCapabilities.js +22 -0
- package/dist/core/domain/value-objects/ThinqFilterState.js +35 -0
- package/dist/core/domain/value-objects/ThinqSnapshot.js +125 -0
- package/dist/errors/AuthenticationError.js +26 -0
- package/dist/errors/BaseError.js +23 -0
- package/dist/errors/index.js +2 -0
- package/dist/model/LgThinkqPluginPlatformConfig.js +23 -0
- package/dist/model/LgThinqPluginPlatformConfig.js +23 -0
- package/dist/module.js +212 -0
- package/dist/platform/deviceRegistry.js +26 -0
- package/dist/platform/platformConfigManager.js +89 -0
- package/dist/platform/platformState.js +9 -0
- package/dist/platform/thinq/thinqAirConditionerCommandHandlers.js +190 -0
- package/dist/platform/thinq/thinqAirConditionerEndpointFactory.js +53 -0
- package/dist/platform/thinq/thinqAirConditionerFilterResetCommandHandler.js +54 -0
- package/dist/platform/thinq/thinqAirConditionerFilterStateSync.js +19 -0
- package/dist/platform/thinq/thinqAirConditionerStateSync.js +205 -0
- package/dist/platform/thinq/thinqDeviceConfigReconciler.js +26 -0
- package/dist/platform/thinq/thinqDeviceConfigurator.js +124 -0
- package/dist/platform/thinq/thinqDeviceDiscovery.js +33 -0
- package/dist/platform/thinq/thinqWasherCommandHandlers.js +54 -0
- package/dist/platform/thinq/thinqWasherEndpointFactory.js +6 -0
- package/dist/platform/thinq/thinqWasherStateSync.js +31 -0
- package/dist/platform/thinq/thinqWasherStopCommandResolver.js +38 -0
- package/dist/services/authentication/AccountAuthStrategy.js +135 -0
- package/dist/services/authentication/AuthContext.js +1 -0
- package/dist/services/authentication/AuthenticationCoordinator.js +29 -0
- package/dist/services/authentication/IAuthStrategy.js +1 -0
- package/dist/services/authentication/TokenAuthStrategy.js +27 -0
- package/dist/services/authentication/UserDataRepository.js +32 -0
- package/dist/services/thinq/gateway.js +61 -0
- package/dist/services/thinq/mqtt/mqttCertificate.js +63 -0
- package/dist/services/thinq/mqtt/mqttConnectionEvents.js +37 -0
- package/dist/services/thinq/mqtt/mqttKeyRepository.js +33 -0
- package/dist/services/thinq/mqtt/mqttRetry.js +25 -0
- package/dist/services/thinq/mqtt/thinqMqttListener.js +97 -0
- package/dist/services/thinq/serviceContainer.js +73 -0
- package/dist/services/thinq/session.js +48 -0
- package/dist/services/thinq/thinqApiClient.js +258 -0
- package/dist/services/thinq/thinqDeviceService.js +75 -0
- package/dist/services/thinq/thinqFilterMonitoringService.js +40 -0
- package/dist/settings.js +9 -0
- package/dist/tests/helpers/testUtils.js +87 -0
- package/dist/utils/mask.js +39 -0
- package/docs/README_CLI.md +166 -0
- package/eslint.config.js +194 -0
- package/matterbridge-lg-thinq-plugin.config.json +27 -0
- package/matterbridge-lg-thinq-plugin.schema.json +520 -0
- package/package.json +107 -0
- package/prettier.config.js +49 -0
- package/scripts/parse-junit-failures.mjs +45 -0
- package/scripts/parse-tsc-failures.mjs +48 -0
- package/scripts/run-build-local-summary.mjs +29 -0
- package/scripts/run-format-summary.mjs +36 -0
- package/scripts/run-lint-fix-summary.mjs +38 -0
- package/scripts/run-precommit-summary.mjs +94 -0
- package/src/cli/commands/devices.ts +67 -0
- package/src/cli/commands/energy.ts +160 -0
- package/src/cli/commands/login.ts +74 -0
- package/src/cli/help.ts +53 -0
- package/src/cli/main.ts +66 -0
- package/src/cli/session.ts +15 -0
- package/src/cli/types.ts +10 -0
- package/src/cli/utils.ts +30 -0
- package/src/cli.ts +3 -0
- package/src/constants/index.ts +8 -0
- package/src/core/domain/entities/ThinqDevice.test.ts +224 -0
- package/src/core/domain/entities/ThinqDevice.ts +74 -0
- package/src/core/domain/value-objects/AirConditionerCapabilities.ts +50 -0
- package/src/core/domain/value-objects/ThinqFilterState.ts +50 -0
- package/src/core/domain/value-objects/ThinqSnapshot.ts +162 -0
- package/src/errors/AuthenticationError.ts +37 -0
- package/src/errors/BaseError.ts +38 -0
- package/src/errors/index.ts +24 -0
- package/src/model/LgThinqPluginPlatformConfig.ts +94 -0
- package/src/module.ts +298 -0
- package/src/platform/deviceRegistry.ts +58 -0
- package/src/platform/platformConfigManager.ts +133 -0
- package/src/platform/platformState.ts +16 -0
- package/src/platform/thinq/thinqAirConditionerCommandHandlers.ts +291 -0
- package/src/platform/thinq/thinqAirConditionerEndpointFactory.ts +145 -0
- package/src/platform/thinq/thinqAirConditionerFilterResetCommandHandler.ts +96 -0
- package/src/platform/thinq/thinqAirConditionerFilterStateSync.ts +42 -0
- package/src/platform/thinq/thinqAirConditionerStateSync.ts +343 -0
- package/src/platform/thinq/thinqDeviceConfigReconciler.ts +42 -0
- package/src/platform/thinq/thinqDeviceConfigurator.test.ts +90 -0
- package/src/platform/thinq/thinqDeviceConfigurator.ts +191 -0
- package/src/platform/thinq/thinqDeviceDiscovery.test.ts +173 -0
- package/src/platform/thinq/thinqDeviceDiscovery.ts +51 -0
- package/src/platform/thinq/thinqWasherCommandHandlers.ts +120 -0
- package/src/platform/thinq/thinqWasherEndpointFactory.ts +37 -0
- package/src/platform/thinq/thinqWasherStateSync.ts +60 -0
- package/src/platform/thinq/thinqWasherStopCommandResolver.ts +62 -0
- package/src/services/authentication/AccountAuthStrategy.test.ts +358 -0
- package/src/services/authentication/AccountAuthStrategy.ts +209 -0
- package/src/services/authentication/AuthContext.ts +19 -0
- package/src/services/authentication/AuthenticationCoordinator.test.ts +217 -0
- package/src/services/authentication/AuthenticationCoordinator.ts +59 -0
- package/src/services/authentication/IAuthStrategy.ts +10 -0
- package/src/services/authentication/TokenAuthStrategy.test.ts +167 -0
- package/src/services/authentication/TokenAuthStrategy.ts +40 -0
- package/src/services/authentication/UserDataRepository.test.ts +149 -0
- package/src/services/authentication/UserDataRepository.ts +46 -0
- package/src/services/thinq/gateway.ts +86 -0
- package/src/services/thinq/mqtt/mqttCertificate.test.ts +344 -0
- package/src/services/thinq/mqtt/mqttCertificate.ts +102 -0
- package/src/services/thinq/mqtt/mqttConnectionEvents.test.ts +276 -0
- package/src/services/thinq/mqtt/mqttConnectionEvents.ts +76 -0
- package/src/services/thinq/mqtt/mqttKeyRepository.test.ts +164 -0
- package/src/services/thinq/mqtt/mqttKeyRepository.ts +43 -0
- package/src/services/thinq/mqtt/mqttRetry.test.ts +239 -0
- package/src/services/thinq/mqtt/mqttRetry.ts +43 -0
- package/src/services/thinq/mqtt/thinqMqttListener.test.ts +349 -0
- package/src/services/thinq/mqtt/thinqMqttListener.ts +120 -0
- package/src/services/thinq/serviceContainer.test.ts +203 -0
- package/src/services/thinq/serviceContainer.ts +105 -0
- package/src/services/thinq/session.test.ts +287 -0
- package/src/services/thinq/session.ts +69 -0
- package/src/services/thinq/thinqApiClient.test.ts +702 -0
- package/src/services/thinq/thinqApiClient.ts +418 -0
- package/src/services/thinq/thinqDeviceService.test.ts +415 -0
- package/src/services/thinq/thinqDeviceService.ts +101 -0
- package/src/services/thinq/thinqFilterMonitoringService.test.ts +264 -0
- package/src/services/thinq/thinqFilterMonitoringService.ts +53 -0
- package/src/settings.ts +18 -0
- package/src/tests/cli/commands/devices.test.ts +293 -0
- package/src/tests/cli/commands/login.test.ts +240 -0
- package/src/tests/cli/help.test.ts +46 -0
- package/src/tests/cli/main.test.ts +214 -0
- package/src/tests/cli/session.test.ts +198 -0
- package/src/tests/cli/utils.test.ts +194 -0
- package/src/tests/core/domain/value-objects/AirConditionerCapabilities.test.ts +685 -0
- package/src/tests/core/domain/value-objects/ThinqFilterState.test.ts +210 -0
- package/src/tests/core/domain/value-objects/ThinqSnapshot.test.ts +1035 -0
- package/src/tests/helpers/testUtils.ts +118 -0
- package/src/tests/model/LgThinqPluginPlatformConfig.test.ts +81 -0
- package/src/tests/platform/deviceRegistry.test.ts +177 -0
- package/src/tests/platform/platformConfigManager.test.ts +790 -0
- package/src/tests/platform/platformState.test.ts +41 -0
- package/src/tests/platform/thinq/thinqAirConditionerCommandHandlers.test.ts +879 -0
- package/src/tests/platform/thinq/thinqAirConditionerEndpointFactory.test.ts +993 -0
- package/src/tests/platform/thinq/thinqAirConditionerFilterResetCommandHandler.test.ts +508 -0
- package/src/tests/platform/thinq/thinqAirConditionerFilterStateSync.test.ts +212 -0
- package/src/tests/platform/thinq/thinqAirConditionerStateSync.test.ts +1586 -0
- package/src/tests/platform/thinq/thinqDeviceConfigReconciler.test.ts +269 -0
- package/src/tests/platform/thinq/thinqDeviceConfigurator.test.ts +492 -0
- package/src/tests/platform/thinq/thinqWasherCommandHandlers.test.ts +594 -0
- package/src/tests/platform/thinq/thinqWasherEndpointFactory.test.ts +84 -0
- package/src/tests/platform/thinq/thinqWasherStateSync.test.ts +373 -0
- package/src/tests/platform/thinq/thinqWasherStopCommandResolver.test.ts +392 -0
- package/src/tests/settings.test.ts +33 -0
- package/src/utils/mask.ts +50 -0
- package/tsconfig.base.json +25 -0
- package/tsconfig.json +9 -0
- package/tsconfig.production.json +13 -0
- package/tsconfig.vitest.json +5 -0
- package/vite.config.ts +43 -0
- package/wiki/memory-archive.md +12 -0
- package/wiki/reference-workspaces.md +23 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { asPartial, createMockLogger } from '../../tests/helpers/testUtils.js';
|
|
4
|
+
import type { ThinqApiClient } from '../thinq/thinqApiClient.js';
|
|
5
|
+
import { AccountAuthStrategy } from './AccountAuthStrategy.js';
|
|
6
|
+
import type { ThinqAuthContext, ThinqUserData } from './AuthContext.js';
|
|
7
|
+
import { AuthenticationCoordinator } from './AuthenticationCoordinator.js';
|
|
8
|
+
import { TokenAuthStrategy } from './TokenAuthStrategy.js';
|
|
9
|
+
|
|
10
|
+
describe('AuthenticationCoordinator', () => {
|
|
11
|
+
let mockAccountStrategy: AccountAuthStrategy;
|
|
12
|
+
let mockTokenStrategy: TokenAuthStrategy;
|
|
13
|
+
let mockApiClient: ThinqApiClient;
|
|
14
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
15
|
+
let coordinator: AuthenticationCoordinator;
|
|
16
|
+
|
|
17
|
+
const testUserData: ThinqUserData = {
|
|
18
|
+
accessToken: 'access-token-123',
|
|
19
|
+
refreshToken: 'refresh-token-123',
|
|
20
|
+
expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 3600,
|
|
21
|
+
country: 'US',
|
|
22
|
+
language: 'en',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
mockLogger = createMockLogger();
|
|
28
|
+
mockAccountStrategy = asPartial<AccountAuthStrategy>({
|
|
29
|
+
authenticate: vi.fn(),
|
|
30
|
+
});
|
|
31
|
+
mockTokenStrategy = asPartial<TokenAuthStrategy>({
|
|
32
|
+
authenticate: vi.fn(),
|
|
33
|
+
});
|
|
34
|
+
mockApiClient = asPartial<ThinqApiClient>({
|
|
35
|
+
getUserNumber: vi.fn(),
|
|
36
|
+
setUserNumber: vi.fn(),
|
|
37
|
+
});
|
|
38
|
+
coordinator = new AuthenticationCoordinator(mockAccountStrategy, mockTokenStrategy, mockApiClient, mockLogger);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.clearAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('authenticate', () => {
|
|
46
|
+
it('should dispatch to AccountAuthStrategy when method is "account"', async () => {
|
|
47
|
+
vi.mocked(mockAccountStrategy.authenticate).mockResolvedValue(testUserData);
|
|
48
|
+
vi.mocked(mockApiClient.getUserNumber).mockResolvedValue('user-number-123');
|
|
49
|
+
|
|
50
|
+
const context = asPartial<ThinqAuthContext>({
|
|
51
|
+
username: 'test@example.com',
|
|
52
|
+
password: 'password',
|
|
53
|
+
country: 'US',
|
|
54
|
+
language: 'en',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await coordinator.authenticate('account', context);
|
|
58
|
+
|
|
59
|
+
expect(mockAccountStrategy.authenticate).toHaveBeenCalledWith(context);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should dispatch to TokenAuthStrategy when method is "token"', async () => {
|
|
63
|
+
vi.mocked(mockTokenStrategy.authenticate).mockResolvedValue(testUserData);
|
|
64
|
+
vi.mocked(mockApiClient.getUserNumber).mockResolvedValue('user-number-123');
|
|
65
|
+
|
|
66
|
+
const context = asPartial<ThinqAuthContext>({
|
|
67
|
+
refreshToken: 'refresh-token-123',
|
|
68
|
+
country: 'US',
|
|
69
|
+
language: 'en',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await coordinator.authenticate('token', context);
|
|
73
|
+
|
|
74
|
+
expect(mockTokenStrategy.authenticate).toHaveBeenCalledWith(context);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should throw Error with available methods when method is unknown', async () => {
|
|
78
|
+
const context = asPartial<ThinqAuthContext>({
|
|
79
|
+
country: 'US',
|
|
80
|
+
language: 'en',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
await expect(coordinator.authenticate('invalid-method' as any, context)).rejects.toThrow(
|
|
85
|
+
/Unknown ThinQ authentication method/,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should include available methods in error message', async () => {
|
|
90
|
+
const context = asPartial<ThinqAuthContext>({
|
|
91
|
+
country: 'US',
|
|
92
|
+
language: 'en',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
96
|
+
const error = await coordinator.authenticate('unknown' as any, context).catch((e: unknown) => e);
|
|
97
|
+
expect(error).toBeInstanceOf(Error);
|
|
98
|
+
expect((error as Error).message).toContain('Unknown ThinQ authentication method');
|
|
99
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('account'));
|
|
100
|
+
expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('token'));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should fetch user number after successful authentication', async () => {
|
|
104
|
+
vi.mocked(mockAccountStrategy.authenticate).mockResolvedValue(testUserData);
|
|
105
|
+
vi.mocked(mockApiClient.getUserNumber).mockResolvedValue('user-number-456');
|
|
106
|
+
|
|
107
|
+
const context = asPartial<ThinqAuthContext>({
|
|
108
|
+
username: 'test@example.com',
|
|
109
|
+
password: 'password',
|
|
110
|
+
country: 'US',
|
|
111
|
+
language: 'en',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await coordinator.authenticate('account', context);
|
|
115
|
+
|
|
116
|
+
expect(mockApiClient.getUserNumber).toHaveBeenCalledWith('access-token-123');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should set user number on API client', async () => {
|
|
120
|
+
vi.mocked(mockAccountStrategy.authenticate).mockResolvedValue(testUserData);
|
|
121
|
+
vi.mocked(mockApiClient.getUserNumber).mockResolvedValue('user-number-789');
|
|
122
|
+
|
|
123
|
+
const context = asPartial<ThinqAuthContext>({
|
|
124
|
+
username: 'test@example.com',
|
|
125
|
+
password: 'password',
|
|
126
|
+
country: 'US',
|
|
127
|
+
language: 'en',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await coordinator.authenticate('account', context);
|
|
131
|
+
|
|
132
|
+
expect(mockApiClient.setUserNumber).toHaveBeenCalledWith('user-number-789');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should return user data with user number added', async () => {
|
|
136
|
+
vi.mocked(mockAccountStrategy.authenticate).mockResolvedValue(testUserData);
|
|
137
|
+
vi.mocked(mockApiClient.getUserNumber).mockResolvedValue('user-number-999');
|
|
138
|
+
|
|
139
|
+
const context = asPartial<ThinqAuthContext>({
|
|
140
|
+
username: 'test@example.com',
|
|
141
|
+
password: 'password',
|
|
142
|
+
country: 'US',
|
|
143
|
+
language: 'en',
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = await coordinator.authenticate('account', context);
|
|
147
|
+
|
|
148
|
+
expect(result?.userNumber).toBe('user-number-999');
|
|
149
|
+
expect(result?.accessToken).toBe(testUserData.accessToken);
|
|
150
|
+
expect(result?.refreshToken).toBe(testUserData.refreshToken);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should return undefined when strategy returns undefined', async () => {
|
|
154
|
+
vi.mocked(mockAccountStrategy.authenticate).mockResolvedValue(undefined);
|
|
155
|
+
|
|
156
|
+
const context = asPartial<ThinqAuthContext>({
|
|
157
|
+
username: 'test@example.com',
|
|
158
|
+
password: 'password',
|
|
159
|
+
country: 'US',
|
|
160
|
+
language: 'en',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const result = await coordinator.authenticate('account', context);
|
|
164
|
+
|
|
165
|
+
expect(result).toBeUndefined();
|
|
166
|
+
expect(mockApiClient.getUserNumber).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should log debug message before resolving user number', async () => {
|
|
170
|
+
vi.mocked(mockAccountStrategy.authenticate).mockResolvedValue(testUserData);
|
|
171
|
+
vi.mocked(mockApiClient.getUserNumber).mockResolvedValue('user-number-123');
|
|
172
|
+
|
|
173
|
+
const context = asPartial<ThinqAuthContext>({
|
|
174
|
+
username: 'test@example.com',
|
|
175
|
+
password: 'password',
|
|
176
|
+
country: 'US',
|
|
177
|
+
language: 'en',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await coordinator.authenticate('account', context);
|
|
181
|
+
|
|
182
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
183
|
+
'AuthenticationCoordinator: resolving ThinQ user number post-login',
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should propagate getUserNumber errors', async () => {
|
|
188
|
+
vi.mocked(mockAccountStrategy.authenticate).mockResolvedValue(testUserData);
|
|
189
|
+
vi.mocked(mockApiClient.getUserNumber).mockRejectedValue(new Error('User number fetch failed'));
|
|
190
|
+
|
|
191
|
+
const context = asPartial<ThinqAuthContext>({
|
|
192
|
+
username: 'test@example.com',
|
|
193
|
+
password: 'password',
|
|
194
|
+
country: 'US',
|
|
195
|
+
language: 'en',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
await expect(coordinator.authenticate('account', context)).rejects.toThrow('User number fetch failed');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should work with TokenAuthStrategy', async () => {
|
|
202
|
+
vi.mocked(mockTokenStrategy.authenticate).mockResolvedValue(testUserData);
|
|
203
|
+
vi.mocked(mockApiClient.getUserNumber).mockResolvedValue('user-number-token');
|
|
204
|
+
|
|
205
|
+
const context = asPartial<ThinqAuthContext>({
|
|
206
|
+
refreshToken: 'refresh-token-123',
|
|
207
|
+
country: 'US',
|
|
208
|
+
language: 'en',
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const result = await coordinator.authenticate('token', context);
|
|
212
|
+
|
|
213
|
+
expect(result?.userNumber).toBe('user-number-token');
|
|
214
|
+
expect(mockTokenStrategy.authenticate).toHaveBeenCalledWith(context);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
2
|
+
|
|
3
|
+
import type { ThinqApiClient } from '../thinq/thinqApiClient.js';
|
|
4
|
+
import { AccountAuthStrategy } from './AccountAuthStrategy.js';
|
|
5
|
+
import type { ThinqAuthContext, ThinqUserData } from './AuthContext.js';
|
|
6
|
+
import type { IAuthStrategy } from './IAuthStrategy.js';
|
|
7
|
+
import { TokenAuthStrategy } from './TokenAuthStrategy.js';
|
|
8
|
+
|
|
9
|
+
export type ThinqAuthMethod = 'account' | 'token';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Coordinates ThinQ authentication strategies based on the selected method. Also owns the mandatory
|
|
13
|
+
* post-login bootstrap step (`API.ready()`, `API.ts:392-399`): fetching the account's numeric profile ID
|
|
14
|
+
* (`userNumber`) and activating it on the shared `ThinqApiClient` so subsequent ThinQ2 calls (`service/homes`,
|
|
15
|
+
* etc.) send `x-user-no` and a per-session `x-client-id`. Centralized here so neither `AccountAuthStrategy`
|
|
16
|
+
* nor `TokenAuthStrategy` needs to duplicate the fetch-and-compute logic.
|
|
17
|
+
*/
|
|
18
|
+
export class AuthenticationCoordinator {
|
|
19
|
+
private readonly strategies: Map<string, IAuthStrategy>;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
accountStrategy: AccountAuthStrategy,
|
|
23
|
+
tokenStrategy: TokenAuthStrategy,
|
|
24
|
+
private readonly apiClient: ThinqApiClient,
|
|
25
|
+
private readonly logger: AnsiLogger,
|
|
26
|
+
) {
|
|
27
|
+
this.strategies = new Map<string, IAuthStrategy>([
|
|
28
|
+
['account', accountStrategy],
|
|
29
|
+
['token', tokenStrategy],
|
|
30
|
+
]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Authenticate user using the specified method.
|
|
35
|
+
* @param method Authentication method ('account' or 'token')
|
|
36
|
+
* @param context Authentication context containing credentials
|
|
37
|
+
* @returns ThinqUserData if successful, undefined if further action required
|
|
38
|
+
*/
|
|
39
|
+
public async authenticate(method: ThinqAuthMethod, context: ThinqAuthContext): Promise<ThinqUserData | undefined> {
|
|
40
|
+
const strategy = this.strategies.get(method);
|
|
41
|
+
|
|
42
|
+
if (!strategy) {
|
|
43
|
+
const availableMethods = Array.from(this.strategies.keys()).join(', ');
|
|
44
|
+
this.logger.error(`Unknown ThinQ authentication method: ${method}. Available methods: ${availableMethods}`);
|
|
45
|
+
throw new Error(`Unknown ThinQ authentication method: ${method}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const userData = await strategy.authenticate(context);
|
|
49
|
+
if (!userData) {
|
|
50
|
+
return userData;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.logger.debug('AuthenticationCoordinator: resolving ThinQ user number post-login');
|
|
54
|
+
const userNumber = await this.apiClient.getUserNumber(userData.accessToken);
|
|
55
|
+
this.apiClient.setUserNumber(userNumber);
|
|
56
|
+
|
|
57
|
+
return { ...userData, userNumber };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ThinqAuthContext, ThinqUserData } from './AuthContext.js';
|
|
2
|
+
|
|
3
|
+
/** Strategy interface for different ThinQ authentication methods. */
|
|
4
|
+
export interface IAuthStrategy {
|
|
5
|
+
/**
|
|
6
|
+
* Authenticate user with the provided context.
|
|
7
|
+
* @returns ThinqUserData if authentication succeeds, undefined if further action required.
|
|
8
|
+
*/
|
|
9
|
+
authenticate(context: ThinqAuthContext): Promise<ThinqUserData | undefined>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AuthenticationError } from '../../errors/index.js';
|
|
4
|
+
import { asPartial, buildGatewayResponse, createMockLogger } from '../../tests/helpers/testUtils.js';
|
|
5
|
+
import { ThinqSession } from '../thinq/session.js';
|
|
6
|
+
import type { ThinqApiClient } from '../thinq/thinqApiClient.js';
|
|
7
|
+
import type { ThinqAuthContext } from './AuthContext.js';
|
|
8
|
+
import { TokenAuthStrategy } from './TokenAuthStrategy.js';
|
|
9
|
+
|
|
10
|
+
describe('TokenAuthStrategy', () => {
|
|
11
|
+
let mockApiClient: ThinqApiClient;
|
|
12
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
13
|
+
let strategy: TokenAuthStrategy;
|
|
14
|
+
|
|
15
|
+
const testContext: ThinqAuthContext = {
|
|
16
|
+
refreshToken: 'refresh-token-123',
|
|
17
|
+
country: 'US',
|
|
18
|
+
language: 'en',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
mockLogger = createMockLogger();
|
|
24
|
+
mockApiClient = asPartial<ThinqApiClient>({
|
|
25
|
+
getGateway: vi.fn(),
|
|
26
|
+
refreshToken: vi.fn(),
|
|
27
|
+
});
|
|
28
|
+
strategy = new TokenAuthStrategy(mockApiClient, mockLogger);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('authenticate', () => {
|
|
36
|
+
it('should throw AuthenticationError when refreshToken is missing', async () => {
|
|
37
|
+
const contextWithoutToken = asPartial<ThinqAuthContext>({
|
|
38
|
+
country: 'US',
|
|
39
|
+
language: 'en',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await expect(strategy.authenticate(contextWithoutToken)).rejects.toThrow(AuthenticationError);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should throw AuthenticationError when refreshToken is empty string', async () => {
|
|
46
|
+
const contextWithEmptyToken = asPartial<ThinqAuthContext>({
|
|
47
|
+
refreshToken: '',
|
|
48
|
+
country: 'US',
|
|
49
|
+
language: 'en',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await expect(strategy.authenticate(contextWithEmptyToken)).rejects.toThrow(AuthenticationError);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should call getGateway before refreshing token', async () => {
|
|
56
|
+
const gatewayData = buildGatewayResponse();
|
|
57
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
58
|
+
vi.mocked(mockApiClient.refreshToken).mockResolvedValue(
|
|
59
|
+
new ThinqSession('new-access-token', 'refresh-token-123', Math.floor(Date.now() / 1000) + 3600),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await strategy.authenticate(testContext);
|
|
63
|
+
|
|
64
|
+
expect(mockApiClient.getGateway).toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should call refreshToken with a ThinqSession containing the refresh token', async () => {
|
|
68
|
+
const gatewayData = buildGatewayResponse();
|
|
69
|
+
const newSession = new ThinqSession(
|
|
70
|
+
'new-access-token',
|
|
71
|
+
'refresh-token-123',
|
|
72
|
+
Math.floor(Date.now() / 1000) + 3600,
|
|
73
|
+
);
|
|
74
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
75
|
+
vi.mocked(mockApiClient.refreshToken).mockResolvedValue(newSession);
|
|
76
|
+
|
|
77
|
+
await strategy.authenticate(testContext);
|
|
78
|
+
|
|
79
|
+
expect(mockApiClient.refreshToken).toHaveBeenCalled();
|
|
80
|
+
const callArg = vi.mocked(mockApiClient.refreshToken).mock.calls[0][0];
|
|
81
|
+
expect(callArg.refreshToken).toBe('refresh-token-123');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should return ThinqUserData with tokens from refreshToken response', async () => {
|
|
85
|
+
const gatewayData = buildGatewayResponse();
|
|
86
|
+
const newSession = new ThinqSession(
|
|
87
|
+
'new-access-token',
|
|
88
|
+
'refresh-token-updated',
|
|
89
|
+
Math.floor(Date.now() / 1000) + 3600,
|
|
90
|
+
);
|
|
91
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
92
|
+
vi.mocked(mockApiClient.refreshToken).mockResolvedValue(newSession);
|
|
93
|
+
|
|
94
|
+
const result = await strategy.authenticate(testContext);
|
|
95
|
+
|
|
96
|
+
expect(result).toBeDefined();
|
|
97
|
+
expect(result?.accessToken).toBe('new-access-token');
|
|
98
|
+
expect(result?.refreshToken).toBe('refresh-token-updated');
|
|
99
|
+
expect(result?.country).toBe('US');
|
|
100
|
+
expect(result?.language).toBe('en');
|
|
101
|
+
expect(result?.expiresAtEpochSeconds).toBeGreaterThan(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should preserve country and language from context', async () => {
|
|
105
|
+
const contextWithDifferentLocale = asPartial<ThinqAuthContext>({
|
|
106
|
+
refreshToken: 'refresh-token-123',
|
|
107
|
+
country: 'KR',
|
|
108
|
+
language: 'ko',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const gatewayData = buildGatewayResponse();
|
|
112
|
+
const newSession = new ThinqSession(
|
|
113
|
+
'new-access-token',
|
|
114
|
+
'refresh-token-123',
|
|
115
|
+
Math.floor(Date.now() / 1000) + 3600,
|
|
116
|
+
);
|
|
117
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
118
|
+
vi.mocked(mockApiClient.refreshToken).mockResolvedValue(newSession);
|
|
119
|
+
|
|
120
|
+
const result = await strategy.authenticate(contextWithDifferentLocale);
|
|
121
|
+
|
|
122
|
+
expect(result?.country).toBe('KR');
|
|
123
|
+
expect(result?.language).toBe('ko');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should log authentication start message', async () => {
|
|
127
|
+
const gatewayData = buildGatewayResponse();
|
|
128
|
+
const newSession = new ThinqSession(
|
|
129
|
+
'new-access-token',
|
|
130
|
+
'refresh-token-123',
|
|
131
|
+
Math.floor(Date.now() / 1000) + 3600,
|
|
132
|
+
);
|
|
133
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
134
|
+
vi.mocked(mockApiClient.refreshToken).mockResolvedValue(newSession);
|
|
135
|
+
|
|
136
|
+
await strategy.authenticate(testContext);
|
|
137
|
+
|
|
138
|
+
expect(mockLogger.notice).toHaveBeenCalledWith('Authenticating with LG ThinQ refresh token...');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should log debug message before getting gateway', async () => {
|
|
142
|
+
const gatewayData = buildGatewayResponse();
|
|
143
|
+
const newSession = new ThinqSession(
|
|
144
|
+
'new-access-token',
|
|
145
|
+
'refresh-token-123',
|
|
146
|
+
Math.floor(Date.now() / 1000) + 3600,
|
|
147
|
+
);
|
|
148
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
149
|
+
vi.mocked(mockApiClient.refreshToken).mockResolvedValue(newSession);
|
|
150
|
+
|
|
151
|
+
await strategy.authenticate(testContext);
|
|
152
|
+
|
|
153
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
154
|
+
'TokenAuthStrategy: resolving gateway before refresh-token exchange',
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should propagate errors from refreshToken', async () => {
|
|
159
|
+
const gatewayData = buildGatewayResponse();
|
|
160
|
+
const error = new Error('Token refresh failed');
|
|
161
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
162
|
+
vi.mocked(mockApiClient.refreshToken).mockRejectedValue(error);
|
|
163
|
+
|
|
164
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow('Token refresh failed');
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
2
|
+
|
|
3
|
+
import { AuthenticationError } from '../../errors/index.js';
|
|
4
|
+
import { ThinqSession } from '../thinq/session.js';
|
|
5
|
+
import { ThinqApiClient } from '../thinq/thinqApiClient.js';
|
|
6
|
+
import type { ThinqAuthContext, ThinqUserData } from './AuthContext.js';
|
|
7
|
+
import type { IAuthStrategy } from './IAuthStrategy.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Refresh-token authentication strategy. Skips the 5-call EMP login chain entirely and exchanges a
|
|
11
|
+
* pre-obtained `refresh_token` directly for an access token (`Auth.refreshNewToken`, `Auth.ts:347-391`).
|
|
12
|
+
*/
|
|
13
|
+
export class TokenAuthStrategy implements IAuthStrategy {
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly apiClient: ThinqApiClient,
|
|
16
|
+
private readonly logger: AnsiLogger,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
public async authenticate(context: ThinqAuthContext): Promise<ThinqUserData | undefined> {
|
|
20
|
+
if (!context.refreshToken) {
|
|
21
|
+
throw new AuthenticationError('A ThinQ refresh token is required for token-based authentication.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
this.logger.notice('Authenticating with LG ThinQ refresh token...');
|
|
25
|
+
this.logger.debug('TokenAuthStrategy: resolving gateway before refresh-token exchange');
|
|
26
|
+
|
|
27
|
+
await this.apiClient.getGateway();
|
|
28
|
+
|
|
29
|
+
const session = new ThinqSession('', context.refreshToken, 0);
|
|
30
|
+
const refreshed = await this.apiClient.refreshToken(session);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
accessToken: refreshed.accessToken,
|
|
34
|
+
refreshToken: refreshed.refreshToken,
|
|
35
|
+
expiresAtEpochSeconds: refreshed.expiresAtEpochSeconds,
|
|
36
|
+
country: context.country,
|
|
37
|
+
language: context.language,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { PlatformConfigManager } from '../../platform/platformConfigManager.js';
|
|
4
|
+
import { asPartial, createMockLogger, createMockNodePersist } from '../../tests/helpers/testUtils.js';
|
|
5
|
+
import type { ThinqUserData } from './AuthContext.js';
|
|
6
|
+
import { UserDataRepository } from './UserDataRepository.js';
|
|
7
|
+
|
|
8
|
+
describe('UserDataRepository', () => {
|
|
9
|
+
let mockPersist: ReturnType<typeof createMockNodePersist>;
|
|
10
|
+
let mockConfigManager: PlatformConfigManager;
|
|
11
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
12
|
+
let repository: UserDataRepository;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
mockPersist = createMockNodePersist();
|
|
17
|
+
mockLogger = createMockLogger();
|
|
18
|
+
mockConfigManager = asPartial<PlatformConfigManager>({
|
|
19
|
+
country: 'US',
|
|
20
|
+
});
|
|
21
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
|
+
repository = new UserDataRepository(mockPersist as any, mockConfigManager, mockLogger);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('loadUserData', () => {
|
|
26
|
+
it('should return undefined when no saved data exists', async () => {
|
|
27
|
+
vi.mocked(mockPersist.getItem).mockResolvedValue(undefined);
|
|
28
|
+
|
|
29
|
+
const result = await repository.loadUserData();
|
|
30
|
+
|
|
31
|
+
expect(result).toBeUndefined();
|
|
32
|
+
expect(mockPersist.getItem).toHaveBeenCalledWith('thinq:userData');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return saved data when country matches config', async () => {
|
|
36
|
+
const userData: ThinqUserData = {
|
|
37
|
+
accessToken: 'access123',
|
|
38
|
+
refreshToken: 'refresh123',
|
|
39
|
+
expiresAtEpochSeconds: 9999999999,
|
|
40
|
+
country: 'US',
|
|
41
|
+
language: 'en',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
vi.mocked(mockPersist.getItem).mockResolvedValue(userData);
|
|
45
|
+
|
|
46
|
+
const result = await repository.loadUserData();
|
|
47
|
+
|
|
48
|
+
expect(result).toEqual(userData);
|
|
49
|
+
expect(mockPersist.removeItem).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should clear and return undefined when saved country does not match config', async () => {
|
|
53
|
+
const userData: ThinqUserData = {
|
|
54
|
+
accessToken: 'access123',
|
|
55
|
+
refreshToken: 'refresh123',
|
|
56
|
+
expiresAtEpochSeconds: 9999999999,
|
|
57
|
+
country: 'KR',
|
|
58
|
+
language: 'ko',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
vi.mocked(mockPersist.getItem).mockResolvedValue(userData);
|
|
62
|
+
|
|
63
|
+
const result = await repository.loadUserData();
|
|
64
|
+
|
|
65
|
+
expect(result).toBeUndefined();
|
|
66
|
+
expect(mockPersist.removeItem).toHaveBeenCalledWith('thinq:userData');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should log debug message when no saved data found', async () => {
|
|
70
|
+
vi.mocked(mockPersist.getItem).mockResolvedValue(undefined);
|
|
71
|
+
|
|
72
|
+
await repository.loadUserData();
|
|
73
|
+
|
|
74
|
+
expect(mockLogger.debug).toHaveBeenCalledWith('No saved ThinQ userData found');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should log debug message when data is returned', async () => {
|
|
78
|
+
const userData: ThinqUserData = {
|
|
79
|
+
accessToken: 'access123',
|
|
80
|
+
refreshToken: 'refresh123',
|
|
81
|
+
expiresAtEpochSeconds: 9999999999,
|
|
82
|
+
country: 'US',
|
|
83
|
+
language: 'en',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
vi.mocked(mockPersist.getItem).mockResolvedValue(userData);
|
|
87
|
+
|
|
88
|
+
await repository.loadUserData();
|
|
89
|
+
|
|
90
|
+
expect(mockLogger.debug).toHaveBeenCalledWith('Loading saved ThinQ userData');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should log debug message when country does not match', async () => {
|
|
94
|
+
const userData: ThinqUserData = {
|
|
95
|
+
accessToken: 'access123',
|
|
96
|
+
refreshToken: 'refresh123',
|
|
97
|
+
expiresAtEpochSeconds: 9999999999,
|
|
98
|
+
country: 'KR',
|
|
99
|
+
language: 'ko',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
vi.mocked(mockPersist.getItem).mockResolvedValue(userData);
|
|
103
|
+
|
|
104
|
+
await repository.loadUserData();
|
|
105
|
+
|
|
106
|
+
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
107
|
+
'Saved ThinQ userData country does not match current config, ignoring saved data',
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('saveUserData', () => {
|
|
113
|
+
it('should persist user data to storage', async () => {
|
|
114
|
+
const userData: ThinqUserData = {
|
|
115
|
+
accessToken: 'access123',
|
|
116
|
+
refreshToken: 'refresh123',
|
|
117
|
+
expiresAtEpochSeconds: 9999999999,
|
|
118
|
+
country: 'US',
|
|
119
|
+
language: 'en',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
await repository.saveUserData(userData);
|
|
123
|
+
|
|
124
|
+
expect(mockPersist.setItem).toHaveBeenCalledWith('thinq:userData', userData);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should log debug message when data is saved', async () => {
|
|
128
|
+
const userData: ThinqUserData = {
|
|
129
|
+
accessToken: 'access123',
|
|
130
|
+
refreshToken: 'refresh123',
|
|
131
|
+
expiresAtEpochSeconds: 9999999999,
|
|
132
|
+
country: 'US',
|
|
133
|
+
language: 'en',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
await repository.saveUserData(userData);
|
|
137
|
+
|
|
138
|
+
expect(mockLogger.debug).toHaveBeenCalledWith('ThinQ user data saved successfully');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('clearUserData', () => {
|
|
143
|
+
it('should remove user data from storage', async () => {
|
|
144
|
+
await repository.clearUserData();
|
|
145
|
+
|
|
146
|
+
expect(mockPersist.removeItem).toHaveBeenCalledWith('thinq:userData');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
2
|
+
import type NodePersist from 'node-persist';
|
|
3
|
+
|
|
4
|
+
import { PlatformConfigManager } from '../../platform/platformConfigManager.js';
|
|
5
|
+
import type { ThinqUserData } from './AuthContext.js';
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'thinq:userData';
|
|
8
|
+
|
|
9
|
+
/** Repository for persisting and retrieving ThinQ user session data. */
|
|
10
|
+
export class UserDataRepository {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly persist: NodePersist.LocalStorage,
|
|
13
|
+
private readonly configManager: PlatformConfigManager,
|
|
14
|
+
private readonly logger: AnsiLogger,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
/** Load saved ThinQ user data, discarding it if the configured country no longer matches. */
|
|
18
|
+
public async loadUserData(): Promise<ThinqUserData | undefined> {
|
|
19
|
+
const savedUserData = (await this.persist.getItem(STORAGE_KEY)) as ThinqUserData | undefined;
|
|
20
|
+
|
|
21
|
+
if (!savedUserData) {
|
|
22
|
+
this.logger.debug('No saved ThinQ userData found');
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (savedUserData.country !== this.configManager.country) {
|
|
27
|
+
this.logger.debug('Saved ThinQ userData country does not match current config, ignoring saved data');
|
|
28
|
+
await this.clearUserData();
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
this.logger.debug('Loading saved ThinQ userData');
|
|
33
|
+
return savedUserData;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Save user data for future use. */
|
|
37
|
+
public async saveUserData(userData: ThinqUserData): Promise<void> {
|
|
38
|
+
await this.persist.setItem(STORAGE_KEY, userData);
|
|
39
|
+
this.logger.debug('ThinQ user data saved successfully');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Clear saved user data. */
|
|
43
|
+
public async clearUserData(): Promise<void> {
|
|
44
|
+
await this.persist.removeItem(STORAGE_KEY);
|
|
45
|
+
}
|
|
46
|
+
}
|