mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
test(webchat): HTTP e2e coverage for attachment upload/stream/download
Adds WebChatAttachmentE2ETest — second HTTP-level test in the webchat suite. Boots RANDOM_PORT, drives Spring's multipart parser for real, and verifies the cross-endpoint wiring that turns an upload into an agent-addressable attachment. Coverage (7 tests): - upload + /stream round-trip: persisted user message's content_parts carries the file part with a server path that points into the conversation's upload dir; bytes on disk match what was uploaded - unknown attachmentId → silently dropped (no error, text-only parts) - foreign visitor cannot reference another visitor's fileId (consume() is conversation-scoped) - upload without visitorToken → HTTP 401 - upload with disallowed extension → HTTP 400 - GET /files streams back the uploaded bytes - GET /files without visitorToken → HTTP 401 AgentService is @MockBean'd so /stream returns instantly; what we assert is the persisted user-message shape (DB row content_parts), not the agent's actual file consumption (which would need a real agent + tool runtime — out of scope for the wire-format focus). Worth noting: RHttpStatusAdvice maps R.fail(401/400) to the matching HTTP status, so 4xx assertions are on the HTTP status, not the body. Stack: feat/webchat-stream-e2e-test → feat/webchat-attachment-e2e Follow-up to PR #363.
This commit is contained in:
parent
59713a7215
commit
a0598fb0b8
@ -0,0 +1,418 @@
|
|||||||
|
package vip.mate.channel.webchat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import vip.mate.MateClawApplication;
|
||||||
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end HTTP coverage of the WebChat attachment flow (epic #355 follow-up):
|
||||||
|
* <ol>
|
||||||
|
* <li>{@code POST /upload} stores bytes under the server-derived conversation dir
|
||||||
|
* and returns an opaque {@code fileId}; the bytes are addressable by the
|
||||||
|
* agent through the conversation's upload path;</li>
|
||||||
|
* <li>{@code POST /stream} with {@code attachmentIds=[fileId]} resolves that id
|
||||||
|
* back to a server path and persists a user message whose {@code content_parts}
|
||||||
|
* carry the path so the agent's file tools can read it on the next turn.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>Boots a real servlet container (RANDOM_PORT), drives the actual multipart
|
||||||
|
* parser, and asserts on the persisted {@code mate_message.content_parts} JSON —
|
||||||
|
* the in-memory {@link WebChatFileServiceTest} already covers the service's
|
||||||
|
* validation, so this layer's job is the cross-endpoint wiring + auth contract.
|
||||||
|
*
|
||||||
|
* <p>{@link AgentService} is mocked (same pattern as {@link WebChatStreamE2ETest})
|
||||||
|
* so /stream returns immediately without invoking a real agent — what we are
|
||||||
|
* asserting is the persisted user-message shape, not the agent's actual file
|
||||||
|
* consumption (which would need a real agent + tool runtime).
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@SpringBootTest(
|
||||||
|
classes = MateClawApplication.class,
|
||||||
|
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
|
||||||
|
)
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"spring.datasource.url=jdbc:h2:mem:webchat_att_e2e_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
|
||||||
|
"spring.ai.dashscope.api-key=test-key",
|
||||||
|
"mateclaw.jwt.secret=webchat-it-secret-0123456789",
|
||||||
|
"mateclaw.feature-flag.refresh-ms=999999"
|
||||||
|
})
|
||||||
|
class WebChatAttachmentE2ETest {
|
||||||
|
|
||||||
|
private static final String SECRET = "webchat-it-secret-0123456789";
|
||||||
|
private static final String API_KEY = "testkey1atte2e01"; // key8 = "testkey1"
|
||||||
|
private static final long CHANNEL_ID = 9_148_101L;
|
||||||
|
private static final long AGENT_ID = 9_148_1011L;
|
||||||
|
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(15);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe the upload dir for our test conversation ids. The path is deterministic
|
||||||
|
* from the API key prefix, so we can clean it precisely instead of nuking
|
||||||
|
* the whole {@code data/chat-uploads} tree.
|
||||||
|
*/
|
||||||
|
private static final Path UPLOAD_ROOT = Paths.get("data", "chat-uploads");
|
||||||
|
|
||||||
|
@LocalServerPort private int port;
|
||||||
|
@Autowired private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
@MockBean private AgentService agentService;
|
||||||
|
|
||||||
|
private HttpClient http;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||||
|
|
||||||
|
jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID);
|
||||||
|
jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID);
|
||||||
|
jdbc.update(
|
||||||
|
"MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " +
|
||||||
|
"workspace_id, create_time, update_time, deleted) " +
|
||||||
|
"KEY(id) VALUES (?, 'wc-att-agent', 'react', '', 10, TRUE, 1, " +
|
||||||
|
"CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
|
||||||
|
AGENT_ID);
|
||||||
|
jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " +
|
||||||
|
"workspace_id, create_time, update_time, deleted) " +
|
||||||
|
"VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
|
||||||
|
CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}");
|
||||||
|
|
||||||
|
// chatStructuredStream: instant completion so /stream returns fast.
|
||||||
|
AgentEntity agent = new AgentEntity();
|
||||||
|
agent.setId(AGENT_ID);
|
||||||
|
agent.setWorkspaceId(1L);
|
||||||
|
org.mockito.Mockito.when(agentService.getAgent(AGENT_ID)).thenReturn(agent);
|
||||||
|
org.mockito.Mockito.when(agentService.chatStructuredStream(
|
||||||
|
eq(AGENT_ID), anyString(), anyString(), anyString(), isNull(), any()))
|
||||||
|
.thenReturn(Flux.just(new AgentService.StreamDelta("ack", null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== multipart helpers ====================
|
||||||
|
|
||||||
|
/** Build a multipart/form-data body with one file part plus arbitrary form fields. */
|
||||||
|
private static byte[] multipart(String boundary, Map<String, String> fields,
|
||||||
|
String fileField, String fileName, String contentType, byte[] fileBytes) {
|
||||||
|
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||||
|
String crlf = "\r\n";
|
||||||
|
String dash = "--";
|
||||||
|
try {
|
||||||
|
for (var e : fields.entrySet()) {
|
||||||
|
write(bos, dash + boundary + crlf);
|
||||||
|
write(bos, "Content-Disposition: form-data; name=\"" + e.getKey() + "\"" + crlf);
|
||||||
|
write(bos, crlf);
|
||||||
|
write(bos, e.getValue() + crlf);
|
||||||
|
}
|
||||||
|
write(bos, dash + boundary + crlf);
|
||||||
|
write(bos, "Content-Disposition: form-data; name=\"" + fileField + "\"; filename=\""
|
||||||
|
+ fileName + "\"" + crlf);
|
||||||
|
write(bos, "Content-Type: " + contentType + crlf);
|
||||||
|
write(bos, crlf);
|
||||||
|
bos.write(fileBytes);
|
||||||
|
write(bos, crlf);
|
||||||
|
write(bos, dash + boundary + dash + crlf);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
return bos.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void write(ByteArrayOutputStream bos, String s) throws IOException {
|
||||||
|
bos.write(s.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== HTTP helpers ====================
|
||||||
|
|
||||||
|
private URI uploadUri() {
|
||||||
|
return URI.create("http://localhost:" + port + "/api/v1/channels/webchat/upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI streamUri() {
|
||||||
|
return URI.create("http://localhost:" + port + "/api/v1/channels/webchat/stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
private URI filesUri(String storedName, String visitorId, String sessionId) {
|
||||||
|
StringBuilder sb = new StringBuilder("http://localhost:" + port + "/api/v1/channels/webchat/files");
|
||||||
|
sb.append("?storedName=").append(java.net.URLEncoder.encode(storedName, StandardCharsets.UTF_8));
|
||||||
|
sb.append("&visitorId=").append(java.net.URLEncoder.encode(visitorId, StandardCharsets.UTF_8));
|
||||||
|
if (sessionId != null) {
|
||||||
|
sb.append("&sessionId=").append(java.net.URLEncoder.encode(sessionId, StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
return URI.create(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String tokenFor(String visitorId) {
|
||||||
|
return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload, return the parsed fileId (or fail loudly if the response isn't 200). */
|
||||||
|
private String upload(String visitorId, String sessionId, String fileName,
|
||||||
|
String contentType, byte[] bytes) throws Exception {
|
||||||
|
String boundary = "----mcboundary" + System.nanoTime();
|
||||||
|
String token = tokenFor(visitorId);
|
||||||
|
Map<String, String> fields = new java.util.LinkedHashMap<>();
|
||||||
|
fields.put("visitorId", visitorId);
|
||||||
|
if (sessionId != null) fields.put("sessionId", sessionId);
|
||||||
|
byte[] body = multipart(boundary, fields, "file", fileName, contentType, bytes);
|
||||||
|
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(uploadUri())
|
||||||
|
.timeout(HTTP_TIMEOUT)
|
||||||
|
.header("X-MC-Key", API_KEY)
|
||||||
|
.header("X-MC-Visitor-Token", token)
|
||||||
|
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
assertThat(resp.statusCode()).as("upload response: %s", resp.body()).isEqualTo(200);
|
||||||
|
// Response shape: {"code":200,...,"data":{"fileId":"...","fileName":"...",...}}
|
||||||
|
String fileId = extractStringField(resp.body(), "fileId");
|
||||||
|
assertThat(fileId).isNotBlank();
|
||||||
|
return fileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /stream with the given message + attachmentIds; drain SSE until done. */
|
||||||
|
private void stream(String visitorId, String sessionId, String message, String attachmentJson) throws Exception {
|
||||||
|
String attachmentField = attachmentJson == null ? "" : ",\"attachmentIds\":" + attachmentJson;
|
||||||
|
String sessionField = sessionId == null ? "" : ",\"sessionId\":\"" + sessionId + "\"";
|
||||||
|
String body = "{\"message\":\"" + message + "\",\"visitorId\":\"" + visitorId + "\""
|
||||||
|
+ sessionField + attachmentField + "}";
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(streamUri())
|
||||||
|
.timeout(HTTP_TIMEOUT)
|
||||||
|
.header("X-MC-Key", API_KEY)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Accept", "text/event-stream")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
|
||||||
|
.build();
|
||||||
|
HttpResponse<java.io.InputStream> resp = http.send(req, HttpResponse.BodyHandlers.ofInputStream());
|
||||||
|
// Drain to done so the request completes within the test.
|
||||||
|
try (var is = resp.body();
|
||||||
|
var reader = new java.io.BufferedReader(new java.io.InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
if (line.startsWith("event:done")) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pull the persisted user message's raw content_parts JSON for this conversation. */
|
||||||
|
private String lastUserContentParts(String conversationId) {
|
||||||
|
return jdbc.queryForObject(
|
||||||
|
"SELECT content_parts FROM mate_message WHERE conversation_id = ? AND role = 'user' " +
|
||||||
|
"ORDER BY create_time DESC, id DESC LIMIT 1",
|
||||||
|
String.class, conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Naive JSON string-field extractor — avoids pulling in Jackson in the test body. */
|
||||||
|
private static String extractStringField(String json, String fieldName) {
|
||||||
|
String key = "\"" + fieldName + "\":\"";
|
||||||
|
int i = json.indexOf(key);
|
||||||
|
if (i < 0) return null;
|
||||||
|
int start = i + key.length();
|
||||||
|
int end = json.indexOf('"', start);
|
||||||
|
return end < 0 ? null : json.substring(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== cleanup ====================
|
||||||
|
|
||||||
|
@org.junit.jupiter.api.AfterEach
|
||||||
|
void cleanUploadDirs() throws IOException {
|
||||||
|
// Only the convs under our key8 prefix are ours; safe to wipe.
|
||||||
|
Path root = UPLOAD_ROOT.resolve("webchat:testkey1");
|
||||||
|
if (Files.exists(root)) {
|
||||||
|
try (Stream<Path> walk = Files.walk(root)) {
|
||||||
|
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||||
|
try { Files.deleteIfExists(p); } catch (IOException ignored) { }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== tests ====================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("upload + /stream round-trip: user message content_parts carries the file path")
|
||||||
|
void uploadThenStreamAttachesPath() throws Exception {
|
||||||
|
String visitorId = "vAtt-roundtrip";
|
||||||
|
String sessionId = "s-att-1";
|
||||||
|
String fileBody = "hello attachment e2e";
|
||||||
|
String fileId = upload(visitorId, sessionId, "note.txt", "text/plain", fileBody.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
stream(visitorId, sessionId, "please read the attached", "[\"" + fileId + "\"]");
|
||||||
|
|
||||||
|
String cid = WebChatController.deriveConversationId(API_KEY, visitorId, sessionId);
|
||||||
|
String parts = lastUserContentParts(cid);
|
||||||
|
assertThat(parts).isNotNull();
|
||||||
|
// Text part is present.
|
||||||
|
assertThat(parts).contains("\"type\":\"text\"");
|
||||||
|
assertThat(parts).contains("please read the attached");
|
||||||
|
// File part is present with the server path resolved.
|
||||||
|
assertThat(parts).contains("\"type\":\"file\"");
|
||||||
|
assertThat(parts).contains("\"fileName\":\"note.txt\"");
|
||||||
|
assertThat(parts).contains("\"contentType\":\"text/plain\"");
|
||||||
|
assertThat(parts).contains("\"path\":\"");
|
||||||
|
// Path points into the conversation's upload dir on disk.
|
||||||
|
String path = extractStringField(parts, "path");
|
||||||
|
assertThat(path).contains(cid);
|
||||||
|
assertThat(Files.isRegularFile(Path.of(path))).isTrue();
|
||||||
|
// The bytes on disk match what we uploaded.
|
||||||
|
assertThat(Files.readString(Path.of(path))).isEqualTo(fileBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("unknown attachmentId is silently dropped — no error, user message has text only")
|
||||||
|
void unknownAttachmentIdDropped() throws Exception {
|
||||||
|
String visitorId = "vAtt-unknown";
|
||||||
|
stream(visitorId, null, "hello", "[\"totally-bogus-file-id\"]");
|
||||||
|
|
||||||
|
String cid = WebChatController.deriveConversationId(API_KEY, visitorId, null);
|
||||||
|
String parts = lastUserContentParts(cid);
|
||||||
|
assertThat(parts).isNotNull();
|
||||||
|
assertThat(parts).contains("\"type\":\"text\"");
|
||||||
|
// No file part at all.
|
||||||
|
assertThat(parts).doesNotContain("\"type\":\"file\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("foreign visitor cannot reference another visitor's attachmentId")
|
||||||
|
void foreignAttachmentIdDropped() throws Exception {
|
||||||
|
// visitor A uploads legitimately.
|
||||||
|
String visitorA = "vAtt-alice";
|
||||||
|
String visitorB = "vAtt-bob";
|
||||||
|
String aliceFileId = upload(visitorA, null, "alice.txt", "text/plain",
|
||||||
|
"alice's secret".getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
// visitor B references alice's fileId — should be silently dropped.
|
||||||
|
stream(visitorB, null, "trying to grab alice's file", "[\"" + aliceFileId + "\"]");
|
||||||
|
|
||||||
|
// B's conversation's user message has no file part.
|
||||||
|
String bobCid = WebChatController.deriveConversationId(API_KEY, visitorB, null);
|
||||||
|
String bobParts = lastUserContentParts(bobCid);
|
||||||
|
assertThat(bobParts).doesNotContain("\"type\":\"file\"");
|
||||||
|
// Alice's conversation is untouched — no user message there at all.
|
||||||
|
String aliceCid = WebChatController.deriveConversationId(API_KEY, visitorA, null);
|
||||||
|
Integer aliceMsgCount = jdbc.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM mate_message WHERE conversation_id = ? AND role = 'user'",
|
||||||
|
Integer.class, aliceCid);
|
||||||
|
assertThat(aliceMsgCount).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("upload without visitorToken → HTTP 401 (RHttpStatusAdvice maps R.code to status)")
|
||||||
|
void uploadRequiresVisitorToken() throws Exception {
|
||||||
|
String boundary = "----mcboundary" + System.nanoTime();
|
||||||
|
byte[] body = multipart(boundary, Map.of("visitorId", "vAtt-notoken"),
|
||||||
|
"file", "x.txt", "text/plain", "x".getBytes(StandardCharsets.UTF_8));
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(uploadUri())
|
||||||
|
.timeout(HTTP_TIMEOUT)
|
||||||
|
.header("X-MC-Key", API_KEY)
|
||||||
|
// No X-MC-Visitor-Token
|
||||||
|
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
assertThat(resp.statusCode()).isEqualTo(401);
|
||||||
|
assertThat(resp.body()).contains("\"code\":401");
|
||||||
|
assertThat(resp.body()).contains("Invalid or missing visitor token");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("upload with disallowed extension → HTTP 400")
|
||||||
|
void uploadRejectsDisallowedExtension() throws Exception {
|
||||||
|
String boundary = "----mcboundary" + System.nanoTime();
|
||||||
|
byte[] body = multipart(boundary, Map.of("visitorId", "vAtt-badext"),
|
||||||
|
"file", "evil.exe", "application/octet-stream", new byte[]{1, 2, 3});
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(uploadUri())
|
||||||
|
.timeout(HTTP_TIMEOUT)
|
||||||
|
.header("X-MC-Key", API_KEY)
|
||||||
|
.header("X-MC-Visitor-Token", tokenFor("vAtt-badext"))
|
||||||
|
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(body))
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||||
|
assertThat(resp.statusCode()).isEqualTo(400);
|
||||||
|
assertThat(resp.body()).contains("File type not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("GET /files streams back the uploaded bytes")
|
||||||
|
void downloadReturnsUploadedBytes() throws Exception {
|
||||||
|
String visitorId = "vAtt-download";
|
||||||
|
String sessionId = "s-dl-1";
|
||||||
|
byte[] payload = "download me".getBytes(StandardCharsets.UTF_8);
|
||||||
|
String fileId = upload(visitorId, sessionId, "payload.txt", "text/plain", payload);
|
||||||
|
|
||||||
|
// /stream must consume the staged file first; download's ownsConversation
|
||||||
|
// guard also needs the conversation row to exist. sessionId MUST be
|
||||||
|
// threaded through /files too — the controller recomputes conversationId
|
||||||
|
// from (apiKey, visitorId, sessionId) so a missing sid maps to a
|
||||||
|
// different namespace than where the upload lives.
|
||||||
|
stream(visitorId, sessionId, "first message", "[\"" + fileId + "\"]");
|
||||||
|
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(filesUri(fileId, visitorId, sessionId))
|
||||||
|
.timeout(HTTP_TIMEOUT)
|
||||||
|
.header("X-MC-Key", API_KEY)
|
||||||
|
.header("X-MC-Visitor-Token", tokenFor(visitorId))
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<byte[]> resp = http.send(req, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
assertThat(resp.statusCode()).isEqualTo(200);
|
||||||
|
assertThat(resp.body()).isEqualTo(payload);
|
||||||
|
assertThat(resp.headers().firstValue("Content-Type").orElse(""))
|
||||||
|
.contains("text/plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("GET /files without visitorToken → 401")
|
||||||
|
void downloadRequiresVisitorToken() throws Exception {
|
||||||
|
String visitorId = "vAtt-dl-notoken";
|
||||||
|
String sessionId = "s-dl-2";
|
||||||
|
String fileId = upload(visitorId, sessionId, "x.txt", "text/plain",
|
||||||
|
"x".getBytes(StandardCharsets.UTF_8));
|
||||||
|
stream(visitorId, sessionId, "first", "[\"" + fileId + "\"]");
|
||||||
|
|
||||||
|
HttpRequest req = HttpRequest.newBuilder()
|
||||||
|
.uri(filesUri(fileId, visitorId, sessionId))
|
||||||
|
.timeout(HTTP_TIMEOUT)
|
||||||
|
.header("X-MC-Key", API_KEY)
|
||||||
|
// No X-MC-Visitor-Token
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<Void> resp = http.send(req, HttpResponse.BodyHandlers.discarding());
|
||||||
|
assertThat(resp.statusCode()).isEqualTo(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user