switchroom 0.12.29 → 0.13.1

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.
@@ -749,4 +749,536 @@ describe('createDraftStream — draft transport', () => {
749
749
  expect(callCount).toBeGreaterThan(1) // draft update + failed clear attempt
750
750
  })
751
751
 
752
+ // ─── PR A observability: gw-trace stream-start / stream-end ───────────
753
+ describe('gw-trace stream-start / stream-end', () => {
754
+ let captured: string[] = []
755
+ let originalWrite: typeof process.stderr.write
756
+
757
+ beforeEach(() => {
758
+ captured = []
759
+ originalWrite = process.stderr.write
760
+ process.stderr.write = ((chunk: string | Uint8Array) => {
761
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
762
+ captured.push(text)
763
+ return true
764
+ }) as typeof process.stderr.write
765
+ })
766
+ afterEach(() => {
767
+ process.stderr.write = originalWrite
768
+ })
769
+
770
+ it('emits stream-start with resolved transport=draft on dm + draftApi', async () => {
771
+ const m = makeMock()
772
+ const draftApi = vi.fn(async () => ({ ok: true }))
773
+ const stream = createDraftStream(m.send, m.edit, {
774
+ throttleMs: 1000,
775
+ previewTransport: 'draft',
776
+ sendMessageDraft: draftApi,
777
+ chatId: 'chat-X',
778
+ })
779
+ const trace = captured.find((c) => c.includes('gw-trace stream-start'))
780
+ expect(trace).toBeDefined()
781
+ expect(trace).toContain('transport=draft')
782
+ expect(trace).toContain('reason=draft')
783
+ expect(trace).toContain('api=available')
784
+ expect(trace).toContain('chatId=chat-X')
785
+ await stream.finalize()
786
+ })
787
+
788
+ it('emits stream-start with reason=auto-non-dm when isPrivateChat is false', async () => {
789
+ const m = makeMock()
790
+ const draftApi = vi.fn(async () => ({ ok: true }))
791
+ createDraftStream(m.send, m.edit, {
792
+ throttleMs: 1000,
793
+ previewTransport: 'auto',
794
+ isPrivateChat: false,
795
+ sendMessageDraft: draftApi,
796
+ chatId: 'chat-Y',
797
+ })
798
+ const trace = captured.find((c) => c.includes('gw-trace stream-start'))
799
+ expect(trace).toBeDefined()
800
+ expect(trace).toContain('transport=message')
801
+ expect(trace).toContain('reason=auto-non-dm')
802
+ })
803
+
804
+ it('emits stream-end with fire counts on finalize', async () => {
805
+ const m = makeMock()
806
+ const stream = createDraftStream(m.send, m.edit, {
807
+ throttleMs: 50,
808
+ previewTransport: 'message',
809
+ })
810
+ void stream.update('first')
811
+ await microtaskFlush()
812
+ await stream.finalize()
813
+ const trace = captured.find((c) => c.includes('gw-trace stream-end'))
814
+ expect(trace).toBeDefined()
815
+ expect(trace).toContain('transport=message')
816
+ expect(trace).toMatch(/sends=[1-9]/)
817
+ expect(trace).toContain('firstFireMs=')
818
+ expect(trace).toContain('durationMs=')
819
+ })
820
+
821
+ it('SWITCHROOM_STREAM_TRACES=0 suppresses both traces', async () => {
822
+ const prev = process.env.SWITCHROOM_STREAM_TRACES
823
+ process.env.SWITCHROOM_STREAM_TRACES = '0'
824
+ try {
825
+ const m = makeMock()
826
+ const stream = createDraftStream(m.send, m.edit, {
827
+ throttleMs: 50,
828
+ previewTransport: 'message',
829
+ })
830
+ await stream.finalize()
831
+ expect(captured.find((c) => c.includes('gw-trace stream-'))).toBeUndefined()
832
+ } finally {
833
+ if (prev === undefined) delete process.env.SWITCHROOM_STREAM_TRACES
834
+ else process.env.SWITCHROOM_STREAM_TRACES = prev
835
+ }
836
+ })
837
+ })
838
+
839
+ // ─── PR B: transport-aware throttle defaults ──────────────────────────
840
+ describe('transport-aware throttle defaults (PR B)', () => {
841
+ let captured: string[] = []
842
+ let originalWrite: typeof process.stderr.write
843
+
844
+ beforeEach(() => {
845
+ captured = []
846
+ originalWrite = process.stderr.write
847
+ process.stderr.write = ((chunk: string | Uint8Array) => {
848
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
849
+ captured.push(text)
850
+ return true
851
+ }) as typeof process.stderr.write
852
+ })
853
+ afterEach(() => {
854
+ process.stderr.write = originalWrite
855
+ })
856
+
857
+ it('draft transport defaults to 300ms throttle (sub-second)', () => {
858
+ const m = makeMock()
859
+ const draftApi = vi.fn(async () => ({ ok: true }))
860
+ createDraftStream(m.send, m.edit, {
861
+ previewTransport: 'draft',
862
+ sendMessageDraft: draftApi,
863
+ chatId: 'c1',
864
+ })
865
+ const trace = captured.find((c) => c.includes('gw-trace stream-start'))
866
+ expect(trace).toBeDefined()
867
+ expect(trace).toContain('throttleMs=300')
868
+ })
869
+
870
+ it('message transport defaults to 1000ms throttle', () => {
871
+ const m = makeMock()
872
+ createDraftStream(m.send, m.edit, {
873
+ previewTransport: 'message',
874
+ })
875
+ const trace = captured.find((c) => c.includes('gw-trace stream-start'))
876
+ expect(trace).toBeDefined()
877
+ expect(trace).toContain('throttleMs=1000')
878
+ })
879
+
880
+ it('auto + DM + draftApi resolves to draft default (300ms)', () => {
881
+ const m = makeMock()
882
+ const draftApi = vi.fn(async () => ({ ok: true }))
883
+ createDraftStream(m.send, m.edit, {
884
+ previewTransport: 'auto',
885
+ isPrivateChat: true,
886
+ sendMessageDraft: draftApi,
887
+ chatId: 'c1',
888
+ })
889
+ const trace = captured.find((c) => c.includes('gw-trace stream-start'))
890
+ expect(trace).toContain('throttleMs=300')
891
+ })
892
+
893
+ it('auto + non-DM resolves to message default (1000ms)', () => {
894
+ const m = makeMock()
895
+ const draftApi = vi.fn(async () => ({ ok: true }))
896
+ createDraftStream(m.send, m.edit, {
897
+ previewTransport: 'auto',
898
+ isPrivateChat: false,
899
+ sendMessageDraft: draftApi,
900
+ chatId: 'c1',
901
+ })
902
+ const trace = captured.find((c) => c.includes('gw-trace stream-start'))
903
+ expect(trace).toContain('throttleMs=1000')
904
+ })
905
+
906
+ it('explicit config.throttleMs wins over transport default', () => {
907
+ const m = makeMock()
908
+ const draftApi = vi.fn(async () => ({ ok: true }))
909
+ createDraftStream(m.send, m.edit, {
910
+ previewTransport: 'draft',
911
+ sendMessageDraft: draftApi,
912
+ chatId: 'c1',
913
+ throttleMs: 500,
914
+ })
915
+ const trace = captured.find((c) => c.includes('gw-trace stream-start'))
916
+ expect(trace).toContain('throttleMs=500')
917
+ })
918
+
919
+ it('explicit override below MIN_THROTTLE_MS is floored at 250', () => {
920
+ const m = makeMock()
921
+ createDraftStream(m.send, m.edit, {
922
+ previewTransport: 'message',
923
+ throttleMs: 100,
924
+ })
925
+ const trace = captured.find((c) => c.includes('gw-trace stream-start'))
926
+ expect(trace).toContain('throttleMs=250')
927
+ })
928
+ })
929
+
930
+ // ─── PR C: persist-then-continue chain at 25s / 4000-char boundary ───
931
+ describe('persist-chain (PR C)', () => {
932
+ let captured: string[] = []
933
+ let originalWrite: typeof process.stderr.write
934
+
935
+ beforeEach(() => {
936
+ // Outer describe sets useFakeTimers; keep that and drive time with
937
+ // advanceTimersByTimeAsync. The size-trigger doesn't need time at
938
+ // all (it fires on tail length); the time-trigger tests advance.
939
+ captured = []
940
+ originalWrite = process.stderr.write
941
+ process.stderr.write = ((chunk: string | Uint8Array) => {
942
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
943
+ captured.push(text)
944
+ return true
945
+ }) as typeof process.stderr.write
946
+ })
947
+ afterEach(() => {
948
+ process.stderr.write = originalWrite
949
+ })
950
+
951
+ it('size-trigger: persist fires when tail crosses persistSizeLimit', async () => {
952
+ const m = makeMock()
953
+ const sendMessageDraft = vi.fn(async () => {})
954
+ const stream = createDraftStream(m.send, m.edit, {
955
+ throttleMs: 50,
956
+ previewTransport: 'draft',
957
+ sendMessageDraft,
958
+ chatId: 'chat-size',
959
+ persistSizeLimit: 200, // small for tests
960
+ })
961
+
962
+ // First update: 100 chars, below threshold. Drafts.
963
+ void stream.update('a'.repeat(100))
964
+ await microtaskFlush(20)
965
+ vi.advanceTimersByTime(300); await microtaskFlush(20)
966
+ // Second update: 250 chars total, tail exceeds 200 → size persist.
967
+ void stream.update('a'.repeat(250))
968
+ await microtaskFlush(20)
969
+ vi.advanceTimersByTime(300); await microtaskFlush(20)
970
+ await microtaskFlush(20)
971
+ await stream.finalize()
972
+
973
+ const persistLine = captured.find((c) => c.includes('gw-trace stream-persist'))
974
+ expect(persistLine).toBeDefined()
975
+ expect(persistLine).toContain('reason=size')
976
+ expect(m.sendCalls.length).toBeGreaterThan(0)
977
+ })
978
+
979
+ it('time-trigger: persist fires after persistIntervalMs elapsed', async () => {
980
+ const m = makeMock()
981
+ const sendMessageDraft = vi.fn(async () => {})
982
+ const stream = createDraftStream(m.send, m.edit, {
983
+ throttleMs: 50,
984
+ previewTransport: 'draft',
985
+ sendMessageDraft,
986
+ chatId: 'chat-time',
987
+ persistIntervalMs: 200, // small for tests
988
+ })
989
+
990
+ void stream.update('first chunk text')
991
+ await microtaskFlush(20)
992
+ // Advance past the time trigger.
993
+ vi.advanceTimersByTime(250); await microtaskFlush(20)
994
+ void stream.update('first chunk text continues')
995
+ await microtaskFlush(20)
996
+ vi.advanceTimersByTime(300); await microtaskFlush(20)
997
+ await microtaskFlush(20)
998
+ await stream.finalize()
999
+
1000
+ const persistLine = captured.find((c) => c.includes('gw-trace stream-persist'))
1001
+ expect(persistLine).toBeDefined()
1002
+ expect(persistLine).toContain('reason=time')
1003
+ })
1004
+
1005
+ it('persist bumps persists counter in stream-end trace', async () => {
1006
+ const m = makeMock()
1007
+ const sendMessageDraft = vi.fn(async () => {})
1008
+ const stream = createDraftStream(m.send, m.edit, {
1009
+ throttleMs: 50,
1010
+ previewTransport: 'draft',
1011
+ sendMessageDraft,
1012
+ chatId: 'chat-2',
1013
+ persistSizeLimit: 100,
1014
+ })
1015
+
1016
+ void stream.update('x'.repeat(50))
1017
+ await microtaskFlush(20)
1018
+ vi.advanceTimersByTime(300); await microtaskFlush(20)
1019
+ void stream.update('x'.repeat(150))
1020
+ await microtaskFlush(20)
1021
+ vi.advanceTimersByTime(300); await microtaskFlush(20)
1022
+ await microtaskFlush(20)
1023
+ await stream.finalize()
1024
+
1025
+ const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
1026
+ expect(endLine).toBeDefined()
1027
+ expect(endLine).toMatch(/persists=[1-9]/)
1028
+ })
1029
+
1030
+ it('finalize only materializes the unpersisted tail (no duplicate)', async () => {
1031
+ const m = makeMock()
1032
+ const sendMessageDraft = vi.fn(async () => {})
1033
+ const stream = createDraftStream(m.send, m.edit, {
1034
+ throttleMs: 50,
1035
+ previewTransport: 'draft',
1036
+ sendMessageDraft,
1037
+ chatId: 'chat-3',
1038
+ persistSizeLimit: 200,
1039
+ })
1040
+
1041
+ void stream.update('a'.repeat(100))
1042
+ await microtaskFlush(20)
1043
+ vi.advanceTimersByTime(300); await microtaskFlush(20)
1044
+ void stream.update('a'.repeat(250)) // size trigger fires
1045
+ await microtaskFlush(20)
1046
+ vi.advanceTimersByTime(300); await microtaskFlush(20)
1047
+ await microtaskFlush(20)
1048
+ const persistsBefore = m.sendCalls.length
1049
+ expect(persistsBefore).toBeGreaterThan(0)
1050
+
1051
+ // Now add a small tail and finalize. The tail-only send should
1052
+ // be much smaller than 250 — only post-persist content.
1053
+ void stream.update('a'.repeat(250) + 'tail')
1054
+ await microtaskFlush(20)
1055
+ vi.advanceTimersByTime(300); await microtaskFlush(20)
1056
+ await stream.finalize()
1057
+
1058
+ const lastSend = m.sendCalls[m.sendCalls.length - 1]
1059
+ expect(lastSend).toBeDefined()
1060
+ // Without PR C, finalize would re-send the entire 250+ chars.
1061
+ expect(lastSend!.text.length).toBeLessThan(50)
1062
+ })
1063
+
1064
+ it('message-transport stream has persists=0 (no chunking)', async () => {
1065
+ const m = makeMock()
1066
+ const stream = createDraftStream(m.send, m.edit, {
1067
+ throttleMs: 50,
1068
+ previewTransport: 'message',
1069
+ })
1070
+ void stream.update('z'.repeat(3000))
1071
+ await microtaskFlush(20)
1072
+ await stream.finalize()
1073
+ const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
1074
+ expect(endLine).toContain('persists=0')
1075
+ })
1076
+ })
1077
+
1078
+ // ─── PR D: 429 + non-true return fallback robustness ──────────────────
1079
+ describe('429 + non-true fallback (PR D)', () => {
1080
+ it('429 on draft falls back to message transport (no further draft fires)', async () => {
1081
+ const m = makeMock()
1082
+ let draftCalls = 0
1083
+ const sendMessageDraft = vi.fn(async () => {
1084
+ draftCalls++
1085
+ const err = new Error('sendMessageDraft: Too Many Requests: retry after 3') as Error & {
1086
+ error_code?: number
1087
+ method?: string
1088
+ parameters?: { retry_after?: number }
1089
+ }
1090
+ err.error_code = 429
1091
+ err.method = 'sendMessageDraft'
1092
+ err.parameters = { retry_after: 3 }
1093
+ throw err
1094
+ })
1095
+ const stream = createDraftStream(m.send, m.edit, {
1096
+ throttleMs: 50,
1097
+ previewTransport: 'draft',
1098
+ sendMessageDraft,
1099
+ chatId: 'chat-429',
1100
+ })
1101
+
1102
+ void stream.update('hello')
1103
+ await microtaskFlush(20)
1104
+ expect(draftCalls).toBe(1)
1105
+ await microtaskFlush(20)
1106
+ vi.advanceTimersByTime(3500)
1107
+ await microtaskFlush(20)
1108
+ await stream.finalize()
1109
+ expect(m.sendCalls.length).toBeGreaterThanOrEqual(1)
1110
+ // sendMessageDraft was NOT called again after the 429.
1111
+ expect(draftCalls).toBe(1)
1112
+ })
1113
+
1114
+ it('non-true (false) return from sendMessageDraft triggers fallback', async () => {
1115
+ const m = makeMock()
1116
+ let draftCalls = 0
1117
+ const sendMessageDraft = vi.fn(async () => {
1118
+ draftCalls++
1119
+ return false
1120
+ })
1121
+ const stream = createDraftStream(m.send, m.edit, {
1122
+ throttleMs: 50,
1123
+ previewTransport: 'draft',
1124
+ sendMessageDraft,
1125
+ chatId: 'chat-nonbool',
1126
+ })
1127
+ void stream.update('content')
1128
+ await microtaskFlush(20)
1129
+ expect(draftCalls).toBe(1)
1130
+ await microtaskFlush(20)
1131
+ vi.advanceTimersByTime(100)
1132
+ await microtaskFlush(20)
1133
+ await stream.finalize()
1134
+ // Subsequent updates / finalize do NOT re-invoke sendMessageDraft.
1135
+ expect(draftCalls).toBe(1)
1136
+ expect(m.sendCalls.length).toBeGreaterThanOrEqual(1)
1137
+ })
1138
+
1139
+ it('undefined return is treated as success (does not trigger fallback)', async () => {
1140
+ // grammY's typed wrapper sometimes returns void/undefined on
1141
+ // success. We must not false-positive fallback on those.
1142
+ const m = makeMock()
1143
+ let draftCalls = 0
1144
+ const sendMessageDraft = vi.fn(async () => {
1145
+ draftCalls++
1146
+ return undefined
1147
+ })
1148
+ const stream = createDraftStream(m.send, m.edit, {
1149
+ throttleMs: 50,
1150
+ previewTransport: 'draft',
1151
+ sendMessageDraft,
1152
+ chatId: 'chat-undef',
1153
+ })
1154
+ void stream.update('first')
1155
+ await microtaskFlush(20)
1156
+ vi.advanceTimersByTime(100)
1157
+ void stream.update('second')
1158
+ await microtaskFlush(20)
1159
+ vi.advanceTimersByTime(100)
1160
+ await microtaskFlush(20)
1161
+ await stream.finalize()
1162
+ expect(draftCalls).toBeGreaterThan(1)
1163
+ })
1164
+
1165
+ it('429 path increments fallbacks counter (visible in stream-end trace)', async () => {
1166
+ const captured: string[] = []
1167
+ const originalWrite = process.stderr.write
1168
+ process.stderr.write = ((chunk: string | Uint8Array) => {
1169
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
1170
+ captured.push(text)
1171
+ return true
1172
+ }) as typeof process.stderr.write
1173
+ try {
1174
+ const m = makeMock()
1175
+ const sendMessageDraft = vi.fn(async () => {
1176
+ const err = new Error('429') as Error & {
1177
+ error_code?: number
1178
+ method?: string
1179
+ parameters?: { retry_after?: number }
1180
+ }
1181
+ err.error_code = 429
1182
+ err.method = 'sendMessageDraft'
1183
+ err.parameters = { retry_after: 2 }
1184
+ throw err
1185
+ })
1186
+ const stream = createDraftStream(m.send, m.edit, {
1187
+ throttleMs: 50,
1188
+ previewTransport: 'draft',
1189
+ sendMessageDraft,
1190
+ chatId: 'chat-429-trace',
1191
+ })
1192
+ void stream.update('text')
1193
+ await microtaskFlush(20)
1194
+ vi.advanceTimersByTime(3000)
1195
+ await stream.finalize()
1196
+ const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
1197
+ expect(endLine).toBeDefined()
1198
+ expect(endLine).toMatch(/fallbacks=[1-9]/)
1199
+ } finally {
1200
+ process.stderr.write = originalWrite
1201
+ }
1202
+ })
1203
+ })
1204
+
1205
+ // ─── Follow-up: stream-end `sends` counter includes finalize-materialize ───
1206
+ describe('finalize-materialize bumps sends counter', () => {
1207
+ let captured: string[] = []
1208
+ let originalWrite: typeof process.stderr.write
1209
+
1210
+ beforeEach(() => {
1211
+ captured = []
1212
+ originalWrite = process.stderr.write
1213
+ process.stderr.write = ((chunk: string | Uint8Array) => {
1214
+ const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
1215
+ captured.push(text)
1216
+ return true
1217
+ }) as typeof process.stderr.write
1218
+ })
1219
+ afterEach(() => {
1220
+ process.stderr.write = originalWrite
1221
+ })
1222
+
1223
+ it('draft-transport stream that materializes on finalize shows sends>=1', async () => {
1224
+ // Pre-fix this showed sends=0 even though sendMessage fired
1225
+ // inside finalize. Bug was visible in production v0.13.0 traces.
1226
+ const m = makeMock()
1227
+ const sendMessageDraft = vi.fn(async () => {})
1228
+ const stream = createDraftStream(m.send, m.edit, {
1229
+ throttleMs: 50,
1230
+ previewTransport: 'draft',
1231
+ sendMessageDraft,
1232
+ chatId: 'chat-x',
1233
+ })
1234
+ void stream.update('Hello world')
1235
+ await microtaskFlush()
1236
+ vi.advanceTimersByTime(100)
1237
+ await microtaskFlush()
1238
+ await stream.finalize()
1239
+ // Real send() called inside finalize.
1240
+ expect(m.sendCalls.length).toBe(1)
1241
+ const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
1242
+ expect(endLine).toBeDefined()
1243
+ // Counter now reflects reality.
1244
+ expect(endLine).toMatch(/sends=[1-9]/)
1245
+ })
1246
+
1247
+ it('persist-chain bump counts toward sends (size-trigger fires + finalize)', async () => {
1248
+ // The sibling bug at the persist-chain callsite — its bare
1249
+ // send(chunk) also bypasses sendViaMessage. Without the fix
1250
+ // a stream that crosses the size boundary would show sends=1
1251
+ // (only the finalize materialize), missing the chain fire.
1252
+ // With the fix sends counts BOTH the persist send AND the
1253
+ // finalize materialize → sends>=2.
1254
+ const m = makeMock()
1255
+ const sendMessageDraft = vi.fn(async () => {})
1256
+ const stream = createDraftStream(m.send, m.edit, {
1257
+ throttleMs: 50,
1258
+ previewTransport: 'draft',
1259
+ sendMessageDraft,
1260
+ chatId: 'chat-chain',
1261
+ persistSizeLimit: 200,
1262
+ })
1263
+ void stream.update('a'.repeat(100))
1264
+ await microtaskFlush()
1265
+ vi.advanceTimersByTime(300)
1266
+ void stream.update('a'.repeat(250)) // size trigger fires (tail=250 ≥ 200)
1267
+ await microtaskFlush()
1268
+ vi.advanceTimersByTime(300)
1269
+ await microtaskFlush()
1270
+ // Extra text after persist so finalize-materialize tail is non-empty.
1271
+ void stream.update('a'.repeat(250) + 'b'.repeat(50))
1272
+ await microtaskFlush()
1273
+ vi.advanceTimersByTime(300)
1274
+ await microtaskFlush()
1275
+ await stream.finalize()
1276
+ const endLine = captured.find((c) => c.includes('gw-trace stream-end'))
1277
+ expect(endLine).toBeDefined()
1278
+ // Persist send + finalize materialize = at least 2.
1279
+ expect(endLine).toMatch(/sends=[2-9]/)
1280
+ expect(endLine).toMatch(/persists=[1-9]/)
1281
+ })
1282
+ })
1283
+
752
1284
  })
