@oxyhq/core 1.11.15 → 1.11.16
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mixins/OxyServices.appData.js +108 -0
- package/dist/cjs/mixins/index.js +2 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -0
- package/dist/esm/mixins/OxyServices.appData.js +103 -0
- package/dist/esm/mixins/index.js +2 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +107 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.appData.ts +158 -0
- package/src/mixins/__tests__/appData.test.ts +203 -0
- package/src/mixins/index.ts +3 -0
package/dist/types/index.d.ts
CHANGED
|
@@ -28,6 +28,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
|
28
28
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
29
29
|
export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount } from './mixins/OxyServices.managedAccounts';
|
|
30
30
|
export type { ContactDiscoveryMatch, ContactDiscoveryResponse } from './mixins/OxyServices.contacts';
|
|
31
|
+
export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
|
|
31
32
|
export { KeyManager, SignatureService, RecoveryPhraseService, IdentityAlreadyExistsError, IdentityPersistError, } from './crypto';
|
|
32
33
|
export type { KeyPair, SignedMessage, AuthChallenge, RecoveryPhraseResult } from './crypto';
|
|
33
34
|
export * from './models/interfaces';
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user App-Data Mixin
|
|
3
|
+
*
|
|
4
|
+
* Thin client around `/users/me/app-data/...` — a generic per-user JSON KV
|
|
5
|
+
* store on the API. Authenticated callers can read, write, list, and delete
|
|
6
|
+
* entries scoped to their own user account.
|
|
7
|
+
*
|
|
8
|
+
* Identifier rules (must match the API):
|
|
9
|
+
* - Both `namespace` and `key` must match `/^[a-z0-9_-]{1,64}$/u`.
|
|
10
|
+
*
|
|
11
|
+
* Limits (enforced by the API):
|
|
12
|
+
* - Serialized JSON values are capped at 64 KB.
|
|
13
|
+
* - Writes are rate-limited to 100 / minute / user.
|
|
14
|
+
*
|
|
15
|
+
* Intended use cases are small bits of cross-device app state — e.g. Academy
|
|
16
|
+
* course progress, "last viewed" markers, dismissed banner flags. Do not use
|
|
17
|
+
* this for large blobs or anything that needs server-side querying; it's a
|
|
18
|
+
* write-it-and-read-it-back store.
|
|
19
|
+
*/
|
|
20
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
21
|
+
/** Thrown when a namespace or key fails the kebab/snake-case validator. */
|
|
22
|
+
export declare class OxyAppDataIdentifierError extends Error {
|
|
23
|
+
constructor(field: 'namespace' | 'key', value: string);
|
|
24
|
+
}
|
|
25
|
+
export declare function OxyServicesAppDataMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
26
|
+
new (...args: any[]): {
|
|
27
|
+
/**
|
|
28
|
+
* Read the value stored under `(namespace, key)` for the current user.
|
|
29
|
+
*
|
|
30
|
+
* @returns The previously-stored value, or `null` if nothing has been
|
|
31
|
+
* stored yet. Never throws on "not found" — a missing entry is
|
|
32
|
+
* semantically a `null` value.
|
|
33
|
+
*/
|
|
34
|
+
getAppData<TValue = unknown>(namespace: string, key: string): Promise<TValue | null>;
|
|
35
|
+
/**
|
|
36
|
+
* Upsert the value under `(namespace, key)` for the current user.
|
|
37
|
+
*
|
|
38
|
+
* Returns the value the server confirmed it stored — typically the same
|
|
39
|
+
* value the caller passed in, but consumers should prefer the returned
|
|
40
|
+
* value (the API is the source of truth).
|
|
41
|
+
*
|
|
42
|
+
* @throws OxyAppDataIdentifierError when namespace or key is malformed.
|
|
43
|
+
*/
|
|
44
|
+
setAppData<TValue = unknown>(namespace: string, key: string, value: TValue): Promise<TValue>;
|
|
45
|
+
/**
|
|
46
|
+
* Delete the value stored under `(namespace, key)` for the current user.
|
|
47
|
+
*
|
|
48
|
+
* Idempotent — resolves successfully whether or not the entry existed.
|
|
49
|
+
*/
|
|
50
|
+
deleteAppData(namespace: string, key: string): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* List every entry stored under `namespace` for the current user.
|
|
53
|
+
*
|
|
54
|
+
* Returns an empty object when the namespace contains no entries (the
|
|
55
|
+
* endpoint never 404s on an empty namespace).
|
|
56
|
+
*/
|
|
57
|
+
listAppData<TValue = unknown>(namespace: string): Promise<Record<string, TValue>>;
|
|
58
|
+
httpService: import("../HttpService").HttpService;
|
|
59
|
+
cloudURL: string;
|
|
60
|
+
config: import("../OxyServices.base").OxyConfig;
|
|
61
|
+
__resetTokensForTests(): void;
|
|
62
|
+
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
63
|
+
getBaseURL(): string;
|
|
64
|
+
getClient(): import("../HttpService").HttpService;
|
|
65
|
+
getMetrics(): {
|
|
66
|
+
totalRequests: number;
|
|
67
|
+
successfulRequests: number;
|
|
68
|
+
failedRequests: number;
|
|
69
|
+
cacheHits: number;
|
|
70
|
+
cacheMisses: number;
|
|
71
|
+
averageResponseTime: number;
|
|
72
|
+
};
|
|
73
|
+
clearCache(): void;
|
|
74
|
+
clearCacheEntry(key: string): void;
|
|
75
|
+
clearCacheByPrefix(prefix: string): number;
|
|
76
|
+
getCacheStats(): {
|
|
77
|
+
size: number;
|
|
78
|
+
hits: number;
|
|
79
|
+
misses: number;
|
|
80
|
+
hitRate: number;
|
|
81
|
+
};
|
|
82
|
+
getCloudURL(): string;
|
|
83
|
+
setTokens(accessToken: string, refreshToken?: string): void;
|
|
84
|
+
clearTokens(): void;
|
|
85
|
+
_cachedUserId: string | null | undefined;
|
|
86
|
+
_cachedAccessToken: string | null;
|
|
87
|
+
getCurrentUserId(): string | null;
|
|
88
|
+
hasValidToken(): boolean;
|
|
89
|
+
getAccessToken(): string | null;
|
|
90
|
+
setActingAs(userId: string | null): void;
|
|
91
|
+
getActingAs(): string | null;
|
|
92
|
+
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
93
|
+
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
94
|
+
maxRetries?: number;
|
|
95
|
+
retryDelay?: number;
|
|
96
|
+
authTimeoutMs?: number;
|
|
97
|
+
}): Promise<T_1>;
|
|
98
|
+
validate(): Promise<boolean>;
|
|
99
|
+
handleError(error: unknown): Error;
|
|
100
|
+
healthCheck(): Promise<{
|
|
101
|
+
status: string;
|
|
102
|
+
users?: number;
|
|
103
|
+
timestamp?: string;
|
|
104
|
+
[key: string]: any;
|
|
105
|
+
}>;
|
|
106
|
+
};
|
|
107
|
+
} & T;
|
|
@@ -25,6 +25,7 @@ import { OxyServicesFeaturesMixin } from './OxyServices.features';
|
|
|
25
25
|
import { OxyServicesTopicsMixin } from './OxyServices.topics';
|
|
26
26
|
import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
|
|
27
27
|
import { OxyServicesContactsMixin } from './OxyServices.contacts';
|
|
28
|
+
import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
28
29
|
/**
|
|
29
30
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
30
31
|
* `composeOxyServices()` produces a class whose instances expose all of
|
|
@@ -34,7 +35,7 @@ import { OxyServicesContactsMixin } from './OxyServices.contacts';
|
|
|
34
35
|
* If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
|
|
35
36
|
* are visible without a cast.
|
|
36
37
|
*/
|
|
37
|
-
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPopupAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeveloperMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
38
|
+
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPopupAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeveloperMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
38
39
|
/**
|
|
39
40
|
* Constructor type for the fully composed mixin pipeline. Each mixin returns
|
|
40
41
|
* a new constructor that augments its input; reducing across the pipeline
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
|
|
|
35
35
|
export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
|
|
36
36
|
export type { CreateManagedAccountInput, ManagedAccountManager, ManagedAccount } from './mixins/OxyServices.managedAccounts';
|
|
37
37
|
export type { ContactDiscoveryMatch, ContactDiscoveryResponse } from './mixins/OxyServices.contacts';
|
|
38
|
+
export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
|
|
38
39
|
|
|
39
40
|
// --- Crypto / Identity ---
|
|
40
41
|
export {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user App-Data Mixin
|
|
3
|
+
*
|
|
4
|
+
* Thin client around `/users/me/app-data/...` — a generic per-user JSON KV
|
|
5
|
+
* store on the API. Authenticated callers can read, write, list, and delete
|
|
6
|
+
* entries scoped to their own user account.
|
|
7
|
+
*
|
|
8
|
+
* Identifier rules (must match the API):
|
|
9
|
+
* - Both `namespace` and `key` must match `/^[a-z0-9_-]{1,64}$/u`.
|
|
10
|
+
*
|
|
11
|
+
* Limits (enforced by the API):
|
|
12
|
+
* - Serialized JSON values are capped at 64 KB.
|
|
13
|
+
* - Writes are rate-limited to 100 / minute / user.
|
|
14
|
+
*
|
|
15
|
+
* Intended use cases are small bits of cross-device app state — e.g. Academy
|
|
16
|
+
* course progress, "last viewed" markers, dismissed banner flags. Do not use
|
|
17
|
+
* this for large blobs or anything that needs server-side querying; it's a
|
|
18
|
+
* write-it-and-read-it-back store.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Identifier validator — mirror of the API regex. Validating client-side
|
|
25
|
+
* gives consumers a clean throw before the request even leaves the device.
|
|
26
|
+
*/
|
|
27
|
+
const APP_DATA_IDENTIFIER_PATTERN = /^[a-z0-9_-]{1,64}$/u;
|
|
28
|
+
|
|
29
|
+
/** Thrown when a namespace or key fails the kebab/snake-case validator. */
|
|
30
|
+
export class OxyAppDataIdentifierError extends Error {
|
|
31
|
+
constructor(field: 'namespace' | 'key', value: string) {
|
|
32
|
+
super(
|
|
33
|
+
`Invalid app-data ${field} "${value}": must match [a-z0-9_-]{1,64} (lowercase letters, digits, dashes, underscores).`,
|
|
34
|
+
);
|
|
35
|
+
this.name = 'OxyAppDataIdentifierError';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertIdentifier(field: 'namespace' | 'key', value: string): void {
|
|
40
|
+
if (!APP_DATA_IDENTIFIER_PATTERN.test(value)) {
|
|
41
|
+
throw new OxyAppDataIdentifierError(field, value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Wire shape of `GET /users/me/app-data/:namespace/:key`. */
|
|
46
|
+
interface AppDataValueResponse<T> {
|
|
47
|
+
value: T | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Wire shape of `GET /users/me/app-data/:namespace`. */
|
|
51
|
+
interface AppDataNamespaceResponse<T> {
|
|
52
|
+
entries: Record<string, T>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function OxyServicesAppDataMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
56
|
+
return class extends Base {
|
|
57
|
+
constructor(...args: any[]) {
|
|
58
|
+
super(...(args as [any]));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Read the value stored under `(namespace, key)` for the current user.
|
|
63
|
+
*
|
|
64
|
+
* @returns The previously-stored value, or `null` if nothing has been
|
|
65
|
+
* stored yet. Never throws on "not found" — a missing entry is
|
|
66
|
+
* semantically a `null` value.
|
|
67
|
+
*/
|
|
68
|
+
async getAppData<TValue = unknown>(
|
|
69
|
+
namespace: string,
|
|
70
|
+
key: string,
|
|
71
|
+
): Promise<TValue | null> {
|
|
72
|
+
assertIdentifier('namespace', namespace);
|
|
73
|
+
assertIdentifier('key', key);
|
|
74
|
+
|
|
75
|
+
return this.withAuthRetry(async () => {
|
|
76
|
+
const response = await this.makeRequest<AppDataValueResponse<TValue>>(
|
|
77
|
+
'GET',
|
|
78
|
+
`/users/me/app-data/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
|
|
79
|
+
undefined,
|
|
80
|
+
{ cache: false },
|
|
81
|
+
);
|
|
82
|
+
return response?.value ?? null;
|
|
83
|
+
}, 'getAppData');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Upsert the value under `(namespace, key)` for the current user.
|
|
88
|
+
*
|
|
89
|
+
* Returns the value the server confirmed it stored — typically the same
|
|
90
|
+
* value the caller passed in, but consumers should prefer the returned
|
|
91
|
+
* value (the API is the source of truth).
|
|
92
|
+
*
|
|
93
|
+
* @throws OxyAppDataIdentifierError when namespace or key is malformed.
|
|
94
|
+
*/
|
|
95
|
+
async setAppData<TValue = unknown>(
|
|
96
|
+
namespace: string,
|
|
97
|
+
key: string,
|
|
98
|
+
value: TValue,
|
|
99
|
+
): Promise<TValue> {
|
|
100
|
+
assertIdentifier('namespace', namespace);
|
|
101
|
+
assertIdentifier('key', key);
|
|
102
|
+
|
|
103
|
+
return this.withAuthRetry(async () => {
|
|
104
|
+
const response = await this.makeRequest<AppDataValueResponse<TValue>>(
|
|
105
|
+
'PUT',
|
|
106
|
+
`/users/me/app-data/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
|
|
107
|
+
{ value },
|
|
108
|
+
{ cache: false },
|
|
109
|
+
);
|
|
110
|
+
// The server echoes the stored value back; fall back to the caller's
|
|
111
|
+
// input only if the server somehow omitted it (defensive — the route
|
|
112
|
+
// always sets it).
|
|
113
|
+
return (response?.value ?? value) as TValue;
|
|
114
|
+
}, 'setAppData');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Delete the value stored under `(namespace, key)` for the current user.
|
|
119
|
+
*
|
|
120
|
+
* Idempotent — resolves successfully whether or not the entry existed.
|
|
121
|
+
*/
|
|
122
|
+
async deleteAppData(namespace: string, key: string): Promise<void> {
|
|
123
|
+
assertIdentifier('namespace', namespace);
|
|
124
|
+
assertIdentifier('key', key);
|
|
125
|
+
|
|
126
|
+
await this.withAuthRetry(async () => {
|
|
127
|
+
await this.makeRequest<void>(
|
|
128
|
+
'DELETE',
|
|
129
|
+
`/users/me/app-data/${encodeURIComponent(namespace)}/${encodeURIComponent(key)}`,
|
|
130
|
+
undefined,
|
|
131
|
+
{ cache: false },
|
|
132
|
+
);
|
|
133
|
+
}, 'deleteAppData');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* List every entry stored under `namespace` for the current user.
|
|
138
|
+
*
|
|
139
|
+
* Returns an empty object when the namespace contains no entries (the
|
|
140
|
+
* endpoint never 404s on an empty namespace).
|
|
141
|
+
*/
|
|
142
|
+
async listAppData<TValue = unknown>(
|
|
143
|
+
namespace: string,
|
|
144
|
+
): Promise<Record<string, TValue>> {
|
|
145
|
+
assertIdentifier('namespace', namespace);
|
|
146
|
+
|
|
147
|
+
return this.withAuthRetry(async () => {
|
|
148
|
+
const response = await this.makeRequest<AppDataNamespaceResponse<TValue>>(
|
|
149
|
+
'GET',
|
|
150
|
+
`/users/me/app-data/${encodeURIComponent(namespace)}`,
|
|
151
|
+
undefined,
|
|
152
|
+
{ cache: false },
|
|
153
|
+
);
|
|
154
|
+
return response?.entries ?? {};
|
|
155
|
+
}, 'listAppData');
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-Data Mixin Tests
|
|
3
|
+
*
|
|
4
|
+
* Exercises the typed helpers around `/users/me/app-data/...`. We stub
|
|
5
|
+
* `makeRequest` so the tests run without a network or a database — what we
|
|
6
|
+
* care about here is request shape (method, URL, body), identifier
|
|
7
|
+
* validation, and response handling (`null` when missing, echo on write).
|
|
8
|
+
*
|
|
9
|
+
* The mixin sits behind `withAuthRetry`, which polls for a token before
|
|
10
|
+
* running the operation. We force a token in via `__resetTokensForTests`'
|
|
11
|
+
* companion path (set access token directly) so the auth wait short-circuits.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { OxyServices } from '../../OxyServices';
|
|
15
|
+
import { OxyAppDataIdentifierError } from '../OxyServices.appData';
|
|
16
|
+
|
|
17
|
+
const setAccessTokenForTest = (oxy: OxyServices): void => {
|
|
18
|
+
// Tokens are managed by HttpService — `hasAccessToken()` is the gate the
|
|
19
|
+
// `withAuthRetry` loop polls. Reaching in via the public httpService and
|
|
20
|
+
// calling setTokens with a dummy avoids us needing to expose new test
|
|
21
|
+
// hooks just for this.
|
|
22
|
+
oxy.httpService.setTokens('test-token', '');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('OxyServices.appData', () => {
|
|
26
|
+
let oxy: OxyServices;
|
|
27
|
+
let makeRequestSpy: jest.SpyInstance;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
31
|
+
setAccessTokenForTest(oxy);
|
|
32
|
+
makeRequestSpy = jest.spyOn(oxy, 'makeRequest');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
makeRequestSpy.mockRestore();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('getAppData', () => {
|
|
40
|
+
it('returns the stored value when the API responds with one', async () => {
|
|
41
|
+
makeRequestSpy.mockResolvedValue({ value: { completed: ['intro'] } });
|
|
42
|
+
|
|
43
|
+
const result = await oxy.getAppData<{ completed: string[] }>(
|
|
44
|
+
'academy',
|
|
45
|
+
'getting-started',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(result).toEqual({ completed: ['intro'] });
|
|
49
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
50
|
+
'GET',
|
|
51
|
+
'/users/me/app-data/academy/getting-started',
|
|
52
|
+
undefined,
|
|
53
|
+
expect.objectContaining({ cache: false }),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns null when the API responds with `value: null`', async () => {
|
|
58
|
+
makeRequestSpy.mockResolvedValue({ value: null });
|
|
59
|
+
const result = await oxy.getAppData('academy', 'unknown');
|
|
60
|
+
expect(result).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns null when the response object is missing `value`', async () => {
|
|
64
|
+
makeRequestSpy.mockResolvedValue({});
|
|
65
|
+
const result = await oxy.getAppData('academy', 'unknown');
|
|
66
|
+
expect(result).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('URL-encodes namespace and key path segments', async () => {
|
|
70
|
+
makeRequestSpy.mockResolvedValue({ value: 'ok' });
|
|
71
|
+
await oxy.getAppData('a-b_c', 'd-e_f');
|
|
72
|
+
// URL-encoding is a no-op for our allowed character set, but we still
|
|
73
|
+
// run through encodeURIComponent — make sure that's wired so the call
|
|
74
|
+
// site doesn't accidentally bypass it later.
|
|
75
|
+
expect(makeRequestSpy.mock.calls[0][1]).toBe('/users/me/app-data/a-b_c/d-e_f');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('throws OxyAppDataIdentifierError for invalid namespace', async () => {
|
|
79
|
+
await expect(oxy.getAppData('Bad Namespace', 'k')).rejects.toBeInstanceOf(
|
|
80
|
+
OxyAppDataIdentifierError,
|
|
81
|
+
);
|
|
82
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('throws OxyAppDataIdentifierError for invalid key', async () => {
|
|
86
|
+
await expect(oxy.getAppData('ns', 'Bad/Key')).rejects.toBeInstanceOf(
|
|
87
|
+
OxyAppDataIdentifierError,
|
|
88
|
+
);
|
|
89
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects empty identifiers (regex requires at least one char)', async () => {
|
|
93
|
+
await expect(oxy.getAppData('', 'k')).rejects.toBeInstanceOf(
|
|
94
|
+
OxyAppDataIdentifierError,
|
|
95
|
+
);
|
|
96
|
+
await expect(oxy.getAppData('n', '')).rejects.toBeInstanceOf(
|
|
97
|
+
OxyAppDataIdentifierError,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('rejects identifiers longer than 64 chars', async () => {
|
|
102
|
+
const tooLong = 'a'.repeat(65);
|
|
103
|
+
await expect(oxy.getAppData(tooLong, 'k')).rejects.toBeInstanceOf(
|
|
104
|
+
OxyAppDataIdentifierError,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('surfaces API errors (e.g. 401) via withAuthRetry', async () => {
|
|
109
|
+
const err = Object.assign(new Error('Authentication required'), {
|
|
110
|
+
response: { status: 401 },
|
|
111
|
+
});
|
|
112
|
+
makeRequestSpy.mockRejectedValue(err);
|
|
113
|
+
await expect(oxy.getAppData('academy', 'getting-started')).rejects.toThrow();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('setAppData', () => {
|
|
118
|
+
it('writes the value and returns the server-echoed value', async () => {
|
|
119
|
+
makeRequestSpy.mockResolvedValue({ value: { completed: ['intro'] } });
|
|
120
|
+
|
|
121
|
+
const result = await oxy.setAppData('academy', 'getting-started', {
|
|
122
|
+
completed: ['intro'],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result).toEqual({ completed: ['intro'] });
|
|
126
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
127
|
+
'PUT',
|
|
128
|
+
'/users/me/app-data/academy/getting-started',
|
|
129
|
+
{ value: { completed: ['intro'] } },
|
|
130
|
+
expect.objectContaining({ cache: false }),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('falls back to the caller value when the server response omits it', async () => {
|
|
135
|
+
makeRequestSpy.mockResolvedValue({});
|
|
136
|
+
const result = await oxy.setAppData('academy', 'k', 'hello');
|
|
137
|
+
expect(result).toBe('hello');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('throws OxyAppDataIdentifierError before issuing a request', async () => {
|
|
141
|
+
await expect(oxy.setAppData('UPPER', 'k', 1)).rejects.toBeInstanceOf(
|
|
142
|
+
OxyAppDataIdentifierError,
|
|
143
|
+
);
|
|
144
|
+
expect(makeRequestSpy).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('deleteAppData', () => {
|
|
149
|
+
it('issues a DELETE and resolves', async () => {
|
|
150
|
+
makeRequestSpy.mockResolvedValue(undefined);
|
|
151
|
+
|
|
152
|
+
await expect(oxy.deleteAppData('academy', 'getting-started')).resolves.toBeUndefined();
|
|
153
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
154
|
+
'DELETE',
|
|
155
|
+
'/users/me/app-data/academy/getting-started',
|
|
156
|
+
undefined,
|
|
157
|
+
expect.objectContaining({ cache: false }),
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('throws OxyAppDataIdentifierError on invalid identifiers', async () => {
|
|
162
|
+
await expect(oxy.deleteAppData('ns', 'BAD KEY')).rejects.toBeInstanceOf(
|
|
163
|
+
OxyAppDataIdentifierError,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('listAppData', () => {
|
|
169
|
+
it('returns the entries map from the API', async () => {
|
|
170
|
+
makeRequestSpy.mockResolvedValue({
|
|
171
|
+
entries: {
|
|
172
|
+
'getting-started': { completed: ['intro'] },
|
|
173
|
+
'using-oxy-id': { completed: [] },
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const result = await oxy.listAppData<{ completed: string[] }>('academy');
|
|
178
|
+
|
|
179
|
+
expect(result).toEqual({
|
|
180
|
+
'getting-started': { completed: ['intro'] },
|
|
181
|
+
'using-oxy-id': { completed: [] },
|
|
182
|
+
});
|
|
183
|
+
expect(makeRequestSpy).toHaveBeenCalledWith(
|
|
184
|
+
'GET',
|
|
185
|
+
'/users/me/app-data/academy',
|
|
186
|
+
undefined,
|
|
187
|
+
expect.objectContaining({ cache: false }),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('returns an empty object when the API returns no entries', async () => {
|
|
192
|
+
makeRequestSpy.mockResolvedValue({});
|
|
193
|
+
const result = await oxy.listAppData('academy');
|
|
194
|
+
expect(result).toEqual({});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('throws OxyAppDataIdentifierError on invalid namespace', async () => {
|
|
198
|
+
await expect(oxy.listAppData('Bad Namespace')).rejects.toBeInstanceOf(
|
|
199
|
+
OxyAppDataIdentifierError,
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { OxyServicesFeaturesMixin } from './OxyServices.features';
|
|
|
26
26
|
import { OxyServicesTopicsMixin } from './OxyServices.topics';
|
|
27
27
|
import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
|
|
28
28
|
import { OxyServicesContactsMixin } from './OxyServices.contacts';
|
|
29
|
+
import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
@@ -56,6 +57,7 @@ type AllMixinInstances =
|
|
|
56
57
|
& InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>>
|
|
57
58
|
& InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>>
|
|
58
59
|
& InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>>
|
|
60
|
+
& InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
|
|
59
61
|
& InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
60
62
|
|
|
61
63
|
/**
|
|
@@ -115,6 +117,7 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
115
117
|
OxyServicesTopicsMixin,
|
|
116
118
|
OxyServicesManagedAccountsMixin,
|
|
117
119
|
OxyServicesContactsMixin,
|
|
120
|
+
OxyServicesAppDataMixin,
|
|
118
121
|
|
|
119
122
|
// Utility (last, can use all above)
|
|
120
123
|
OxyServicesUtilityMixin,
|