Skip to content

Commit 45bd05e

Browse files
zwright8zachwrightxyz
authored andcommitted
MSTeams: add upload session fallback for large files
1 parent 2d67c9b commit 45bd05e

2 files changed

Lines changed: 409 additions & 61 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { MSTeamsAccessTokenProvider } from "./attachments/types.js";
3+
import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
4+
5+
const FOUR_MB = 4 * 1024 * 1024;
6+
const FIVE_MB = 5 * 1024 * 1024;
7+
8+
function jsonResponse(body: unknown, status = 200): Response {
9+
return new Response(JSON.stringify(body), {
10+
status,
11+
headers: { "Content-Type": "application/json" },
12+
});
13+
}
14+
15+
function createTokenProvider() {
16+
const getAccessToken = vi.fn().mockResolvedValue("graph-token");
17+
const tokenProvider: MSTeamsAccessTokenProvider = { getAccessToken };
18+
return {
19+
tokenProvider,
20+
getAccessToken,
21+
};
22+
}
23+
24+
describe("graph-upload", () => {
25+
it("uses simple OneDrive upload for files up to 4MB", async () => {
26+
const { tokenProvider, getAccessToken } = createTokenProvider();
27+
const fetchMock = vi.fn().mockResolvedValue(
28+
jsonResponse({
29+
id: "item-1",
30+
webUrl: "https://example.com/small",
31+
name: "small.txt",
32+
}),
33+
);
34+
35+
const result = await uploadToOneDrive({
36+
buffer: Buffer.alloc(FOUR_MB),
37+
filename: "small.txt",
38+
tokenProvider,
39+
fetchFn: fetchMock as unknown as typeof fetch,
40+
});
41+
42+
expect(result).toEqual({
43+
id: "item-1",
44+
webUrl: "https://example.com/small",
45+
name: "small.txt",
46+
});
47+
expect(fetchMock).toHaveBeenCalledTimes(1);
48+
expect(getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com");
49+
50+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
51+
expect(url).toBe(
52+
"https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/small.txt:/content",
53+
);
54+
expect(init.method).toBe("PUT");
55+
});
56+
57+
it("uses upload sessions for large OneDrive files", async () => {
58+
const { tokenProvider } = createTokenProvider();
59+
const largeBuffer = Buffer.alloc(FIVE_MB + 10, 1);
60+
const fetchMock = vi
61+
.fn()
62+
.mockResolvedValueOnce(jsonResponse({ uploadUrl: "https://upload.example/session" }))
63+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: [`${FIVE_MB}-`] }, 202))
64+
.mockResolvedValueOnce(
65+
jsonResponse(
66+
{
67+
id: "item-2",
68+
webUrl: "https://example.com/large",
69+
name: "large.bin",
70+
},
71+
201,
72+
),
73+
);
74+
75+
const result = await uploadToOneDrive({
76+
buffer: largeBuffer,
77+
filename: "large.bin",
78+
tokenProvider,
79+
fetchFn: fetchMock as unknown as typeof fetch,
80+
});
81+
82+
expect(result).toEqual({
83+
id: "item-2",
84+
webUrl: "https://example.com/large",
85+
name: "large.bin",
86+
});
87+
expect(fetchMock).toHaveBeenCalledTimes(3);
88+
89+
const [sessionUrl, sessionInit] = fetchMock.mock.calls[0] as [string, RequestInit];
90+
expect(sessionUrl).toBe(
91+
"https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/large.bin:/createUploadSession",
92+
);
93+
expect(sessionInit.method).toBe("POST");
94+
95+
const [firstChunkUrl, firstChunkInit] = fetchMock.mock.calls[1] as [string, RequestInit];
96+
const firstChunkHeaders = firstChunkInit.headers as Record<string, string>;
97+
expect(firstChunkUrl).toBe("https://upload.example/session");
98+
expect(firstChunkHeaders["Content-Range"]).toBe(`bytes 0-${FIVE_MB - 1}/${largeBuffer.length}`);
99+
expect(firstChunkHeaders["Content-Length"]).toBe(String(FIVE_MB));
100+
101+
const [secondChunkUrl, secondChunkInit] = fetchMock.mock.calls[2] as [string, RequestInit];
102+
const secondChunkHeaders = secondChunkInit.headers as Record<string, string>;
103+
expect(secondChunkUrl).toBe("https://upload.example/session");
104+
expect(secondChunkHeaders["Content-Range"]).toBe(
105+
`bytes ${FIVE_MB}-${largeBuffer.length - 1}/${largeBuffer.length}`,
106+
);
107+
expect(secondChunkHeaders["Content-Length"]).toBe("10");
108+
});
109+
110+
it("retries the same chunk when nextExpectedRanges points to byte 0", async () => {
111+
const { tokenProvider } = createTokenProvider();
112+
const largeBuffer = Buffer.alloc(FIVE_MB + 10, 1);
113+
const fetchMock = vi
114+
.fn()
115+
.mockResolvedValueOnce(jsonResponse({ uploadUrl: "https://upload.example/session" }))
116+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: ["0-"] }, 202))
117+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: [`${FIVE_MB}-`] }, 202))
118+
.mockResolvedValueOnce(
119+
jsonResponse(
120+
{
121+
id: "item-retry",
122+
webUrl: "https://example.com/retry",
123+
name: "retry.bin",
124+
},
125+
201,
126+
),
127+
);
128+
129+
const result = await uploadToOneDrive({
130+
buffer: largeBuffer,
131+
filename: "retry.bin",
132+
tokenProvider,
133+
fetchFn: fetchMock as unknown as typeof fetch,
134+
});
135+
136+
expect(result).toEqual({
137+
id: "item-retry",
138+
webUrl: "https://example.com/retry",
139+
name: "retry.bin",
140+
});
141+
expect(fetchMock).toHaveBeenCalledTimes(4);
142+
143+
const [, firstChunkInit] = fetchMock.mock.calls[1] as [string, RequestInit];
144+
const firstChunkHeaders = firstChunkInit.headers as Record<string, string>;
145+
expect(firstChunkHeaders["Content-Range"]).toBe(`bytes 0-${FIVE_MB - 1}/${largeBuffer.length}`);
146+
147+
const [, retryChunkInit] = fetchMock.mock.calls[2] as [string, RequestInit];
148+
const retryChunkHeaders = retryChunkInit.headers as Record<string, string>;
149+
expect(retryChunkHeaders["Content-Range"]).toBe(`bytes 0-${FIVE_MB - 1}/${largeBuffer.length}`);
150+
});
151+
152+
it("throws when upload session stalls on the same range", async () => {
153+
const { tokenProvider } = createTokenProvider();
154+
const fetchMock = vi
155+
.fn()
156+
.mockResolvedValueOnce(jsonResponse({ uploadUrl: "https://upload.example/session" }))
157+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: ["0-"] }, 202))
158+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: ["0-"] }, 202))
159+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: ["0-"] }, 202))
160+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: ["0-"] }, 202))
161+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: ["0-"] }, 202))
162+
.mockResolvedValueOnce(jsonResponse({ nextExpectedRanges: ["0-"] }, 202));
163+
164+
await expect(
165+
uploadToOneDrive({
166+
buffer: Buffer.alloc(FIVE_MB + 1),
167+
filename: "stalled.bin",
168+
tokenProvider,
169+
fetchFn: fetchMock as unknown as typeof fetch,
170+
}),
171+
).rejects.toThrow("OneDrive upload session stalled at byte 0");
172+
});
173+
174+
it("throws when OneDrive upload session creation fails", async () => {
175+
const { tokenProvider } = createTokenProvider();
176+
const fetchMock = vi
177+
.fn()
178+
.mockResolvedValue(
179+
new Response("session failed", { status: 500, statusText: "Internal Server Error" }),
180+
);
181+
182+
await expect(
183+
uploadToOneDrive({
184+
buffer: Buffer.alloc(FIVE_MB + 1),
185+
filename: "large.bin",
186+
tokenProvider,
187+
fetchFn: fetchMock as unknown as typeof fetch,
188+
}),
189+
).rejects.toThrow("OneDrive upload session creation failed: 500 Internal Server Error");
190+
});
191+
192+
it("uses upload sessions for large SharePoint files", async () => {
193+
const { tokenProvider } = createTokenProvider();
194+
const largeBuffer = Buffer.alloc(FIVE_MB + 1, 1);
195+
const fetchMock = vi
196+
.fn()
197+
.mockResolvedValueOnce(jsonResponse({ uploadUrl: "https://upload.example/sharepoint" }))
198+
.mockResolvedValueOnce(
199+
jsonResponse(
200+
{
201+
id: "sp-item",
202+
webUrl: "https://example.com/sharepoint",
203+
name: "report.pdf",
204+
},
205+
201,
206+
),
207+
);
208+
209+
const result = await uploadToSharePoint({
210+
buffer: largeBuffer,
211+
filename: "report.pdf",
212+
siteId: "site-123",
213+
tokenProvider,
214+
fetchFn: fetchMock as unknown as typeof fetch,
215+
});
216+
217+
expect(result).toEqual({
218+
id: "sp-item",
219+
webUrl: "https://example.com/sharepoint",
220+
name: "report.pdf",
221+
});
222+
expect(fetchMock).toHaveBeenCalledTimes(2);
223+
224+
const [sessionUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
225+
expect(sessionUrl).toBe(
226+
"https://graph.microsoft.com/v1.0/sites/site-123/drive/root:/OpenClawShared/report.pdf:/createUploadSession",
227+
);
228+
});
229+
});

0 commit comments

Comments
 (0)