Skip to content

Commit cb078ad

Browse files
Copilotstephentoub
andcommitted
Add comprehensive base64 roundtripping tests for all protocol types
Add Theory-based tests for ImageContentBlock, AudioContentBlock, and BlobResourceContents covering: - Various lengths (empty, 1-4 bytes, 256 bytes, 1024 bytes) - Full base64 alphabet including '+' and '/' characters - With/without padding (0, 1, 2 padding chars) - All construction paths: FromBytes(), Data/Blob setter, JSON deserialization - Escaped and unescaped JSON deserialization - Lazy encoding verification (FromBytes defers until Data/Blob accessed) - Cache invalidation when Data/Blob setter is used after FromBytes Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 026e95e commit cb078ad

2 files changed

Lines changed: 381 additions & 0 deletions

File tree

tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using ModelContextProtocol.Protocol;
2+
using System.Text;
23
using System.Text.Json;
34

45
namespace ModelContextProtocol.Tests.Protocol;
@@ -323,4 +324,242 @@ public void AudioContentBlock_Deserialization_HandlesEscapedForwardSlashInBase64
323324
Assert.Equal(base64, System.Text.Encoding.UTF8.GetString(audio.Data.ToArray()));
324325
Assert.Equal(originalBytes, audio.DecodedData.ToArray());
325326
}
327+
328+
/// <summary>
329+
/// Provides test data for base64 roundtrip tests. Each entry is a byte array that exercises
330+
/// different base64 encoding characteristics:
331+
/// - Various lengths producing 0, 1, or 2 padding characters
332+
/// - Bytes that produce all 64 base64 alphabet characters including '+' and '/'
333+
/// </summary>
334+
public static TheoryData<byte[]> Base64TestData()
335+
{
336+
var data = new TheoryData<byte[]>
337+
{
338+
Array.Empty<byte>(), // empty: ""
339+
new byte[] { 0x00 }, // 1 byte, 2 padding chars: "AA=="
340+
new byte[] { 0x00, 0x01 }, // 2 bytes, 1 padding char: "AAE="
341+
new byte[] { 0x00, 0x01, 0x02 }, // 3 bytes, no padding: "AAEC"
342+
new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, // produces '/' in base64: "/9j/4A=="
343+
new byte[] { 0xFB, 0xEF, 0xBE }, // produces '+' in base64: "+++"
344+
};
345+
346+
// All 256 byte values to exercise the full base64 alphabet
347+
byte[] allBytes = new byte[256];
348+
for (int i = 0; i < 256; i++)
349+
{
350+
allBytes[i] = (byte)i;
351+
}
352+
data.Add(allBytes);
353+
354+
// Larger payload (1024 bytes)
355+
byte[] largePayload = new byte[1024];
356+
new Random(42).NextBytes(largePayload);
357+
data.Add(largePayload);
358+
359+
return data;
360+
}
361+
362+
[Theory]
363+
[MemberData(nameof(Base64TestData))]
364+
public void ImageContentBlock_FromBytes_RoundtripsCorrectly(byte[] originalBytes)
365+
{
366+
string expectedBase64 = Convert.ToBase64String(originalBytes);
367+
368+
var image = ImageContentBlock.FromBytes(originalBytes, "image/png");
369+
370+
Assert.Equal("image/png", image.MimeType);
371+
Assert.Equal(originalBytes, image.DecodedData.ToArray());
372+
Assert.Equal(expectedBase64, Encoding.UTF8.GetString(image.Data.ToArray()));
373+
}
374+
375+
[Theory]
376+
[MemberData(nameof(Base64TestData))]
377+
public void ImageContentBlock_DataSetter_RoundtripsCorrectly(byte[] originalBytes)
378+
{
379+
string base64 = Convert.ToBase64String(originalBytes);
380+
byte[] base64Utf8 = Encoding.UTF8.GetBytes(base64);
381+
382+
var image = new ImageContentBlock { Data = base64Utf8, MimeType = "image/png" };
383+
384+
Assert.Equal(base64Utf8, image.Data.ToArray());
385+
Assert.Equal(originalBytes, image.DecodedData.ToArray());
386+
}
387+
388+
[Theory]
389+
[MemberData(nameof(Base64TestData))]
390+
public void ImageContentBlock_JsonRoundtrip_PreservesData(byte[] originalBytes)
391+
{
392+
string base64 = Convert.ToBase64String(originalBytes);
393+
byte[] base64Utf8 = Encoding.UTF8.GetBytes(base64);
394+
395+
var original = new ImageContentBlock { Data = base64Utf8, MimeType = "image/png" };
396+
string json = JsonSerializer.Serialize<ContentBlock>(original, McpJsonUtilities.DefaultOptions);
397+
var deserialized = Assert.IsType<ImageContentBlock>(
398+
JsonSerializer.Deserialize<ContentBlock>(json, McpJsonUtilities.DefaultOptions));
399+
400+
Assert.Equal(base64Utf8, deserialized.Data.ToArray());
401+
Assert.Equal(originalBytes, deserialized.DecodedData.ToArray());
402+
}
403+
404+
[Theory]
405+
[MemberData(nameof(Base64TestData))]
406+
public void ImageContentBlock_FromBytes_JsonRoundtrip_PreservesData(byte[] originalBytes)
407+
{
408+
string expectedBase64 = Convert.ToBase64String(originalBytes);
409+
410+
var original = ImageContentBlock.FromBytes(originalBytes, "image/jpeg");
411+
string json = JsonSerializer.Serialize<ContentBlock>(original, McpJsonUtilities.DefaultOptions);
412+
var deserialized = Assert.IsType<ImageContentBlock>(
413+
JsonSerializer.Deserialize<ContentBlock>(json, McpJsonUtilities.DefaultOptions));
414+
415+
Assert.Equal(expectedBase64, Encoding.UTF8.GetString(deserialized.Data.ToArray()));
416+
Assert.Equal(originalBytes, deserialized.DecodedData.ToArray());
417+
}
418+
419+
[Theory]
420+
[MemberData(nameof(Base64TestData))]
421+
public void ImageContentBlock_EscapedJsonRoundtrip_PreservesData(byte[] originalBytes)
422+
{
423+
string base64 = Convert.ToBase64String(originalBytes);
424+
425+
// Simulate JSON encoder that escapes '/' as '\/'
426+
string json = $$"""{"type":"image","data":"{{base64.Replace("/", "\\/")}}","mimeType":"image/png"}""";
427+
428+
var deserialized = Assert.IsType<ImageContentBlock>(
429+
JsonSerializer.Deserialize<ContentBlock>(json, McpJsonUtilities.DefaultOptions));
430+
431+
Assert.Equal(base64, Encoding.UTF8.GetString(deserialized.Data.ToArray()));
432+
Assert.Equal(originalBytes, deserialized.DecodedData.ToArray());
433+
}
434+
435+
[Fact]
436+
public void ImageContentBlock_DataSetterInvalidatesCachedDecodedData()
437+
{
438+
byte[] bytes1 = [1, 2, 3];
439+
var image = ImageContentBlock.FromBytes(bytes1, "image/png");
440+
441+
// Access DecodedData to populate cache
442+
Assert.Equal(bytes1, image.DecodedData.ToArray());
443+
444+
// Set new Data to invalidate cache
445+
byte[] newBytes = [4, 5, 6];
446+
string newBase64 = Convert.ToBase64String(newBytes);
447+
image.Data = Encoding.UTF8.GetBytes(newBase64);
448+
449+
Assert.Equal(newBytes, image.DecodedData.ToArray());
450+
}
451+
452+
[Theory]
453+
[MemberData(nameof(Base64TestData))]
454+
public void AudioContentBlock_FromBytes_RoundtripsCorrectly(byte[] originalBytes)
455+
{
456+
string expectedBase64 = Convert.ToBase64String(originalBytes);
457+
458+
var audio = AudioContentBlock.FromBytes(originalBytes, "audio/wav");
459+
460+
Assert.Equal("audio/wav", audio.MimeType);
461+
Assert.Equal(originalBytes, audio.DecodedData.ToArray());
462+
Assert.Equal(expectedBase64, Encoding.UTF8.GetString(audio.Data.ToArray()));
463+
}
464+
465+
[Theory]
466+
[MemberData(nameof(Base64TestData))]
467+
public void AudioContentBlock_DataSetter_RoundtripsCorrectly(byte[] originalBytes)
468+
{
469+
string base64 = Convert.ToBase64String(originalBytes);
470+
byte[] base64Utf8 = Encoding.UTF8.GetBytes(base64);
471+
472+
var audio = new AudioContentBlock { Data = base64Utf8, MimeType = "audio/wav" };
473+
474+
Assert.Equal(base64Utf8, audio.Data.ToArray());
475+
Assert.Equal(originalBytes, audio.DecodedData.ToArray());
476+
}
477+
478+
[Theory]
479+
[MemberData(nameof(Base64TestData))]
480+
public void AudioContentBlock_JsonRoundtrip_PreservesData(byte[] originalBytes)
481+
{
482+
string base64 = Convert.ToBase64String(originalBytes);
483+
byte[] base64Utf8 = Encoding.UTF8.GetBytes(base64);
484+
485+
var original = new AudioContentBlock { Data = base64Utf8, MimeType = "audio/wav" };
486+
string json = JsonSerializer.Serialize<ContentBlock>(original, McpJsonUtilities.DefaultOptions);
487+
var deserialized = Assert.IsType<AudioContentBlock>(
488+
JsonSerializer.Deserialize<ContentBlock>(json, McpJsonUtilities.DefaultOptions));
489+
490+
Assert.Equal(base64Utf8, deserialized.Data.ToArray());
491+
Assert.Equal(originalBytes, deserialized.DecodedData.ToArray());
492+
}
493+
494+
[Theory]
495+
[MemberData(nameof(Base64TestData))]
496+
public void AudioContentBlock_FromBytes_JsonRoundtrip_PreservesData(byte[] originalBytes)
497+
{
498+
string expectedBase64 = Convert.ToBase64String(originalBytes);
499+
500+
var original = AudioContentBlock.FromBytes(originalBytes, "audio/mp3");
501+
string json = JsonSerializer.Serialize<ContentBlock>(original, McpJsonUtilities.DefaultOptions);
502+
var deserialized = Assert.IsType<AudioContentBlock>(
503+
JsonSerializer.Deserialize<ContentBlock>(json, McpJsonUtilities.DefaultOptions));
504+
505+
Assert.Equal(expectedBase64, Encoding.UTF8.GetString(deserialized.Data.ToArray()));
506+
Assert.Equal(originalBytes, deserialized.DecodedData.ToArray());
507+
}
508+
509+
[Theory]
510+
[MemberData(nameof(Base64TestData))]
511+
public void AudioContentBlock_EscapedJsonRoundtrip_PreservesData(byte[] originalBytes)
512+
{
513+
string base64 = Convert.ToBase64String(originalBytes);
514+
515+
string json = $$"""{"type":"audio","data":"{{base64.Replace("/", "\\/")}}","mimeType":"audio/wav"}""";
516+
517+
var deserialized = Assert.IsType<AudioContentBlock>(
518+
JsonSerializer.Deserialize<ContentBlock>(json, McpJsonUtilities.DefaultOptions));
519+
520+
Assert.Equal(base64, Encoding.UTF8.GetString(deserialized.Data.ToArray()));
521+
Assert.Equal(originalBytes, deserialized.DecodedData.ToArray());
522+
}
523+
524+
[Fact]
525+
public void AudioContentBlock_DataSetterInvalidatesCachedDecodedData()
526+
{
527+
byte[] bytes1 = [1, 2, 3];
528+
var audio = AudioContentBlock.FromBytes(bytes1, "audio/wav");
529+
530+
Assert.Equal(bytes1, audio.DecodedData.ToArray());
531+
532+
byte[] newBytes = [4, 5, 6];
533+
string newBase64 = Convert.ToBase64String(newBytes);
534+
audio.Data = Encoding.UTF8.GetBytes(newBase64);
535+
536+
Assert.Equal(newBytes, audio.DecodedData.ToArray());
537+
}
538+
539+
[Theory]
540+
[MemberData(nameof(Base64TestData))]
541+
public void ImageContentBlock_FromBytes_LazilyEncodesData(byte[] originalBytes)
542+
{
543+
// FromBytes should only decode when Data is accessed
544+
var image = ImageContentBlock.FromBytes(originalBytes, "image/png");
545+
546+
// First, access DecodedData without touching Data
547+
Assert.Equal(originalBytes, image.DecodedData.ToArray());
548+
549+
// Now access Data and verify it lazily encoded correctly
550+
string expectedBase64 = Convert.ToBase64String(originalBytes);
551+
Assert.Equal(expectedBase64, Encoding.UTF8.GetString(image.Data.ToArray()));
552+
}
553+
554+
[Theory]
555+
[MemberData(nameof(Base64TestData))]
556+
public void AudioContentBlock_FromBytes_LazilyEncodesData(byte[] originalBytes)
557+
{
558+
var audio = AudioContentBlock.FromBytes(originalBytes, "audio/wav");
559+
560+
Assert.Equal(originalBytes, audio.DecodedData.ToArray());
561+
562+
string expectedBase64 = Convert.ToBase64String(originalBytes);
563+
Assert.Equal(expectedBase64, Encoding.UTF8.GetString(audio.Data.ToArray()));
564+
}
326565
}

0 commit comments

Comments
 (0)