server_save_draft_test.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
  4
  5package mcp
  6
  7import (
  8	"context"
  9	"testing"
 10
 11	"git.secluded.site/cooked-mcp/internal/cooked"
 12)
 13
 14// recipes.SAVE.7 recipes.SAVE.7-2 recipes.SAVE.9 recipes.SAVE.16 recipes.SAFETY.1 tools.SAVE_RECIPE_TOOL.1 tools.SAVE_RECIPE_TOOL.5
 15func TestCallToolACIDRecipesSave7And9And16SavesReviewedDraft(t *testing.T) {
 16	draftID := "draft/with space"
 17	backend := &fakeBackend{draftSaveRecipeID: testRecipeID}
 18	server := NewServer(backend, "test")
 19
 20	_, output, err := server.callSaveRecipeTool(
 21		context.Background(),
 22		nil,
 23		SaveRecipeArguments{
 24			Source:   " draft ",
 25			DraftID:  " " + draftID + " ",
 26			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
 27			Portions: 2,
 28		},
 29	)
 30	if err != nil {
 31		t.Fatalf("callSaveRecipeTool() error = %v", err)
 32	}
 33
 34	if backend.draftSaveCalls != 1 {
 35		t.Fatalf("draft save calls = %d, want 1", backend.draftSaveCalls)
 36	}
 37	if backend.draftSaveID != draftID {
 38		t.Fatalf("backend draft ID = %q, want draft ID", backend.draftSaveID)
 39	}
 40	if backend.draftSaveMarkdown != "  # Pasta\n\n1. Boil pasta.\n" {
 41		t.Fatalf("backend draft markdown = %q, want raw markdown preserved", backend.draftSaveMarkdown)
 42	}
 43	if backend.draftSavePortions != 2 {
 44		t.Fatalf("backend draft portions = %g, want 2", backend.draftSavePortions)
 45	}
 46	if output.RecipeID != testRecipeID || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 {
 47		t.Fatalf("draft save output = %#v, want recipe_id only", output)
 48	}
 49	if backend.importRecipeURLCalls != 0 || backend.metadataCalls != 0 || backend.contentCalls != 0 {
 50		t.Fatalf(
 51			"unexpected import/read calls = import %d metadata %d content %d, want none",
 52			backend.importRecipeURLCalls,
 53			backend.metadataCalls,
 54			backend.contentCalls,
 55		)
 56	}
 57	if backend.saveCalls != 0 || backend.updateCalls != 0 {
 58		t.Fatalf("prepared/update calls = %d/%d, want none", backend.saveCalls, backend.updateCalls)
 59	}
 60}
 61
 62// recipes.SAVE.7-5 recipes.SAVE.9-1 recipes.SAVE.16 recipes.SAFETY.1 recipes.SAFETY.2
 63func TestCallToolACIDRecipesSave7_5And9_1OverwritesExistingRecipeFromDraft(t *testing.T) {
 64	backend := &fakeBackend{}
 65	server := NewServer(backend, "test")
 66
 67	_, output, err := server.callSaveRecipeTool(
 68		context.Background(),
 69		nil,
 70		SaveRecipeArguments{
 71			Source:   " draft ",
 72			DraftID:  " " + testDraftID + " ",
 73			RecipeID: " " + testRecipeID + " ",
 74			Markdown: "  # Pasta\n\n1. Boil pasta.\n",
 75			Portions: 2,
 76		},
 77	)
 78	if err != nil {
 79		t.Fatalf("callSaveRecipeTool() error = %v", err)
 80	}
 81
 82	if backend.updateCalls != 1 {
 83		t.Fatalf("update calls = %d, want 1", backend.updateCalls)
 84	}
 85	if backend.updateRecipeID != testRecipeID {
 86		t.Fatalf("backend update recipe ID = %q, want test recipe ID", backend.updateRecipeID)
 87	}
 88	if backend.updateMarkdown != "  # Pasta\n\n1. Boil pasta.\n" {
 89		t.Fatalf("backend update markdown = %q, want raw markdown preserved", backend.updateMarkdown)
 90	}
 91	if backend.updatePortions != 2 {
 92		t.Fatalf("backend update portions = %g, want 2", backend.updatePortions)
 93	}
 94	if output.RecipeID != testRecipeID {
 95		t.Fatalf("save output recipe ID = %q, want test recipe ID", output.RecipeID)
 96	}
 97	if backend.draftSaveCalls != 0 || backend.saveCalls != 0 {
 98		t.Fatalf("draft save/prepared calls = %d/%d, want none", backend.draftSaveCalls, backend.saveCalls)
 99	}
100}
101
102// recipes.SAVE.7 recipes.SAVE.7-3 recipes.SAVE.9 recipes.SAVE.16 tools.SAVE_RECIPE_TOOL.5
103func TestCallToolACIDRecipesSave7_3SavesCurrentDraftContentWhenContentOmitted(t *testing.T) {
104	backend := &fakeBackend{
105		draftSaveRecipeID: testRecipeID,
106		recipeContent: cooked.RecipeContent{
107			Content:  "# Pasta\n\n1. Boil pasta.",
108			Portions: 1.5,
109		},
110	}
111	server := NewServer(backend, "test")
112
113	_, output, err := server.callSaveRecipeTool(
114		context.Background(),
115		nil,
116		SaveRecipeArguments{Source: "draft", DraftID: " " + testDraftID + " "},
117	)
118	if err != nil {
119		t.Fatalf("callSaveRecipeTool() error = %v", err)
120	}
121
122	if backend.contentCalls != 1 || backend.contentRecipeID != testDraftID {
123		t.Fatalf("content reads = %d for %q, want 1 for test draft ID", backend.contentCalls, backend.contentRecipeID)
124	}
125	if backend.draftSaveCalls != 1 {
126		t.Fatalf("draft save calls = %d, want 1", backend.draftSaveCalls)
127	}
128	if backend.draftSaveMarkdown != "# Pasta\n\n1. Boil pasta." {
129		t.Fatalf("backend draft markdown = %q, want current draft content", backend.draftSaveMarkdown)
130	}
131	if backend.draftSavePortions != 1.5 {
132		t.Fatalf("backend draft portions = %g, want current draft portions 1.5", backend.draftSavePortions)
133	}
134	if output.RecipeID != testRecipeID || output.DraftID != "" || output.Markdown != "" || output.Portions != 0 {
135		t.Fatalf("draft save output = %#v, want recipe_id only", output)
136	}
137	if backend.metadataCalls != 0 || backend.importRecipeURLCalls != 0 {
138		t.Fatalf(
139			"unexpected metadata/import calls = %d/%d, want none",
140			backend.metadataCalls,
141			backend.importRecipeURLCalls,
142		)
143	}
144}
145
146// recipes.SAVE.7-1 recipes.SAVE.7-4 tools.SAVE_RECIPE_TOOL.5
147func TestCallToolACIDRecipesSave7_1And7_4AndToolsSaveRecipeTool5RequiresDraftIDAndCompleteContent(t *testing.T) {
148	tests := []struct {
149		name      string
150		arguments SaveRecipeArguments
151	}{
152		{
153			name:      "draft_id",
154			arguments: SaveRecipeArguments{Source: "draft", DraftID: "   ", Markdown: "# Pasta", Portions: 2},
155		},
156		{
157			name:      "markdown_without_portions",
158			arguments: SaveRecipeArguments{Source: "draft", DraftID: testDraftID, Markdown: "# Pasta", Portions: 0},
159		},
160		{
161			name:      "portions_without_markdown",
162			arguments: SaveRecipeArguments{Source: "draft", DraftID: testDraftID, Markdown: "   ", Portions: 2},
163		},
164	}
165
166	for _, tt := range tests {
167		t.Run(tt.name, func(t *testing.T) {
168			backend := &fakeBackend{}
169			server := NewServer(backend, "test")
170
171			_, _, err := server.callSaveRecipeTool(context.Background(), nil, tt.arguments)
172			if err == nil {
173				t.Fatal("callSaveRecipeTool() error = nil, want missing draft field error")
174			}
175			if backend.draftSaveCalls != 0 {
176				t.Fatalf("draft save calls = %d, want none", backend.draftSaveCalls)
177			}
178			if backend.contentCalls != 0 {
179				t.Fatalf("content calls = %d, want none", backend.contentCalls)
180			}
181			if backend.saveCalls != 0 || backend.updateCalls != 0 || backend.importRecipeURLCalls != 0 {
182				t.Fatalf(
183					"unexpected backend calls = prepared %d update %d import %d, want none",
184					backend.saveCalls,
185					backend.updateCalls,
186					backend.importRecipeURLCalls,
187				)
188			}
189		})
190	}
191}
192
193// recipes.VALIDATION.1 recipes.VALIDATION.2
194func TestCallToolACIDRecipesValidation1And2RejectsMalformedDraftOverwriteRecipeIDBeforeCooked(t *testing.T) {
195	backend := &fakeBackend{}
196	server := NewServer(backend, "test")
197
198	_, _, err := server.callSaveRecipeTool(
199		context.Background(),
200		nil,
201		SaveRecipeArguments{
202			Source:   "draft",
203			DraftID:  testDraftID,
204			RecipeID: "not-a-uuid",
205			Markdown: "# Pasta",
206			Portions: 2,
207		},
208	)
209	if err == nil {
210		t.Fatal("callSaveRecipeTool() error = nil, want malformed recipe_id error")
211	}
212	if backend.updateCalls != 0 || backend.draftSaveCalls != 0 {
213		t.Fatalf("update/draft save calls = %d/%d, want none", backend.updateCalls, backend.draftSaveCalls)
214	}
215}