@oxyhq/core 9.2.4 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/index.js +22 -2
  3. package/dist/cjs/i18n/locales/en-US.json +4 -1
  4. package/dist/cjs/i18n/locales/es-ES.json +4 -1
  5. package/dist/cjs/i18n/locales/locales/en-US.json +4 -1
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +4 -1
  7. package/dist/cjs/index.js +16 -5
  8. package/dist/cjs/mixins/OxyServices.assets.js +10 -6
  9. package/dist/cjs/mixins/OxyServices.language.js +11 -10
  10. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  11. package/dist/cjs/mixins/OxyServices.user.js +40 -0
  12. package/dist/cjs/server/safeFetch.js +3 -3
  13. package/dist/cjs/session/SessionClient.js +1 -1
  14. package/dist/cjs/shared/utils/colorUtils.js +9 -9
  15. package/dist/cjs/utils/languageUtils.js +195 -158
  16. package/dist/cjs/utils/platform.js +9 -2
  17. package/dist/cjs/utils/textNormalization.js +168 -0
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/i18n/index.js +22 -2
  20. package/dist/esm/i18n/locales/en-US.json +4 -1
  21. package/dist/esm/i18n/locales/es-ES.json +4 -1
  22. package/dist/esm/i18n/locales/locales/en-US.json +4 -1
  23. package/dist/esm/i18n/locales/locales/es-ES.json +4 -1
  24. package/dist/esm/index.js +5 -1
  25. package/dist/esm/mixins/OxyServices.assets.js +10 -6
  26. package/dist/esm/mixins/OxyServices.language.js +12 -11
  27. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  28. package/dist/esm/mixins/OxyServices.user.js +40 -0
  29. package/dist/esm/server/safeFetch.js +3 -3
  30. package/dist/esm/session/SessionClient.js +1 -1
  31. package/dist/esm/shared/utils/colorUtils.js +9 -9
  32. package/dist/esm/utils/languageUtils.js +189 -156
  33. package/dist/esm/utils/platform.js +9 -2
  34. package/dist/esm/utils/textNormalization.js +164 -0
  35. package/dist/types/.tsbuildinfo +1 -1
  36. package/dist/types/index.d.ts +4 -3
  37. package/dist/types/mixins/OxyServices.language.d.ts +4 -4
  38. package/dist/types/mixins/OxyServices.user.d.ts +30 -0
  39. package/dist/types/models/interfaces.d.ts +14 -0
  40. package/dist/types/session/accountDialogController.d.ts +1 -1
  41. package/dist/types/utils/languageUtils.d.ts +86 -24
  42. package/dist/types/utils/textNormalization.d.ts +100 -0
  43. package/package.json +3 -3
  44. package/src/HttpService.ts +2 -2
  45. package/src/crypto/keyManager.ts +3 -3
  46. package/src/crypto/recoveryPhrase.ts +1 -1
  47. package/src/i18n/index.ts +21 -2
  48. package/src/i18n/locales/en-US.json +4 -1
  49. package/src/i18n/locales/es-ES.json +4 -1
  50. package/src/index.ts +16 -2
  51. package/src/mixins/OxyServices.assets.ts +15 -11
  52. package/src/mixins/OxyServices.language.ts +31 -17
  53. package/src/mixins/OxyServices.privacy.ts +6 -0
  54. package/src/mixins/OxyServices.security.ts +1 -1
  55. package/src/mixins/OxyServices.user.ts +57 -0
  56. package/src/models/interfaces.ts +14 -0
  57. package/src/server/safeFetch.ts +3 -3
  58. package/src/session/SessionClient.ts +1 -1
  59. package/src/session/accountDialogController.ts +1 -1
  60. package/src/shared/utils/colorUtils.ts +11 -11
  61. package/src/shared/utils/errorUtils.ts +1 -1
  62. package/src/utils/__tests__/languageUtils.test.ts +219 -0
  63. package/src/utils/__tests__/textNormalization.test.ts +196 -0
  64. package/src/utils/avatarUtils.ts +1 -1
  65. package/src/utils/languageUtils.ts +223 -162
  66. package/src/utils/platform.ts +21 -3
  67. package/src/utils/requestUtils.ts +3 -3
  68. package/src/utils/sessionUtils.ts +2 -2
  69. package/src/utils/textNormalization.ts +173 -0