@@ -9,6 +9,8 @@ import {
9
9
  shouldFallbackFromDraftTransport,
10
10
  allocateDraftId,
11
11
  __resetDraftIdForTests,
12
+ extractDraft429RetryAfterSecs,
13
+ isDraft429,
12
14
  } from '../draft-transport.js'
13
15
 
14
16
  describe('DRAFT_METHOD_UNAVAILABLE_RE', () => {
@@ -139,3 +141,71 @@ describe('allocateDraftId', () => {
139
141
  expect(allocateDraftId()).toBe(1)
140
142
  })
141
143
  })
144
+
145
+ // ─── PR D: 429 + non-True helpers ──────────────────────────────────────
146
+
147
+ describe('extractDraft429RetryAfterSecs (PR D)', () => {
148
+ it('returns retry_after on a grammY 429', () => {
149
+ const err = { error_code: 429, parameters: { retry_after: 7 } }
150
+ expect(extractDraft429RetryAfterSecs(err)).toBe(7)
151
+ })
152
+
153
+ it('returns null on non-429 error_code', () => {
154
+ const err = { error_code: 400, parameters: { retry_after: 7 } }
155
+ expect(extractDraft429RetryAfterSecs(err)).toBeNull()
156
+ })
157
+
158
+ it('returns null when retry_after is missing', () => {
159
+ const err = { error_code: 429 }
160
+ expect(extractDraft429RetryAfterSecs(err)).toBeNull()
161
+ })
162
+
163
+ it('returns null when retry_after is non-positive', () => {
164
+ expect(extractDraft429RetryAfterSecs({ error_code: 429, parameters: { retry_after: 0 } })).toBeNull()
165
+ expect(extractDraft429RetryAfterSecs({ error_code: 429, parameters: { retry_after: -1 } })).toBeNull()
166
+ })
167
+
168
+ it('returns null on primitive errors', () => {
169
+ expect(extractDraft429RetryAfterSecs(null)).toBeNull()
170
+ expect(extractDraft429RetryAfterSecs(undefined)).toBeNull()
171
+ expect(extractDraft429RetryAfterSecs('boom')).toBeNull()
172
+ expect(extractDraft429RetryAfterSecs(42)).toBeNull()
173
+ })
174
+ })
175
+
176
+ describe('isDraft429 (PR D)', () => {
177
+ it('returns true when 429 carries method=sendMessageDraft', () => {
178
+ const err = {
179
+ error_code: 429,
180
+ parameters: { retry_after: 5 },
181
+ method: 'sendMessageDraft',
182
+ }
183
+ expect(isDraft429(err)).toBe(true)
184
+ })
185
+
186
+ it('returns true when 429 description mentions sendMessageDraft', () => {
187
+ const err = {
188
+ error_code: 429,
189
+ parameters: { retry_after: 5 },
190
+ description: 'sendMessageDraft: Too Many Requests: retry after 5',
191
+ }
192
+ expect(isDraft429(err)).toBe(true)
193
+ })
194
+
195
+ it('returns false on 429 from a different method', () => {
196
+ const err = {
197
+ error_code: 429,
198
+ parameters: { retry_after: 5 },
199
+ method: 'sendMessage',
200
+ }
201
+ expect(isDraft429(err)).toBe(false)
202
+ })
203
+
204
+ it('returns false on non-429 errors even when mentioning sendMessageDraft', () => {
205
+ const err = {
206
+ error_code: 400,
207
+ description: 'sendMessageDraft: bad request',
208
+ }
209
+ expect(isDraft429(err)).toBe(false)
210
+ })
211
+ })