memos/server/router/api/v1/gateway_marshaler_test.go

139 lines
4.6 KiB
Go

package v1
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
)
// TestGatewayMarshalerOmitsUnsetMessageFields pins the REST payload shape the
// generated OpenAPI schema describes. grpc-gateway's stock marshaler writes
// `"motionMedia": null` for a plain image attachment, which no schema declares
// as nullable, and clients that validate against the spec — including strict
// MCP clients, whose tool outputSchema is that same schema — reject the
// response outright.
func TestGatewayMarshalerOmitsUnsetMessageFields(t *testing.T) {
attachment := &v1pb.Attachment{
Name: "attachments/plainimage1",
CreateTime: timestamppb.New(time.Unix(1700000000, 0).UTC()),
Filename: "sunset.png",
Type: "image/png",
}
payload := marshalThroughGateway(t, attachment)
require.NotContains(t, payload, "motionMedia", "an unset message field must be omitted, not emitted as null")
require.NotContains(t, payload, "mediaMetadata", "an unset message field must be omitted, not emitted as null")
// Scalar defaults stay in the payload: the schema lists filename and type as
// required, so dropping unpopulated scalars would break validation instead.
require.Equal(t, "", payload["externalLink"])
require.Equal(t, "sunset.png", payload["filename"])
require.Equal(t, "image/png", payload["type"])
require.Equal(t, "0", payload["size"])
}
func TestGatewayMarshalerKeepsMediaMetadataFieldNames(t *testing.T) {
attachment := &v1pb.Attachment{
Name: "attachments/video1",
Filename: "clip.mp4",
Type: "video/mp4",
MediaMetadata: &v1pb.MediaMetadata{
Width: proto.Int32(1920),
Height: proto.Int32(1080),
Details: &v1pb.MediaMetadata_Video{Video: &v1pb.VideoMetadata{
DurationSeconds: proto.Float64(12.5),
}},
},
}
payload := marshalThroughGateway(t, attachment)
metadata, ok := payload["mediaMetadata"].(map[string]any)
require.True(t, ok)
require.Equal(t, float64(1920), metadata["width"])
video, ok := metadata["video"].(map[string]any)
require.True(t, ok)
require.Equal(t, 12.5, video["durationSeconds"])
}
func TestGatewayMarshalerNamesSourceExifOrientationExplicitly(t *testing.T) {
attachment := &v1pb.Attachment{
Name: "attachments/photo1",
Filename: "photo.jpg",
Type: "image/jpeg",
MediaMetadata: &v1pb.MediaMetadata{
Details: &v1pb.MediaMetadata_Photo{Photo: &v1pb.PhotoMetadata{
SourceExifOrientation: proto.Int32(6),
}},
},
}
payload := marshalThroughGateway(t, attachment)
metadata, ok := payload["mediaMetadata"].(map[string]any)
require.True(t, ok)
photo, ok := metadata["photo"].(map[string]any)
require.True(t, ok)
require.Equal(t, float64(6), photo["sourceExifOrientation"])
require.NotContains(t, photo, "orientation")
}
func TestGatewayMarshalerKeepsPopulatedMessageFields(t *testing.T) {
attachment := &v1pb.Attachment{
Name: "attachments/livephoto1",
Filename: "walk.heic",
Type: "image/heic",
MotionMedia: &v1pb.MotionMedia{
Family: v1pb.MotionMediaFamily_APPLE_LIVE_PHOTO,
Role: v1pb.MotionMediaRole_STILL,
GroupId: "group1",
},
}
payload := marshalThroughGateway(t, attachment)
motionMedia, ok := payload["motionMedia"].(map[string]any)
require.True(t, ok, "a populated message field must still be emitted: %v", payload["motionMedia"])
require.Equal(t, "APPLE_LIVE_PHOTO", motionMedia["family"])
require.Equal(t, "group1", motionMedia["groupId"])
}
// TestGatewayMarshalerKeepsEmptyCollections guards the other half of the
// default-value behaviour: list fields the schema types as arrays must not
// disappear when empty.
func TestGatewayMarshalerKeepsEmptyCollections(t *testing.T) {
memo := &v1pb.Memo{
Name: "memos/plainmemo1",
Content: "",
State: v1pb.State_NORMAL,
Visibility: v1pb.Visibility_PRIVATE,
}
payload := marshalThroughGateway(t, memo)
require.Equal(t, []any{}, payload["attachments"])
require.Equal(t, []any{}, payload["relations"])
require.Equal(t, "", payload["content"])
require.Equal(t, false, payload["pinned"])
// location is `optional` in the proto, so it is already omitted rather than
// null; property has no such marker and would otherwise be null here.
require.NotContains(t, payload, "location")
require.NotContains(t, payload, "property")
}
func marshalThroughGateway(t *testing.T, message any) map[string]any {
t.Helper()
data, err := newGatewayMarshaler().Marshal(message)
require.NoError(t, err)
payload := map[string]any{}
require.NoError(t, json.Unmarshal(data, &payload))
return payload
}