Files
4ier__notion-cli/cmd/media_source_test.go
傅洋 7d0d5f8b77 feat(block): --image-file / --image-upload (and friends) for append/insert (#30)
Before this change there was no first-class CLI path to turn an uploaded
file into a block. Users had to run `notion file upload`, capture the
id, then hand-craft a PATCH /v1/blocks/<id>/children JSON body and send
it via `notion api`. The existing --image-url only accepted external
http(s) links.

This PR adds a symmetric triple for every media type Notion supports:

  --image-url / --image-file / --image-upload
  --file-url  / --file-file  / --file-upload
  --video-url / --video-file / --video-upload
  --audio-url / --audio-file / --audio-upload
  --pdf-url   / --pdf-file   / --pdf-upload

Semantics:
  --<kind>-url     http(s) external URL           → block.<kind>.type = external
  --<kind>-file    local path                     → upload then embed
  --<kind>-upload  existing file_upload id        → embed directly

Flags are mutually exclusive with each other and with --file/positional
text. --caption works with any of them.

Implementation lives in cmd/media_source.go:
  - registerMediaFlags: adds the 15 flags + --caption to a cobra command.
  - resolveMediaSource: validates, returns the single active source.
  - mediaSource.Build: performs the upload (for --*-file) and assembles
    the final block map.

The legacy --image-url flag and its validator/builder are now thin
back-compat wrappers around the new helpers, so existing tests and
usage keep working byte-for-byte.

Closes #23
2026-04-30 12:59:43 +08:00

219 lines
6.6 KiB
Go

package cmd
import (
"fmt"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
)
func newMediaCmd() *cobra.Command {
c := &cobra.Command{Use: "test"}
registerMediaFlags(c)
return c
}
type fakeMediaClient struct {
mockFileUploadClient
}
func (f *fakeMediaClient) Post(path string, body interface{}) ([]byte, error) {
return f.mockFileUploadClient.Post(path, body)
}
func (f *fakeMediaClient) Patch(path string, body interface{}) ([]byte, error) {
return f.mockFileUploadClient.Patch(path, body)
}
func (f *fakeMediaClient) UploadFileContent(uploadID, fileName, contentType string, fileBytes []byte) ([]byte, error) {
return f.mockFileUploadClient.UploadFileContent(uploadID, fileName, contentType, fileBytes)
}
func TestResolveMediaSource_NoneSet(t *testing.T) {
cmd := newMediaCmd()
got, err := resolveMediaSource(cmd, "", "")
if err != nil {
t.Fatal(err)
}
if got.IsActive() {
t.Error("no flags set should produce inactive source")
}
}
func TestResolveMediaSource_EveryKindAndMode(t *testing.T) {
cases := []struct {
flag string
value string
wantKind string
wantMode string
}{
{"image-url", "https://x/y.png", "image", "external"},
{"image-file", "/tmp/a.png", "image", "file"},
{"image-upload", "abc-123", "image", "upload"},
{"video-url", "https://x/y.mp4", "video", "external"},
{"video-file", "/tmp/a.mp4", "video", "file"},
{"video-upload", "abc-123", "video", "upload"},
{"audio-url", "https://x/y.mp3", "audio", "external"},
{"audio-file", "/tmp/a.mp3", "audio", "file"},
{"audio-upload", "abc-123", "audio", "upload"},
{"file-url", "https://x/y.zip", "file", "external"},
{"file-file", "/tmp/a.zip", "file", "file"},
{"file-upload", "abc-123", "file", "upload"},
{"pdf-url", "https://x/y.pdf", "pdf", "external"},
{"pdf-file", "/tmp/a.pdf", "pdf", "file"},
{"pdf-upload", "abc-123", "pdf", "upload"},
}
for _, tc := range cases {
t.Run(tc.flag, func(t *testing.T) {
cmd := newMediaCmd()
if err := cmd.Flags().Set(tc.flag, tc.value); err != nil {
t.Fatal(err)
}
got, err := resolveMediaSource(cmd, "", "")
if err != nil {
t.Fatal(err)
}
if got.kind != tc.wantKind || got.mode != tc.wantMode || got.value != tc.value {
t.Errorf("got %+v, want {kind:%s mode:%s value:%s}", got, tc.wantKind, tc.wantMode, tc.value)
}
})
}
}
func TestResolveMediaSource_MutualExclusion(t *testing.T) {
cmd := newMediaCmd()
cmd.Flags().Set("image-url", "https://x/y.png")
cmd.Flags().Set("pdf-upload", "abc-123")
_, err := resolveMediaSource(cmd, "", "")
if err == nil {
t.Fatal("expected error for two active sources")
}
if !strings.Contains(err.Error(), "at most one media source") {
t.Errorf("unexpected error text: %v", err)
}
}
func TestResolveMediaSource_ConflictsWithTextAndFile(t *testing.T) {
cmd := newMediaCmd()
cmd.Flags().Set("image-file", "/tmp/x.png")
if _, err := resolveMediaSource(cmd, "notes.md", ""); err == nil || !strings.Contains(err.Error(), "--file") {
t.Errorf("should conflict with --file, got: %v", err)
}
cmd2 := newMediaCmd()
cmd2.Flags().Set("image-upload", "abc")
if _, err := resolveMediaSource(cmd2, "", "hello"); err == nil || !strings.Contains(err.Error(), "positional") {
t.Errorf("should conflict with text, got: %v", err)
}
}
func TestResolveMediaSource_RejectsNonHTTPExternal(t *testing.T) {
cmd := newMediaCmd()
cmd.Flags().Set("image-url", "ftp://x/y.png")
_, err := resolveMediaSource(cmd, "", "")
if err == nil || !strings.Contains(err.Error(), "http://") {
t.Errorf("ftp url should be rejected, got: %v", err)
}
}
func TestResolveMediaSource_CaptionWithoutSource(t *testing.T) {
cmd := newMediaCmd()
cmd.Flags().Set("caption", "fig.1")
if _, err := resolveMediaSource(cmd, "", ""); err == nil {
t.Error("standalone --caption should error")
}
}
func TestBuildExternalMediaBlock_Types(t *testing.T) {
for _, kind := range mediaKinds {
b := buildExternalMediaBlock(kind, "https://x/y", "c")
if b["type"] != kind {
t.Errorf("type=%v, want %s", b["type"], kind)
}
m := b[kind].(map[string]interface{})
if m["type"] != "external" {
t.Errorf("%s.type=%v, want external", kind, m["type"])
}
if _, ok := m["caption"]; !ok {
t.Errorf("%s caption missing", kind)
}
}
}
func TestBuildFileUploadMediaBlock_Types(t *testing.T) {
for _, kind := range mediaKinds {
b := buildFileUploadMediaBlock(kind, "u-1", "")
if b["type"] != kind {
t.Errorf("type=%v, want %s", b["type"], kind)
}
m := b[kind].(map[string]interface{})
if m["type"] != "file_upload" {
t.Errorf("%s.type=%v, want file_upload", kind, m["type"])
}
fu := m["file_upload"].(map[string]interface{})
if fu["id"] != "u-1" {
t.Errorf("%s file_upload.id=%v", kind, fu["id"])
}
if _, ok := m["caption"]; ok {
t.Errorf("%s caption should be omitted when empty", kind)
}
}
}
func TestMediaSource_Build_Upload(t *testing.T) {
src := &mediaSource{kind: "pdf", mode: "upload", value: "upload-xyz", caption: "spec"}
block, err := src.Build(&fakeMediaClient{})
if err != nil {
t.Fatal(err)
}
if block["type"] != "pdf" {
t.Errorf("type = %v", block["type"])
}
m := block["pdf"].(map[string]interface{})
if m["file_upload"].(map[string]interface{})["id"] != "upload-xyz" {
t.Errorf("wrong upload id: %v", m)
}
}
func TestMediaSource_Build_External(t *testing.T) {
src := &mediaSource{kind: "image", mode: "external", value: "https://x/y.png"}
block, err := src.Build(&fakeMediaClient{})
if err != nil {
t.Fatal(err)
}
m := block["image"].(map[string]interface{})
if m["external"].(map[string]interface{})["url"] != "https://x/y.png" {
t.Errorf("wrong url: %v", m)
}
}
// Regression: --image-file should upload the local file and then reference
// the returned file_upload id in a single block, without attaching
// anywhere else (attaching is the caller's job via appendChildrenBatched).
func TestMediaSource_Build_File_UploadsAndReferences(t *testing.T) {
tmp := t.TempDir()
localPath := fmt.Sprintf("%s/chart.png", tmp)
if err := writeTempFile(localPath, "not-a-png"); err != nil {
t.Fatal(err)
}
mock := &fakeMediaClient{}
src := &mediaSource{kind: "image", mode: "file", value: localPath}
block, err := src.Build(mock)
if err != nil {
t.Fatal(err)
}
if mock.postPath != "/v1/file_uploads" {
t.Errorf("expected file_uploads POST, got %q", mock.postPath)
}
if mock.patchPath != "" {
t.Errorf("media source should NOT patch a parent; got path=%q", mock.patchPath)
}
m := block["image"].(map[string]interface{})
if m["file_upload"].(map[string]interface{})["id"] != "upload-123" {
t.Errorf("expected upload-123, got %v", m["file_upload"])
}
}
func writeTempFile(path, contents string) error {
return os.WriteFile(path, []byte(contents), 0600)
}