@nativesquare/soma 0.3.0 → 0.4.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 (101) hide show
  1. package/dist/client/index.d.ts +167 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +150 -0
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/api.d.ts +2 -0
  6. package/dist/component/_generated/api.d.ts.map +1 -1
  7. package/dist/component/_generated/api.js.map +1 -1
  8. package/dist/component/_generated/component.d.ts +56 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/garmin.d.ts +110 -0
  11. package/dist/component/garmin.d.ts.map +1 -0
  12. package/dist/component/garmin.js +454 -0
  13. package/dist/component/garmin.js.map +1 -0
  14. package/dist/component/public.d.ts +761 -761
  15. package/dist/component/schema.d.ts +390 -388
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +3 -2
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/component/strava.d.ts +5 -4
  20. package/dist/component/strava.d.ts.map +1 -1
  21. package/dist/component/strava.js +18 -1
  22. package/dist/component/strava.js.map +1 -1
  23. package/dist/component/validators/activity.d.ts +42 -42
  24. package/dist/component/validators/body.d.ts +47 -47
  25. package/dist/component/validators/daily.d.ts +17 -17
  26. package/dist/component/validators/plannedWorkout.d.ts +5 -5
  27. package/dist/component/validators/samples.d.ts +2 -2
  28. package/dist/component/validators/shared.d.ts +17 -17
  29. package/dist/component/validators/sleep.d.ts +17 -17
  30. package/dist/garmin/activity.d.ts +101 -0
  31. package/dist/garmin/activity.d.ts.map +1 -0
  32. package/dist/garmin/activity.js +207 -0
  33. package/dist/garmin/activity.js.map +1 -0
  34. package/dist/garmin/auth.d.ts +65 -0
  35. package/dist/garmin/auth.d.ts.map +1 -0
  36. package/dist/garmin/auth.js +155 -0
  37. package/dist/garmin/auth.js.map +1 -0
  38. package/dist/garmin/body.d.ts +26 -0
  39. package/dist/garmin/body.d.ts.map +1 -0
  40. package/dist/garmin/body.js +44 -0
  41. package/dist/garmin/body.js.map +1 -0
  42. package/dist/garmin/client.d.ts +99 -0
  43. package/dist/garmin/client.d.ts.map +1 -0
  44. package/dist/garmin/client.js +153 -0
  45. package/dist/garmin/client.js.map +1 -0
  46. package/dist/garmin/daily.d.ts +74 -0
  47. package/dist/garmin/daily.d.ts.map +1 -0
  48. package/dist/garmin/daily.js +143 -0
  49. package/dist/garmin/daily.js.map +1 -0
  50. package/dist/garmin/index.d.ts +20 -0
  51. package/dist/garmin/index.d.ts.map +1 -0
  52. package/dist/garmin/index.js +21 -0
  53. package/dist/garmin/index.js.map +1 -0
  54. package/dist/garmin/maps/activity-type.d.ts +7 -0
  55. package/dist/garmin/maps/activity-type.d.ts.map +1 -0
  56. package/dist/garmin/maps/activity-type.js +98 -0
  57. package/dist/garmin/maps/activity-type.js.map +1 -0
  58. package/dist/garmin/maps/sleep-level.d.ts +6 -0
  59. package/dist/garmin/maps/sleep-level.d.ts.map +1 -0
  60. package/dist/garmin/maps/sleep-level.js +21 -0
  61. package/dist/garmin/maps/sleep-level.js.map +1 -0
  62. package/dist/garmin/menstruation.d.ts +23 -0
  63. package/dist/garmin/menstruation.d.ts.map +1 -0
  64. package/dist/garmin/menstruation.js +34 -0
  65. package/dist/garmin/menstruation.js.map +1 -0
  66. package/dist/garmin/sleep.d.ts +62 -0
  67. package/dist/garmin/sleep.d.ts.map +1 -0
  68. package/dist/garmin/sleep.js +125 -0
  69. package/dist/garmin/sleep.js.map +1 -0
  70. package/dist/garmin/sync.d.ts +39 -0
  71. package/dist/garmin/sync.d.ts.map +1 -0
  72. package/dist/garmin/sync.js +175 -0
  73. package/dist/garmin/sync.js.map +1 -0
  74. package/dist/garmin/types.d.ts +212 -0
  75. package/dist/garmin/types.d.ts.map +1 -0
  76. package/dist/garmin/types.js +8 -0
  77. package/dist/garmin/types.js.map +1 -0
  78. package/dist/validators.d.ts +331 -331
  79. package/package.json +5 -1
  80. package/src/client/index.ts +194 -1
  81. package/src/component/_generated/api.ts +2 -0
  82. package/src/component/_generated/component.ts +62 -0
  83. package/src/component/garmin.ts +534 -0
  84. package/src/component/schema.ts +3 -2
  85. package/src/component/strava.ts +23 -1
  86. package/src/garmin/activity.test.ts +178 -0
  87. package/src/garmin/activity.ts +272 -0
  88. package/src/garmin/auth.test.ts +128 -0
  89. package/src/garmin/auth.ts +249 -0
  90. package/src/garmin/body.ts +59 -0
  91. package/src/garmin/client.ts +254 -0
  92. package/src/garmin/daily.ts +211 -0
  93. package/src/garmin/index.ts +76 -0
  94. package/src/garmin/maps/activity-type.test.ts +78 -0
  95. package/src/garmin/maps/activity-type.ts +116 -0
  96. package/src/garmin/maps/sleep-level.ts +22 -0
  97. package/src/garmin/menstruation.ts +42 -0
  98. package/src/garmin/sleep.test.ts +110 -0
  99. package/src/garmin/sleep.ts +170 -0
  100. package/src/garmin/sync.ts +223 -0
  101. package/src/garmin/types.ts +338 -0
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { transformActivity } from "./activity.js";
3
+ import type { GarminActivity } from "./types.js";
4
+
5
+ const baseActivity: GarminActivity = {
6
+ userId: "garmin_user_1",
7
+ userAccessToken: "token",
8
+ summaryId: "summary_12345",
9
+ activityId: 12345,
10
+ activityName: "Morning Run",
11
+ activityType: "RUNNING",
12
+ durationInSeconds: 3600,
13
+ startTimeInSeconds: 1700000000,
14
+ startTimeOffsetInSeconds: -18000,
15
+ };
16
+
17
+ describe("transformActivity", () => {
18
+ it("maps metadata correctly", () => {
19
+ const result = transformActivity(baseActivity);
20
+
21
+ expect(result.metadata.summary_id).toBe("summary_12345");
22
+ expect(result.metadata.start_time).toBe("2023-11-14T22:13:20.000Z");
23
+ expect(result.metadata.end_time).toBe("2023-11-14T23:13:20.000Z");
24
+ expect(result.metadata.type).toBe(8); // RUNNING → Terra Running
25
+ expect(result.metadata.upload_type).toBe(1); // Automatic
26
+ expect(result.metadata.name).toBe("Morning Run");
27
+ });
28
+
29
+ it("maps manual upload type", () => {
30
+ const manual = { ...baseActivity, manual: true };
31
+ const result = transformActivity(manual);
32
+ expect(result.metadata.upload_type).toBe(2);
33
+ });
34
+
35
+ it("maps duration data", () => {
36
+ const result = transformActivity(baseActivity);
37
+ expect(result.active_durations_data.activity_seconds).toBe(3600);
38
+ });
39
+
40
+ it("maps calories data", () => {
41
+ const withCalories: GarminActivity = {
42
+ ...baseActivity,
43
+ activeKilocalories: 450,
44
+ bmrKilocalories: 75,
45
+ };
46
+ const result = transformActivity(withCalories);
47
+
48
+ expect(result.calories_data).toBeDefined();
49
+ expect(result.calories_data!.net_activity_calories).toBe(450);
50
+ expect(result.calories_data!.BMR_calories).toBe(75);
51
+ expect(result.calories_data!.total_burned_calories).toBe(525);
52
+ });
53
+
54
+ it("returns undefined calories_data when no calorie fields present", () => {
55
+ const result = transformActivity(baseActivity);
56
+ expect(result.calories_data).toBeUndefined();
57
+ });
58
+
59
+ it("maps distance data", () => {
60
+ const withDistance: GarminActivity = {
61
+ ...baseActivity,
62
+ distanceInMeters: 10000,
63
+ elevationGainInMeters: 150,
64
+ elevationLossInMeters: 120,
65
+ steps: 12000,
66
+ };
67
+ const result = transformActivity(withDistance);
68
+
69
+ expect(result.distance_data).toBeDefined();
70
+ expect(result.distance_data!.summary!.distance_meters).toBe(10000);
71
+ expect(result.distance_data!.summary!.steps).toBe(12000);
72
+ expect(result.distance_data!.summary!.elevation!.gain_actual_meters).toBe(150);
73
+ });
74
+
75
+ it("maps heart rate summary", () => {
76
+ const withHR: GarminActivity = {
77
+ ...baseActivity,
78
+ averageHeartRateInBeatsPerMinute: 155,
79
+ maxHeartRateInBeatsPerMinute: 185,
80
+ };
81
+ const result = transformActivity(withHR);
82
+
83
+ expect(result.heart_rate_data).toBeDefined();
84
+ expect(result.heart_rate_data!.summary!.avg_hr_bpm).toBe(155);
85
+ expect(result.heart_rate_data!.summary!.max_hr_bpm).toBe(185);
86
+ });
87
+
88
+ it("maps movement data with speed and cadence", () => {
89
+ const withMovement: GarminActivity = {
90
+ ...baseActivity,
91
+ averageSpeedInMetersPerSecond: 3.5,
92
+ maxSpeedInMetersPerSecond: 5.2,
93
+ averageRunCadenceInStepsPerMinute: 170,
94
+ maxRunCadenceInStepsPerMinute: 190,
95
+ };
96
+ const result = transformActivity(withMovement);
97
+
98
+ expect(result.movement_data).toBeDefined();
99
+ expect(result.movement_data!.avg_speed_meters_per_second).toBe(3.5);
100
+ expect(result.movement_data!.max_speed_meters_per_second).toBe(5.2);
101
+ expect(result.movement_data!.avg_cadence_rpm).toBe(170);
102
+ expect(result.movement_data!.max_cadence_rpm).toBe(190);
103
+ });
104
+
105
+ it("maps device data", () => {
106
+ const withDevice: GarminActivity = {
107
+ ...baseActivity,
108
+ deviceName: "Garmin Forerunner 265",
109
+ };
110
+ const result = transformActivity(withDevice);
111
+
112
+ expect(result.device_data).toBeDefined();
113
+ expect(result.device_data!.name).toBe("Garmin Forerunner 265");
114
+ });
115
+
116
+ it("maps position data", () => {
117
+ const withPosition: GarminActivity = {
118
+ ...baseActivity,
119
+ startingLatitudeInDegree: 37.7749,
120
+ startingLongitudeInDegree: -122.4194,
121
+ };
122
+ const result = transformActivity(withPosition);
123
+
124
+ expect(result.position_data).toBeDefined();
125
+ expect(result.position_data!.start_pos_lat_lng_deg).toEqual([37.7749, -122.4194]);
126
+ });
127
+
128
+ it("maps power data", () => {
129
+ const withPower: GarminActivity = {
130
+ ...baseActivity,
131
+ activityType: "CYCLING",
132
+ averagePowerInWatts: 200,
133
+ maxPowerInWatts: 450,
134
+ };
135
+ const result = transformActivity(withPower);
136
+
137
+ expect(result.power_data).toBeDefined();
138
+ expect(result.power_data!.avg_watts).toBe(200);
139
+ expect(result.power_data!.max_watts).toBe(450);
140
+ });
141
+
142
+ it("maps lap data", () => {
143
+ const withLaps: GarminActivity = {
144
+ ...baseActivity,
145
+ laps: [
146
+ {
147
+ startTimeInSeconds: 1700000000,
148
+ timerDurationInSeconds: 600,
149
+ totalDistanceInMeters: 1600,
150
+ heartRate: 150,
151
+ maxSpeed: 4.5,
152
+ },
153
+ {
154
+ startTimeInSeconds: 1700000600,
155
+ timerDurationInSeconds: 600,
156
+ totalDistanceInMeters: 1650,
157
+ heartRate: 158,
158
+ maxSpeed: 4.8,
159
+ },
160
+ ],
161
+ };
162
+ const result = transformActivity(withLaps);
163
+
164
+ expect(result.lap_data).toBeDefined();
165
+ expect(result.lap_data!.laps).toHaveLength(2);
166
+ expect(result.lap_data!.laps![0].distance_meters).toBe(1600);
167
+ expect(result.lap_data!.laps![0].avg_hr_bpm).toBe(150);
168
+ });
169
+
170
+ it("uses summaryId as summary_id and falls back to activityId", () => {
171
+ const withoutSummaryId = {
172
+ ...baseActivity,
173
+ summaryId: undefined as unknown as string,
174
+ };
175
+ const result = transformActivity(withoutSummaryId);
176
+ expect(result.metadata.summary_id).toBe("12345");
177
+ });
178
+ });
@@ -0,0 +1,272 @@
1
+ // ─── Activity Transformer ────────────────────────────────────────────────────
2
+ // Transforms a Garmin activity into the Soma Activity schema shape.
3
+
4
+ import type { GarminActivity } from "./types.js";
5
+ import { mapActivityType } from "./maps/activity-type.js";
6
+
7
+ export type ActivityData = ReturnType<typeof transformActivity>;
8
+
9
+ /**
10
+ * Transform a Garmin activity into a Soma Activity document shape.
11
+ *
12
+ * The returned object is ready to be spread into an `ingestActivity` call
13
+ * alongside `connectionId` and `userId`.
14
+ *
15
+ * @param activity - The Garmin activity from the Health API
16
+ * @returns Soma Activity fields (without connectionId/userId)
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * const data = transformActivity(garminActivity);
21
+ * await soma.ingestActivity(ctx, { connectionId, userId, ...data });
22
+ * ```
23
+ */
24
+ export function transformActivity(activity: GarminActivity) {
25
+ const startMs = activity.startTimeInSeconds * 1000;
26
+ const endMs = startMs + activity.durationInSeconds * 1000;
27
+ const startDate = new Date(startMs).toISOString();
28
+ const endDate = new Date(endMs).toISOString();
29
+
30
+ return {
31
+ metadata: {
32
+ summary_id: activity.summaryId ?? String(activity.activityId),
33
+ start_time: startDate,
34
+ end_time: endDate,
35
+ type: mapActivityType(activity.activityType),
36
+ upload_type: activity.manual ? 2 : 1,
37
+ name: activity.activityName,
38
+ },
39
+
40
+ active_durations_data: {
41
+ activity_seconds: activity.durationInSeconds,
42
+ },
43
+
44
+ calories_data: buildCaloriesData(activity),
45
+
46
+ device_data: activity.deviceName
47
+ ? { name: activity.deviceName }
48
+ : undefined,
49
+
50
+ distance_data: buildDistanceData(activity),
51
+
52
+ heart_rate_data: buildHeartRateData(activity),
53
+
54
+ movement_data: buildMovementData(activity),
55
+
56
+ position_data: buildPositionData(activity),
57
+
58
+ power_data: buildPowerData(activity),
59
+
60
+ lap_data: buildLapData(activity),
61
+ };
62
+ }
63
+
64
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
65
+
66
+ function buildCaloriesData(activity: GarminActivity) {
67
+ if (
68
+ activity.activeKilocalories == null &&
69
+ activity.bmrKilocalories == null
70
+ ) {
71
+ return undefined;
72
+ }
73
+
74
+ const total =
75
+ (activity.activeKilocalories ?? 0) + (activity.bmrKilocalories ?? 0);
76
+
77
+ return {
78
+ net_activity_calories: activity.activeKilocalories,
79
+ BMR_calories: activity.bmrKilocalories,
80
+ total_burned_calories: total || undefined,
81
+ };
82
+ }
83
+
84
+ function buildDistanceData(activity: GarminActivity) {
85
+ if (activity.distanceInMeters == null && activity.elevationGainInMeters == null) {
86
+ return undefined;
87
+ }
88
+
89
+ return {
90
+ summary: {
91
+ distance_meters: activity.distanceInMeters,
92
+ steps: activity.steps,
93
+ elevation:
94
+ activity.elevationGainInMeters != null
95
+ ? {
96
+ gain_actual_meters: activity.elevationGainInMeters,
97
+ loss_actual_meters: activity.elevationLossInMeters,
98
+ }
99
+ : undefined,
100
+ },
101
+ };
102
+ }
103
+
104
+ function buildHeartRateData(activity: GarminActivity) {
105
+ const hasHrSummary =
106
+ activity.averageHeartRateInBeatsPerMinute != null ||
107
+ activity.maxHeartRateInBeatsPerMinute != null;
108
+ const hasSamples = activity.samples && activity.samples.length > 0;
109
+
110
+ if (!hasHrSummary && !hasSamples) return undefined;
111
+
112
+ const hrSamples = hasSamples
113
+ ? activity.samples!
114
+ .filter((s) => s.heartRate != null && s.startTimeInSeconds != null)
115
+ .map((s) => ({
116
+ timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
117
+ bpm: s.heartRate!,
118
+ }))
119
+ : undefined;
120
+
121
+ return {
122
+ summary: hasHrSummary
123
+ ? {
124
+ avg_hr_bpm: activity.averageHeartRateInBeatsPerMinute,
125
+ max_hr_bpm: activity.maxHeartRateInBeatsPerMinute,
126
+ }
127
+ : undefined,
128
+ detailed:
129
+ hrSamples && hrSamples.length > 0
130
+ ? { hr_samples: hrSamples }
131
+ : undefined,
132
+ };
133
+ }
134
+
135
+ function buildMovementData(activity: GarminActivity) {
136
+ const avgCadence =
137
+ activity.averageRunCadenceInStepsPerMinute ??
138
+ activity.averageBikeCadenceInRoundsPerMinute;
139
+ const maxCadence =
140
+ activity.maxRunCadenceInStepsPerMinute ??
141
+ activity.maxBikeCadenceInRoundsPerMinute;
142
+
143
+ const hasMovement =
144
+ activity.averageSpeedInMetersPerSecond != null ||
145
+ activity.maxSpeedInMetersPerSecond != null ||
146
+ avgCadence != null;
147
+
148
+ const hasSamples = activity.samples && activity.samples.length > 0;
149
+
150
+ if (!hasMovement && !hasSamples) return undefined;
151
+
152
+ const speedSamples = hasSamples
153
+ ? activity.samples!
154
+ .filter(
155
+ (s) =>
156
+ s.speedMetersPerSecond != null && s.startTimeInSeconds != null,
157
+ )
158
+ .map((s) => ({
159
+ timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
160
+ speed_meters_per_second: s.speedMetersPerSecond!,
161
+ }))
162
+ : undefined;
163
+
164
+ const cadenceSamples = hasSamples
165
+ ? activity.samples!
166
+ .filter(
167
+ (s) =>
168
+ (s.runCadenceInStepsPerMinute != null ||
169
+ s.bikeCadenceInRPM != null) &&
170
+ s.startTimeInSeconds != null,
171
+ )
172
+ .map((s) => ({
173
+ timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
174
+ cadence_rpm:
175
+ s.runCadenceInStepsPerMinute ?? s.bikeCadenceInRPM ?? 0,
176
+ }))
177
+ : undefined;
178
+
179
+ return {
180
+ avg_speed_meters_per_second: activity.averageSpeedInMetersPerSecond,
181
+ max_speed_meters_per_second: activity.maxSpeedInMetersPerSecond,
182
+ avg_pace_minutes_per_kilometer: activity.averagePaceInMinutesPerKilometer,
183
+ max_pace_minutes_per_kilometer: activity.maxPaceInMinutesPerKilometer,
184
+ avg_cadence_rpm: avgCadence,
185
+ max_cadence_rpm: maxCadence,
186
+ speed_samples:
187
+ speedSamples && speedSamples.length > 0 ? speedSamples : undefined,
188
+ cadence_samples:
189
+ cadenceSamples && cadenceSamples.length > 0
190
+ ? cadenceSamples
191
+ : undefined,
192
+ };
193
+ }
194
+
195
+ function buildPositionData(activity: GarminActivity) {
196
+ const hasStartPos =
197
+ activity.startingLatitudeInDegree != null &&
198
+ activity.startingLongitudeInDegree != null;
199
+ const hasSamples = activity.samples && activity.samples.length > 0;
200
+
201
+ if (!hasStartPos && !hasSamples) return undefined;
202
+
203
+ const positionSamples = hasSamples
204
+ ? activity.samples!
205
+ .filter(
206
+ (s) =>
207
+ s.latitudeInDegree != null &&
208
+ s.longitudeInDegree != null &&
209
+ s.startTimeInSeconds != null,
210
+ )
211
+ .map((s) => ({
212
+ timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
213
+ coords_lat_lng_deg: [s.latitudeInDegree!, s.longitudeInDegree!],
214
+ }))
215
+ : undefined;
216
+
217
+ return {
218
+ start_pos_lat_lng_deg: hasStartPos
219
+ ? [
220
+ activity.startingLatitudeInDegree!,
221
+ activity.startingLongitudeInDegree!,
222
+ ]
223
+ : undefined,
224
+ position_samples:
225
+ positionSamples && positionSamples.length > 0
226
+ ? positionSamples
227
+ : undefined,
228
+ };
229
+ }
230
+
231
+ function buildPowerData(activity: GarminActivity) {
232
+ const hasPowerSummary =
233
+ activity.averagePowerInWatts != null ||
234
+ activity.maxPowerInWatts != null;
235
+ const hasSamples = activity.samples && activity.samples.length > 0;
236
+
237
+ if (!hasPowerSummary && !hasSamples) return undefined;
238
+
239
+ const powerSamples = hasSamples
240
+ ? activity.samples!
241
+ .filter(
242
+ (s) => s.powerInWatts != null && s.startTimeInSeconds != null,
243
+ )
244
+ .map((s) => ({
245
+ timestamp: new Date(s.startTimeInSeconds! * 1000).toISOString(),
246
+ watts: s.powerInWatts!,
247
+ }))
248
+ : undefined;
249
+
250
+ return {
251
+ avg_watts: activity.averagePowerInWatts,
252
+ max_watts: activity.maxPowerInWatts,
253
+ power_samples:
254
+ powerSamples && powerSamples.length > 0 ? powerSamples : undefined,
255
+ };
256
+ }
257
+
258
+ function buildLapData(activity: GarminActivity) {
259
+ if (!activity.laps || activity.laps.length === 0) return undefined;
260
+
261
+ return {
262
+ laps: activity.laps.map((lap) => ({
263
+ start_time: new Date(lap.startTimeInSeconds * 1000).toISOString(),
264
+ end_time: new Date(
265
+ (lap.startTimeInSeconds + lap.timerDurationInSeconds) * 1000,
266
+ ).toISOString(),
267
+ distance_meters: lap.totalDistanceInMeters,
268
+ avg_hr_bpm: lap.heartRate,
269
+ avg_speed_meters_per_second: lap.maxSpeed,
270
+ })),
271
+ };
272
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildOAuthSignature,
4
+ buildOAuthHeader,
5
+ percentEncode,
6
+ generateNonce,
7
+ getTimestamp,
8
+ } from "./auth.js";
9
+
10
+ describe("percentEncode", () => {
11
+ it("encodes special characters per RFC 3986", () => {
12
+ expect(percentEncode("hello world")).toBe("hello%20world");
13
+ expect(percentEncode("a=b&c=d")).toBe("a%3Db%26c%3Dd");
14
+ expect(percentEncode("test!")).toBe("test%21");
15
+ expect(percentEncode("100%")).toBe("100%25");
16
+ });
17
+
18
+ it("encodes characters that encodeURIComponent misses", () => {
19
+ expect(percentEncode("a'b")).toBe("a%27b");
20
+ expect(percentEncode("a(b)")).toBe("a%28b%29");
21
+ expect(percentEncode("a*b")).toBe("a%2Ab");
22
+ });
23
+
24
+ it("does not encode unreserved characters", () => {
25
+ expect(percentEncode("abcXYZ123")).toBe("abcXYZ123");
26
+ expect(percentEncode("-._~")).toBe("-._~");
27
+ });
28
+ });
29
+
30
+ describe("generateNonce", () => {
31
+ it("returns a 32-character hex string", () => {
32
+ const nonce = generateNonce();
33
+ expect(nonce).toHaveLength(32);
34
+ expect(nonce).toMatch(/^[0-9a-f]+$/);
35
+ });
36
+
37
+ it("generates unique values", () => {
38
+ const nonce1 = generateNonce();
39
+ const nonce2 = generateNonce();
40
+ expect(nonce1).not.toBe(nonce2);
41
+ });
42
+ });
43
+
44
+ describe("getTimestamp", () => {
45
+ it("returns a Unix timestamp string", () => {
46
+ const ts = getTimestamp();
47
+ const parsed = parseInt(ts, 10);
48
+ expect(parsed).toBeGreaterThan(1700000000);
49
+ expect(String(parsed)).toBe(ts);
50
+ });
51
+ });
52
+
53
+ describe("buildOAuthSignature", () => {
54
+ it("produces a valid HMAC-SHA1 signature", async () => {
55
+ const signature = await buildOAuthSignature(
56
+ "GET",
57
+ "https://api.example.com/resource",
58
+ {
59
+ oauth_consumer_key: "consumer_key",
60
+ oauth_nonce: "kllo9940pd9333jh",
61
+ oauth_signature_method: "HMAC-SHA1",
62
+ oauth_timestamp: "1191242096",
63
+ oauth_version: "1.0",
64
+ },
65
+ "consumer_secret",
66
+ "",
67
+ );
68
+
69
+ expect(signature).toBeTruthy();
70
+ expect(typeof signature).toBe("string");
71
+ expect(signature).toMatch(/^[A-Za-z0-9+/]+=*$/);
72
+ });
73
+
74
+ it("includes token secret in signing key when provided", async () => {
75
+ const sig1 = await buildOAuthSignature(
76
+ "POST",
77
+ "https://api.example.com/resource",
78
+ { oauth_consumer_key: "key", oauth_nonce: "abc", oauth_timestamp: "123" },
79
+ "consumer_secret",
80
+ "",
81
+ );
82
+
83
+ const sig2 = await buildOAuthSignature(
84
+ "POST",
85
+ "https://api.example.com/resource",
86
+ { oauth_consumer_key: "key", oauth_nonce: "abc", oauth_timestamp: "123" },
87
+ "consumer_secret",
88
+ "token_secret",
89
+ );
90
+
91
+ expect(sig1).not.toBe(sig2);
92
+ });
93
+
94
+ it("sorts parameters alphabetically", async () => {
95
+ const sig1 = await buildOAuthSignature(
96
+ "GET",
97
+ "https://api.example.com/resource",
98
+ { z_param: "last", a_param: "first", m_param: "middle" },
99
+ "secret",
100
+ );
101
+ const sig2 = await buildOAuthSignature(
102
+ "GET",
103
+ "https://api.example.com/resource",
104
+ { a_param: "first", m_param: "middle", z_param: "last" },
105
+ "secret",
106
+ );
107
+
108
+ expect(sig1).toBe(sig2);
109
+ });
110
+ });
111
+
112
+ describe("buildOAuthHeader", () => {
113
+ it("builds a valid OAuth Authorization header", () => {
114
+ const header = buildOAuthHeader({
115
+ oauth_consumer_key: "my_key",
116
+ oauth_nonce: "abc123",
117
+ oauth_signature: "sig%3D",
118
+ oauth_signature_method: "HMAC-SHA1",
119
+ oauth_timestamp: "1234567890",
120
+ oauth_version: "1.0",
121
+ });
122
+
123
+ expect(header).toMatch(/^OAuth /);
124
+ expect(header).toContain('oauth_consumer_key="my_key"');
125
+ expect(header).toContain('oauth_nonce="abc123"');
126
+ expect(header).toContain('oauth_version="1.0"');
127
+ });
128
+ });