@@ -1,12 +1,25 @@
1
1
  /**
2
2
  * Language Methods Mixin
3
3
  */
4
- import { normalizeLanguageCode, getLanguageMetadata, getLanguageName, getNativeLanguageName } from '../utils/languageUtils';
5
- import type { LanguageMetadata } from '../utils/languageUtils';
4
+ import { normalizeLocale, getPrimaryLanguage, getLanguageMetadata, getLanguageName, getNativeLanguageName } from '../utils/languageUtils';
5
+ import type { SupportedLanguage } from '../utils/languageUtils';
6
6
  import type { OxyServicesBase } from '../OxyServices.base';
7
7
  import { loadAsyncStorage } from '@oxyhq/protocol';
8
8
  import { isDev } from '../shared/utils/debugUtils';
9
9
 
10
+ /**
11
+ * Cross-mixin surface consumed by the language methods. `getCurrentUser` is
12
+ * provided by the user mixin at runtime — both live on the composed
13
+ * `OxyServices` instance — but it is not visible on `OxyServicesBase` at the
14
+ * type level. We narrow `this` through this interface (mirroring the
15
+ * `OxyAuthInstance` pattern in `OxyServices.utility.ts`) instead of casting to
16
+ * `any`. Only the fields this mixin reads are declared, keeping it decoupled
17
+ * from the full `User` shape.
18
+ */
19
+ interface LanguageMixinCrossAccess {
20
+ getCurrentUser(): Promise<{ languages?: string[] }>;
21
+ }
22
+
10
23
  export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base: T) {
11
24
  return class extends Base {
12
25
  constructor(...args: any[]) {
@@ -62,29 +75,30 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
62
75
  }
63
76
 
64
77
  /**
65
- * Get the current language from storage or user profile
78
+ * Get the current locale from the user profile or local storage.
66
79
  * @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
67
- * @returns The current language code (e.g., 'en-US') or null if not set
80
+ * @returns The current BCP-47 locale (e.g., 'en-US') or null if not set
68
81
  */
69
- async getCurrentLanguage(storageKeyPrefix: string = 'oxy_session'): Promise<string | null> {
82
+ async getCurrentLanguage(storageKeyPrefix = 'oxy_session'): Promise<string | null> {
70
83
  try {
71
- // First try to get from user profile if authenticated
84
+ // First try the authenticated user's primary account locale.
72
85
  try {
73
- const user = await (this as any).getCurrentUser();
74
- const userLanguage = (user as Record<string, unknown>)?.language as string | undefined;
75
- if (userLanguage) {
76
- return normalizeLanguageCode(userLanguage) || userLanguage;
86
+ const user = await (this as unknown as LanguageMixinCrossAccess).getCurrentUser();
87
+ const primary = getPrimaryLanguage(user);
88
+ if (primary) {
89
+ return primary;
77
90
  }
78
- } catch (e) {
79
- // User not authenticated or error, continue to storage
91
+ } catch {
92
+ // Not authenticated or the profile fetch failed — fall through to the
93
+ // locally stored preference below.
80
94
  }
81
95
 
82
- // Fall back to storage
96
+ // Fall back to the locally stored locale preference.
83
97
  const storage = await this.getStorage();
84
98
  const storageKey = `${storageKeyPrefix}_language`;
85
99
  const storedLanguage = await storage.getItem(storageKey);
86
100
  if (storedLanguage) {
87
- return normalizeLanguageCode(storedLanguage) || storedLanguage;
101
+ return normalizeLocale(storedLanguage) ?? storedLanguage;
88
102
  }
89
103
 
90
104
  return null;
@@ -101,7 +115,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
101
115
  * @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
102
116
  * @returns Language metadata object or null if not set
103
117
  */
104
- async getCurrentLanguageMetadata(storageKeyPrefix: string = 'oxy_session'): Promise<LanguageMetadata | null> {
118
+ async getCurrentLanguageMetadata(storageKeyPrefix = 'oxy_session'): Promise<SupportedLanguage | null> {
105
119
  const languageCode = await this.getCurrentLanguage(storageKeyPrefix);
106
120
  return getLanguageMetadata(languageCode);
107
121
  }
@@ -111,7 +125,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
111
125
  * @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
112
126
  * @returns Language name or null if not set
113
127
  */
114
- async getCurrentLanguageName(storageKeyPrefix: string = 'oxy_session'): Promise<string | null> {
128
+ async getCurrentLanguageName(storageKeyPrefix = 'oxy_session'): Promise<string | null> {
115
129
  const languageCode = await this.getCurrentLanguage(storageKeyPrefix);
116
130
  if (!languageCode) return null;
117
131
  return getLanguageName(languageCode);
@@ -122,7 +136,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
122
136
  * @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
123
137
  * @returns Native language name or null if not set
124
138
  */
125
- async getCurrentNativeLanguageName(storageKeyPrefix: string = 'oxy_session'): Promise<string | null> {
139
+ async getCurrentNativeLanguageName(storageKeyPrefix = 'oxy_session'): Promise<string | null> {
126
140
  const languageCode = await this.getCurrentLanguage(storageKeyPrefix);
127
141
  if (!languageCode) return null;
128
142
  return getNativeLanguageName(languageCode);
@@ -82,6 +82,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
82
82
  cache: false,
83
83
  });
84
84
  this.clearCacheEntry('GET:/privacy/blocked');
85
+ // The block changed the viewer's graph (`blockedIds`) — bust the cached
86
+ // consolidated `GET /users/me/graph` so the next read reflects it.
87
+ this.clearCacheEntry('GET:/users/me/graph');
85
88
  return result;
86
89
  } catch (error) {
87
90
  throw this.handleError(error);
@@ -105,6 +108,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
105
108
  cache: false,
106
109
  });
107
110
  this.clearCacheEntry('GET:/privacy/blocked');
111
+ // Symmetric to blockUser: the unblock changed the viewer's `blockedIds`,
112
+ // so bust the consolidated `GET /users/me/graph` cache.
113
+ this.clearCacheEntry('GET:/users/me/graph');
108
114
  return result;
109
115
  } catch (error) {
110
116
  throw this.handleError(error);
@@ -57,7 +57,7 @@ export function OxyServicesSecurityMixin<T extends typeof OxyServicesBase>(Base:
57
57
  * @param limit - Number of recent events to fetch (default: 10)
58
58
  * @returns Array of recent security activities
59
59
  */
60
- async getRecentSecurityActivity(limit: number = 10): Promise<SecurityActivity[]> {
60
+ async getRecentSecurityActivity(limit = 10): Promise<SecurityActivity[]> {
61
61
  try {
62
62
  const response = await this.getSecurityActivity(limit, 0);
63
63
  return response.data || [];
@@ -49,6 +49,23 @@ export interface BulkFollowResult {
49
49
  followedCount: number;
50
50
  }
51
51
 
52
+ /**
53
+ * The authenticated viewer's OWN social graph, ids-only — the response of
54
+ * `GET /users/me/graph`. Consolidates the accounts the viewer follows, the
55
+ * subset who follow back (mutuals), and the accounts the viewer has blocked
56
+ * into one payload so a consumer can fetch its whole viewer graph in a single
57
+ * round trip instead of three. Each list is server-bounded; bare ids only (no
58
+ * hydrated DTOs) because the consumer hydrates/ranks itself.
59
+ */
60
+ export interface ViewerGraph {
61
+ /** Accounts the viewer follows (most-recent first, bounded). */
62
+ followingIds: string[];
63
+ /** Accounts the viewer follows that ALSO follow the viewer back (bounded). */
64
+ mutualIds: string[];
65
+ /** Accounts the viewer has blocked (bounded). */
66
+ blockedIds: string[];
67
+ }
68
+
52
69
  /** Per-user outcome returned by `POST /users/unfollow/bulk`. */
53
70
  export interface BulkUnfollowEntry {
54
71
  /** The user ID that was processed. */
@@ -666,6 +683,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
666
683
  try {
667
684
  const result = await this.makeRequest<{ success: boolean; message: string }>('POST', `/users/${userId}/follow`, undefined, { cache: false });
668
685
  this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
686
+ // The follow changed the viewer's graph — bust the cached consolidated
687
+ // `GET /users/me/graph` so the next read reflects the new following/
688
+ // mutual set instead of the stale pre-write snapshot.
689
+ this.clearCacheEntry('GET:/users/me/graph');
669
690
  return result;
670
691
  } catch (error) {
671
692
  throw this.handleError(error);
@@ -690,6 +711,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
690
711
  for (const id of userIds) {
691
712
  this.clearCacheEntry(`GET:/users/${id}/follow-status`);
692
713
  }
714
+ // The batch changed the viewer's graph — bust the consolidated cache.
715
+ this.clearCacheEntry('GET:/users/me/graph');
693
716
  return result;
694
717
  } catch (error) {
695
718
  throw this.handleError(error);
@@ -714,6 +737,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
714
737
  for (const id of userIds) {
715
738
  this.clearCacheEntry(`GET:/users/${id}/follow-status`);
716
739
  }
740
+ // The batch changed the viewer's graph — bust the consolidated cache.
741
+ this.clearCacheEntry('GET:/users/me/graph');
717
742
  return result;
718
743
  } catch (error) {
719
744
  throw this.handleError(error);
@@ -728,6 +753,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
728
753
  const result = await this.makeRequest<{ success: boolean; message: string }>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
729
754
  // Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
730
755
  this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
756
+ // The unfollow changed the viewer's graph — bust the consolidated cache.
757
+ this.clearCacheEntry('GET:/users/me/graph');
731
758
  return result;
732
759
  } catch (error) {
733
760
  throw this.handleError(error);
@@ -872,6 +899,36 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
872
899
  }
873
900
  }
874
901
 
902
+ /**
903
+ * Get the authenticated VIEWER's OWN social graph — the accounts they follow,
904
+ * the subset who follow back (mutuals), and the accounts they have blocked —
905
+ * as ONE ids-only payload. The viewer is derived server-side from the SDK's
906
+ * auth token (never a param).
907
+ *
908
+ * Consolidates what were three separate round trips (`getUserFollowing` /
909
+ * `getMutualUserIds` / `getBlockedUsers`) into a single request so a consumer
910
+ * can prime its whole viewer graph at once. Mirrors {@link getMutualUserIds}'s
911
+ * caching posture (2-minute identity-scoped cache); the follow/unfollow/block/
912
+ * unblock write methods bust this entry so a local mutation is reflected
913
+ * immediately. An anonymous caller resolves to empty lists.
914
+ */
915
+ async getViewerGraph(): Promise<ViewerGraph> {
916
+ try {
917
+ const response = await this.makeRequest<{ data: ViewerGraph }>('GET', '/users/me/graph', undefined, {
918
+ cache: true,
919
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
920
+ });
921
+ const graph = response.data;
922
+ return {
923
+ followingIds: graph?.followingIds || [],
924
+ mutualIds: graph?.mutualIds || [],
925
+ blockedIds: graph?.blockedIds || [],
926
+ };
927
+ } catch (error) {
928
+ throw this.handleError(error);
929
+ }
930
+ }
931
+
875
932
  /**
876
933
  * Get notifications
877
934
  */
@@ -103,6 +103,12 @@ export interface User {
103
103
  */
104
104
  name: UserNameResponse;
105
105
  bio?: string;
106
+ /**
107
+ * Longer public profile text. Emitted alongside `bio` by every user DTO the
108
+ * API produces (single profile, `getUsersByIds`, follower/following/mutual
109
+ * lists, profile search) — the two are separate fields on the User document.
110
+ */
111
+ description?: string;
106
112
  phone?: string;
107
113
  address?: string;
108
114
  birthday?: string;
@@ -144,6 +150,14 @@ export interface User {
144
150
  managedBy?: string;
145
151
  /** Real-estate taxonomy when this user is a `kind: 'organization'` account. */
146
152
  organizationCategory?: OrganizationCategory;
153
+ /**
154
+ * The account's languages as full BCP-47 locales (`language-REGION`, e.g.
155
+ * `en-US`, `es-MX`, `pt-BR`), ordered with the PRIMARY (UI) locale first.
156
+ * `languages[0]` is the primary locale — there is no singular `language`
157
+ * field. Resolve via `getUserLanguages` / `getPrimaryLanguage`, which
158
+ * normalize, validate against the supported catalog, and de-duplicate.
159
+ */
160
+ languages?: string[];
147
161
  // User-controlled notification preferences. All channels default to on; users
148
162
  // opt out per-channel. Updated via `PUT /users/me`.
149
163
  notificationPreferences?: NotificationPreferences;
@@ -183,7 +183,7 @@ function ipv6ToGroups(ip: string): number[] | null {
183
183
  const groups: number[] = [];
184
184
  for (const part of segment.split(':')) {
185
185
  if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return null;
186
- groups.push(parseInt(part, 16));
186
+ groups.push(Number.parseInt(part, 16));
187
187
  }
188
188
  return groups;
189
189
  };
@@ -234,8 +234,8 @@ function extractEmbeddedIpv4(ip: string): string | null {
234
234
  // Hex form "::ffff:0102:0304" → 1.2.3.4
235
235
  const hexMapped = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
236
236
  if (hexMapped) {
237
- const hi = parseInt(hexMapped[1], 16);
238
- const lo = parseInt(hexMapped[2], 16);
237
+ const hi = Number.parseInt(hexMapped[1], 16);
238
+ const lo = Number.parseInt(hexMapped[2], 16);
239
239
  return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
240
240
  }
241
241
  return null;
@@ -374,7 +374,7 @@ export class SessionClient {
374
374
  transports: ['websocket'],
375
375
  autoConnect: true,
376
376
  reconnection: true,
377
- reconnectionAttempts: Infinity,
377
+ reconnectionAttempts: Number.POSITIVE_INFINITY,
378
378
  reconnectionDelay: 1000,
379
379
  reconnectionDelayMax: 10000,
380
380
  auth: (cb: (data: Record<string, string>) => void) => {
@@ -37,7 +37,7 @@ import {
37
37
  normalizeOAuthRedirectUri,
38
38
  persistOAuthHandshake,
39
39
  } from '../utils/oauthPkce';
40
- import { SessionClient } from './SessionClient';
40
+ import type { SessionClient } from './SessionClient';
41
41
  import {
42
42
  projectSwitchableAccounts,
43
43
  switchableAccountIds,
@@ -20,12 +20,12 @@
20
20
  * darkenColor('FF0000', 0.3); // Also works without #
21
21
  * ```
22
22
  */
23
- export const darkenColor = (color: string, factor: number = 0.6): string => {
23
+ export const darkenColor = (color: string, factor = 0.6): string => {
24
24
  const hex = color.replace('#', '');
25
25
 
26
- const r = parseInt(hex.substring(0, 2), 16);
27
- const g = parseInt(hex.substring(2, 4), 16);
28
- const b = parseInt(hex.substring(4, 6), 16);
26
+ const r = Number.parseInt(hex.substring(0, 2), 16);
27
+ const g = Number.parseInt(hex.substring(2, 4), 16);
28
+ const b = Number.parseInt(hex.substring(4, 6), 16);
29
29
 
30
30
  const newR = Math.max(0, Math.round(r * (1 - factor)));
31
31
  const newG = Math.max(0, Math.round(g * (1 - factor)));
@@ -46,12 +46,12 @@ export const darkenColor = (color: string, factor: number = 0.6): string => {
46
46
  * lightenColor('#0000FF', 0.5); // Returns a lighter blue
47
47
  * ```
48
48
  */
49
- export const lightenColor = (color: string, factor: number = 0.3): string => {
49
+ export const lightenColor = (color: string, factor = 0.3): string => {
50
50
  const hex = color.replace('#', '');
51
51
 
52
- const r = parseInt(hex.substring(0, 2), 16);
53
- const g = parseInt(hex.substring(2, 4), 16);
54
- const b = parseInt(hex.substring(4, 6), 16);
52
+ const r = Number.parseInt(hex.substring(0, 2), 16);
53
+ const g = Number.parseInt(hex.substring(2, 4), 16);
54
+ const b = Number.parseInt(hex.substring(4, 6), 16);
55
55
 
56
56
  const newR = Math.min(255, Math.round(r + (255 - r) * factor));
57
57
  const newG = Math.min(255, Math.round(g + (255 - g) * factor));
@@ -75,9 +75,9 @@ export const hexToRgb = (hex: string): { r: number; g: number; b: number } | nul
75
75
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
76
76
  return result
77
77
  ? {
78
- r: parseInt(result[1], 16),
79
- g: parseInt(result[2], 16),
80
- b: parseInt(result[3], 16),
78
+ r: Number.parseInt(result[1], 16),
79
+ g: Number.parseInt(result[2], 16),
80
+ b: Number.parseInt(result[3], 16),
81
81
  }
82
82
  : null;
83
83
  };
@@ -57,7 +57,7 @@ export const getErrorStatus = (error: unknown): number | undefined => {
57
57
  * @param fallback - Fallback message if none found
58
58
  * @returns The error message
59
59
  */
60
- export const getErrorMessage = (error: unknown, fallback: string = 'An unknown error occurred'): string => {
60
+ export const getErrorMessage = (error: unknown, fallback = 'An unknown error occurred'): string => {
61
61
  if (!error) return fallback;
62
62
 
63
63
  if (typeof error === 'string') return error;
@@ -0,0 +1,219 @@
1
+ import {
2
+ SUPPORTED_LANGUAGES,
3
+ getBaseLanguage,
4
+ normalizeLocale,
5
+ isSupportedLocale,
6
+ getLanguageMetadata,
7
+ getLanguageName,
8
+ getNativeLanguageName,
9
+ isRTLLocale,
10
+ getUserLanguages,
11
+ getPrimaryLanguage,
12
+ } from '../languageUtils';
13
+
14
+ describe('getBaseLanguage', () => {
15
+ it('extracts the base subtag from a full locale, lowercased', () => {
16
+ expect(getBaseLanguage('es-ES')).toBe('es');
17
+ expect(getBaseLanguage('EN-us')).toBe('en');
18
+ });
19
+
20
+ it('is tolerant of a bare base subtag', () => {
21
+ expect(getBaseLanguage('es')).toBe('es');
22
+ expect(getBaseLanguage('ES')).toBe('es');
23
+ });
24
+
25
+ it('ignores script/variant subtags', () => {
26
+ expect(getBaseLanguage('zh-Hant-TW')).toBe('zh');
27
+ });
28
+
29
+ it('trims surrounding whitespace and handles empty input', () => {
30
+ expect(getBaseLanguage(' fr-FR ')).toBe('fr');
31
+ expect(getBaseLanguage('')).toBe('');
32
+ });
33
+ });
34
+
35
+ describe('normalizeLocale', () => {
36
+ it('canonicalizes case: lowercase base, uppercase region', () => {
37
+ expect(normalizeLocale('es-es')).toBe('es-ES');
38
+ expect(normalizeLocale('EN-us')).toBe('en-US');
39
+ expect(normalizeLocale('PT-br')).toBe('pt-BR');
40
+ });
41
+
42
+ it('returns already-canonical supported locales unchanged', () => {
43
+ expect(normalizeLocale('es-MX')).toBe('es-MX');
44
+ expect(normalizeLocale('zh-TW')).toBe('zh-TW');
45
+ });
46
+
47
+ it('trims surrounding whitespace', () => {
48
+ expect(normalizeLocale(' fr-CA ')).toBe('fr-CA');
49
+ });
50
+
51
+ it('collapses extra subtags to language-REGION', () => {
52
+ expect(normalizeLocale('zh-Hant-TW')).toBe('zh-TW');
53
+ });
54
+
55
+ it('returns undefined for a bare base subtag (no region)', () => {
56
+ expect(normalizeLocale('es')).toBeUndefined();
57
+ expect(normalizeLocale('en')).toBeUndefined();
58
+ });
59
+
60
+ it('returns undefined for unsupported locales', () => {
61
+ expect(normalizeLocale('xx-ZZ')).toBeUndefined();
62
+ expect(normalizeLocale('es-ZZ')).toBeUndefined();
63
+ });
64
+
65
+ it('returns undefined for empty or whitespace input', () => {
66
+ expect(normalizeLocale('')).toBeUndefined();
67
+ expect(normalizeLocale(' ')).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe('isSupportedLocale', () => {
72
+ it('is true for supported locales in any case', () => {
73
+ expect(isSupportedLocale('es-ES')).toBe(true);
74
+ expect(isSupportedLocale('es-es')).toBe(true);
75
+ });
76
+
77
+ it('is false for bare subtags and unknown locales', () => {
78
+ expect(isSupportedLocale('es')).toBe(false);
79
+ expect(isSupportedLocale('xx-ZZ')).toBe(false);
80
+ expect(isSupportedLocale('')).toBe(false);
81
+ });
82
+ });
83
+
84
+ describe('getLanguageMetadata', () => {
85
+ it('resolves a supported locale to its catalog entry', () => {
86
+ const entry = getLanguageMetadata('es-mx');
87
+ expect(entry).not.toBeNull();
88
+ expect(entry?.code).toBe('es-MX');
89
+ expect(entry?.language).toBe('es');
90
+ expect(entry?.region).toBe('MX');
91
+ });
92
+
93
+ it('returns null for bare, unknown, or empty codes', () => {
94
+ expect(getLanguageMetadata('es')).toBeNull();
95
+ expect(getLanguageMetadata('xx-ZZ')).toBeNull();
96
+ expect(getLanguageMetadata('')).toBeNull();
97
+ expect(getLanguageMetadata(null)).toBeNull();
98
+ expect(getLanguageMetadata(undefined)).toBeNull();
99
+ });
100
+ });
101
+
102
+ describe('getLanguageName / getNativeLanguageName', () => {
103
+ it('returns the English and native display names for a supported locale', () => {
104
+ expect(getLanguageName('es-ES')).toBe('Spanish (Spain)');
105
+ expect(getNativeLanguageName('es-ES')).toBe('Español (España)');
106
+ });
107
+
108
+ it('falls back to the input tag for unsupported locales', () => {
109
+ expect(getLanguageName('xx-ZZ')).toBe('xx-ZZ');
110
+ expect(getNativeLanguageName('xx-ZZ')).toBe('xx-ZZ');
111
+ });
112
+
113
+ it('falls back to the fallback locale for empty input', () => {
114
+ expect(getLanguageName('')).toBe('en-US');
115
+ expect(getNativeLanguageName(null)).toBe('en-US');
116
+ });
117
+ });
118
+
119
+ describe('isRTLLocale', () => {
120
+ it('is true for right-to-left locales and their base subtags', () => {
121
+ expect(isRTLLocale('ar-SA')).toBe(true);
122
+ expect(isRTLLocale('he-IL')).toBe(true);
123
+ expect(isRTLLocale('fa')).toBe(true);
124
+ expect(isRTLLocale('ur-PK')).toBe(true);
125
+ });
126
+
127
+ it('is false for left-to-right locales and empty input', () => {
128
+ expect(isRTLLocale('en-US')).toBe(false);
129
+ expect(isRTLLocale('es-ES')).toBe(false);
130
+ expect(isRTLLocale(null)).toBe(false);
131
+ expect(isRTLLocale(undefined)).toBe(false);
132
+ });
133
+ });
134
+
135
+ describe('getUserLanguages', () => {
136
+ it('normalizes and validates the plural array, preserving order', () => {
137
+ expect(getUserLanguages({ languages: ['es-es', 'EN-us', 'pt-BR'] })).toEqual([
138
+ 'es-ES',
139
+ 'en-US',
140
+ 'pt-BR',
141
+ ]);
142
+ });
143
+
144
+ it('drops unsupported locales and bare subtags', () => {
145
+ expect(getUserLanguages({ languages: ['es-ES', 'xx-ZZ', 'en'] })).toEqual(['es-ES']);
146
+ });
147
+
148
+ it('de-duplicates after normalization, keeping first-seen order', () => {
149
+ expect(getUserLanguages({ languages: ['es-ES', 'es-es', 'en-US', 'ES-es'] })).toEqual([
150
+ 'es-ES',
151
+ 'en-US',
152
+ ]);
153
+ });
154
+
155
+ it('ignores non-string junk entries without throwing', () => {
156
+ const languages = ['es-ES', 42, null, undefined, {}, 'en-US'] as unknown as string[];
157
+ expect(getUserLanguages({ languages })).toEqual(['es-ES', 'en-US']);
158
+ });
159
+
160
+ it('returns an empty array when languages is absent, empty, or non-array', () => {
161
+ expect(getUserLanguages({})).toEqual([]);
162
+ expect(getUserLanguages({ languages: [] })).toEqual([]);
163
+ expect(getUserLanguages({ languages: undefined })).toEqual([]);
164
+ expect(getUserLanguages(null)).toEqual([]);
165
+ expect(getUserLanguages(undefined)).toEqual([]);
166
+ });
167
+
168
+ it('ignores any stray singular language field (removed from User)', () => {
169
+ const strayLanguage: { language: string; languages?: string[] } = { language: 'es-ES' };
170
+ expect(getUserLanguages(strayLanguage)).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe('getPrimaryLanguage', () => {
175
+ it('returns the first normalized locale from the plural array', () => {
176
+ expect(getPrimaryLanguage({ languages: ['es-es', 'en-US'] })).toBe('es-ES');
177
+ });
178
+
179
+ it('skips leading unsupported entries', () => {
180
+ expect(getPrimaryLanguage({ languages: ['xx-ZZ', 'pt-br'] })).toBe('pt-BR');
181
+ });
182
+
183
+ it('returns undefined when the user has no supported locale', () => {
184
+ expect(getPrimaryLanguage({})).toBeUndefined();
185
+ expect(getPrimaryLanguage({ languages: ['xx-ZZ'] })).toBeUndefined();
186
+ expect(getPrimaryLanguage(null)).toBeUndefined();
187
+ });
188
+ });
189
+
190
+ describe('SUPPORTED_LANGUAGES catalog integrity', () => {
191
+ it('is non-empty', () => {
192
+ expect(SUPPORTED_LANGUAGES.length).toBeGreaterThan(0);
193
+ });
194
+
195
+ it('has no duplicate codes', () => {
196
+ const codes = SUPPORTED_LANGUAGES.map((entry) => entry.code);
197
+ expect(new Set(codes).size).toBe(codes.length);
198
+ });
199
+
200
+ it('every entry is internally consistent', () => {
201
+ for (const entry of SUPPORTED_LANGUAGES) {
202
+ // code is canonical language-REGION derived from its subtags
203
+ expect(entry.code).toBe(`${entry.language}-${entry.region}`);
204
+ // base subtag is lowercase, region is uppercase
205
+ expect(entry.language).toBe(entry.language.toLowerCase());
206
+ expect(entry.region).toBe(entry.region.toUpperCase());
207
+ // parsing the code recovers the subtags
208
+ expect(getBaseLanguage(entry.code)).toBe(entry.language);
209
+ // the code round-trips through normalization
210
+ expect(normalizeLocale(entry.code)).toBe(entry.code);
211
+ expect(isSupportedLocale(entry.code)).toBe(true);
212
+ // display names are present
213
+ expect(entry.name.length).toBeGreaterThan(0);
214
+ expect(entry.nativeName.length).toBeGreaterThan(0);
215
+ // rtl flag agrees with the RTL detector
216
+ expect(isRTLLocale(entry.code)).toBe(entry.rtl === true);
217
+ }
218
+ });
219
+ });