Ctrl K
ComunidadGuías…

internal/adminapi/schedule_handler_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package adminapi

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/yeomyeonggeori/blueclaw/internal/task"
)

func TestScheduleHandlerReturnsSummary(t *testing.T) {
	nextRunAt := time.Date(2026, 6, 6, 3, 0, 0, 0, time.UTC)
	handler := ScheduleHandler{
		SummaryRepository: scheduleSummaryRepositoryStub{
			summary: task.ScheduleSummary{
				ActiveCount:       3,
				UnboundedCount:    1,
				IntervalCount:     2,
				EarliestNextRunAt: &nextRunAt,
				LatestNextRunAt:   &nextRunAt,
				CheckedAt:         nextRunAt,
			},
		},
	}
	request := httptest.NewRequest(http.MethodGet, "/admin/api/schedule/summary", nil)
	responseRecorder := httptest.NewRecorder()

	handler.HandleSummary(responseRecorder, request)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("expected ok response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	responseBody := responseRecorder.Body.String()
	for _, expectedFragment := range []string{`"activeCount":3`, `"unboundedCount":1`, `"intervalCount":2`} {
		if !strings.Contains(responseBody, expectedFragment) {
			t.Fatalf("expected response to contain %s, got %s", expectedFragment, responseBody)
		}
	}
	for _, forbiddenFragment := range []string{"prompt", "creator", "conversation", "replyTarget"} {
		if strings.Contains(responseBody, forbiddenFragment) {
			t.Fatalf("expected summary to avoid private schedule details, got %s", responseBody)
		}
	}
}

func TestScheduleHandlerListsActiveSchedules(t *testing.T) {
	nextRunAt := time.Date(2026, 6, 6, 3, 0, 0, 0, time.UTC)
	longPrompt := strings.Repeat("scheduled message ", 40)
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{{
			ScheduleID:       "schedule-1",
			CreatorPersonID:  "person-1",
			Prompt:           longPrompt,
			ExecutionMode:    task.ScheduleExecutionModeAgent,
			Kind:             task.ScheduleKindCron,
			CronExpression:   "0 * * * *",
			NextRunAt:        &nextRunAt,
			CreatedAt:        nextRunAt.Add(-time.Hour),
			UpdatedAt:        nextRunAt.Add(-time.Minute),
			ConversationID:   "channel-1",
			ReplyTargetID:    "post-1",
			AgentProfileName: "default",
		}},
	}
	handler := ScheduleHandler{ListRepository: repository}
	request := httptest.NewRequest(http.MethodGet, "/admin/api/schedule?deliveryConversationID=channel-1&unboundedOnly=true&includeExpired=true&page=2&pageSize=5", nil)
	responseRecorder := httptest.NewRecorder()

	handler.HandleList(responseRecorder, request)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("expected ok response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	if repository.request.ConversationID != "channel-1" || !repository.request.UnboundedOnly || !repository.request.IncludeExpired || repository.request.Page != 2 || repository.request.PageSize != 5 {
		t.Fatalf("expected query parameters to reach repository, got %+v", repository.request)
	}
	responseBody := responseRecorder.Body.String()
	for _, expectedFragment := range []string{`"taskScheduleID":"schedule-1"`, `"deliveryChannelID":"channel-1"`, `"totalCount":1`, `"page":2`, `"pageSize":5`, `"promptPreview":"`} {
		if !strings.Contains(responseBody, expectedFragment) {
			t.Fatalf("expected response to contain %s, got %s", expectedFragment, responseBody)
		}
	}
	if strings.Contains(responseBody, longPrompt) {
		t.Fatalf("expected prompt to be compacted, got %s", responseBody)
	}
}

func TestScheduleToolListUsesOnlySignedPrincipalAndExactProjection(t *testing.T) {
	nextRunAt := time.Now().UTC().Add(time.Hour)
	fullInstruction := strings.Repeat("full instruction ", 20)
	repository := &scheduleListRepositoryStub{schedules: []task.Schedule{
		{
			ScheduleID:      "other-schedule",
			CreatorPersonID: "person-other",
			Prompt:          "must not be selected",
			Kind:            task.ScheduleKindOnce,
			NextRunAt:       &nextRunAt,
		},
		{
			ScheduleID:      "failed-schedule",
			CreatorPersonID: "person-signed",
			Prompt:          fullInstruction,
			Name:            "Daily report",
			Kind:            task.ScheduleKindCron,
			CronExpression:  "0 9 * * *",
			NextRunAt:       &nextRunAt,
			LastError:       "temporary failure",
		},
	}}
	handler := ScheduleHandler{
		ListRepository: repository,
		ReaderPersonID: func(*http.Request) string { return "person-signed" },
	}
	request := httptest.NewRequest(http.MethodPost, "/admin/api/schedule/tool-list", strings.NewReader(`{"status":"failed","limit":1,"creatorPersonID":"person-other"}`))
	responseRecorder := httptest.NewRecorder()

	handler.HandleToolList(responseRecorder, request)

	if responseRecorder.Code != http.StatusBadRequest {
		t.Fatalf("unknown creator field should be refused, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	request = httptest.NewRequest(http.MethodPost, "/admin/api/schedule/tool-list", strings.NewReader(`{"status":"failed"}{"limit":1}`))
	responseRecorder = httptest.NewRecorder()
	handler.HandleToolList(responseRecorder, request)
	if responseRecorder.Code != http.StatusBadRequest {
		t.Fatalf("trailing JSON should be refused, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	request = httptest.NewRequest(http.MethodPost, "/admin/api/schedule/tool-list", strings.NewReader(`{"status":"failed","limit":1}`))
	responseRecorder = httptest.NewRecorder()
	handler.HandleToolList(responseRecorder, request)
	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("expected ok response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	if repository.request.CreatorPersonID != "person-signed" || !repository.request.IncludeExpired || repository.request.PageSize != 200 {
		t.Fatalf("repository query escaped signed principal: %+v", repository.request)
	}
	var output task.ScheduleListOutput
	if errorValue := json.NewDecoder(responseRecorder.Body).Decode(&output); errorValue != nil {
		t.Fatal(errorValue)
	}
	if len(output.Schedules) != 1 || output.Schedules[0].ScheduleID != "failed-schedule" || output.Schedules[0].TaskInstruction != fullInstruction {
		t.Fatalf("unexpected tool projection: %+v", output)
	}
}

func TestScheduleToolListRefusesACursorItNeverGave(t *testing.T) {
	handler := ScheduleHandler{
		ListRepository: &scheduleListRepositoryStub{},
		ReaderPersonID: func(*http.Request) string { return "person-signed" },
	}
	responseRecorder := httptest.NewRecorder()

	handler.HandleToolList(responseRecorder, httptest.NewRequest(http.MethodPost, "/admin/api/schedule/tool-list", strings.NewReader(`{"cursor":"invented"}`)))

	if responseRecorder.Code != http.StatusBadRequest {
		t.Fatalf("an invented cursor status = %d, want %d: %s", responseRecorder.Code, http.StatusBadRequest, responseRecorder.Body.String())
	}
}

func TestScheduleToolListFailsClosedWithoutSignedPrincipal(t *testing.T) {
	for _, handler := range []ScheduleHandler{
		{},
		{ReaderPersonID: func(*http.Request) string { return "" }},
	} {
		responseRecorder := httptest.NewRecorder()
		handler.HandleToolList(responseRecorder, httptest.NewRequest(http.MethodPost, "/admin/api/schedule/tool-list", strings.NewReader(`{}`)))
		if responseRecorder.Code != http.StatusForbidden {
			t.Fatalf("unsigned request status = %d, want %d", responseRecorder.Code, http.StatusForbidden)
		}
	}
}

func TestScheduleHandlerCancelsOwnedSchedule(t *testing.T) {
	nextRunAt := time.Now().UTC().Add(time.Hour)
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{{
			ScheduleID:      "schedule-1",
			CreatorPersonID: "person-1",
			Kind:            task.ScheduleKindInterval,
			IntervalSecond:  3600,
			NextRunAt:       &nextRunAt,
		}},
	}
	handler := ScheduleHandler{ListRepository: repository}
	request := httptest.NewRequest(http.MethodPost, "/admin/api/schedule/cancel", strings.NewReader(`{"taskScheduleID":"schedule-1","creatorPersonID":"person-1"}`))
	responseRecorder := httptest.NewRecorder()

	handler.HandleCancel(responseRecorder, request)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("expected ok response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	if len(repository.cancelRequest.ScheduleIDs) != 1 || repository.cancelRequest.ScheduleIDs[0] != "schedule-1" {
		t.Fatalf("expected schedule id cancel request, got %+v", repository.cancelRequest)
	}
	if repository.cancelRequest.RequesterPersonID != "person-1" {
		t.Fatalf("expected requester person, got %+v", repository.cancelRequest)
	}
}

func TestScheduleHandlerDeletesOwnedSchedule(t *testing.T) {
	nextRunAt := time.Now().UTC().Add(time.Hour)
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{{
			ScheduleID:      "schedule-1",
			CreatorPersonID: "person-1",
			Kind:            task.ScheduleKindInterval,
			IntervalSecond:  3600,
			NextRunAt:       &nextRunAt,
		}},
	}
	handler := ScheduleHandler{ListRepository: repository}
	request := httptest.NewRequest(http.MethodPost, "/admin/api/schedule/delete", strings.NewReader(`{"taskScheduleID":"schedule-1","creatorPersonID":"person-1"}`))
	responseRecorder := httptest.NewRecorder()

	handler.HandleDelete(responseRecorder, request)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("expected ok response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	if repository.deleteRequest.ScheduleID != "schedule-1" || repository.deleteRequest.RequesterPersonID != "person-1" {
		t.Fatalf("expected delete request, got %+v", repository.deleteRequest)
	}
	if len(repository.schedules) != 0 {
		t.Fatalf("expected schedule to be removed, got %+v", repository.schedules)
	}
}

func TestScheduleHandlerRejectsDeleteCreatorMismatch(t *testing.T) {
	nextRunAt := time.Now().UTC().Add(time.Hour)
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{{
			ScheduleID:      "schedule-1",
			CreatorPersonID: "person-1",
			Kind:            task.ScheduleKindInterval,
			IntervalSecond:  3600,
			NextRunAt:       &nextRunAt,
		}},
	}
	handler := ScheduleHandler{ListRepository: repository}
	request := httptest.NewRequest(http.MethodPost, "/admin/api/schedule/delete", strings.NewReader(`{"taskScheduleID":"schedule-1","creatorPersonID":"person-2"}`))
	responseRecorder := httptest.NewRecorder()

	handler.HandleDelete(responseRecorder, request)

	if responseRecorder.Code != http.StatusForbidden {
		t.Fatalf("expected forbidden response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	if repository.deleteRequest.RequesterPersonID != "" {
		t.Fatalf("expected no delete request, got %+v", repository.deleteRequest)
	}
}

func TestScheduleHandlerRejectsCancelCreatorMismatch(t *testing.T) {
	nextRunAt := time.Now().UTC().Add(time.Hour)
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{{
			ScheduleID:      "schedule-1",
			CreatorPersonID: "person-1",
			Kind:            task.ScheduleKindInterval,
			IntervalSecond:  3600,
			NextRunAt:       &nextRunAt,
		}},
	}
	handler := ScheduleHandler{ListRepository: repository}
	request := httptest.NewRequest(http.MethodPost, "/admin/api/schedule/cancel", strings.NewReader(`{"taskScheduleID":"schedule-1","creatorPersonID":"person-2"}`))
	responseRecorder := httptest.NewRecorder()

	handler.HandleCancel(responseRecorder, request)

	if responseRecorder.Code != http.StatusForbidden {
		t.Fatalf("expected forbidden response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	if repository.cancelRequest.RequesterPersonID != "" {
		t.Fatalf("expected no cancel request, got %+v", repository.cancelRequest)
	}
}

func TestScheduleHandlerReturnsNotFoundForMissingCancelSchedule(t *testing.T) {
	handler := ScheduleHandler{ListRepository: &scheduleListRepositoryStub{}}
	request := httptest.NewRequest(http.MethodPost, "/admin/api/schedule/cancel", strings.NewReader(`{"taskScheduleID":"missing","creatorPersonID":"person-1"}`))
	responseRecorder := httptest.NewRecorder()

	handler.HandleCancel(responseRecorder, request)

	if responseRecorder.Code != http.StatusNotFound {
		t.Fatalf("expected not found response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
}

func TestScheduleHandlerUpdatesOwnedSchedule(t *testing.T) {
	nextRunAt := time.Now().UTC().Add(time.Hour)
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{{
			ScheduleID:       "schedule-1",
			CreatorPersonID:  "person-1",
			Name:             "Old name",
			ExecutionMode:    task.ScheduleExecutionModeAgent,
			AgentProfileName: "default",
			TimeZone:         "Asia/Seoul",
			Kind:             task.ScheduleKindInterval,
			IntervalSecond:   3600,
			NextRunAt:        &nextRunAt,
			CreatedAt:        nextRunAt.Add(-time.Hour),
			UpdatedAt:        nextRunAt.Add(-time.Hour),
		}},
	}
	handler := ScheduleHandler{ListRepository: repository}
	request := httptest.NewRequest(http.MethodPost, "/admin/api/schedule/update", strings.NewReader(`{"taskScheduleID":"schedule-1","creatorPersonID":"person-1","name":"New name","intervalSecond":7200,"repeatPolicy":"unbounded"}`))
	responseRecorder := httptest.NewRecorder()

	handler.HandleUpdate(responseRecorder, request)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("expected ok response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	var result task.ScheduleUpdateResult
	if errorValue := json.NewDecoder(responseRecorder.Body).Decode(&result); errorValue != nil {
		t.Fatal(errorValue)
	}
	if result.Schedule.Name != "New name" || result.Schedule.IntervalSecond != 7200 || result.Schedule.NextRunAt == nil {
		t.Fatalf("expected updated schedule, got %+v", result.Schedule)
	}
	if repository.updateRequest.RequesterPersonID != "person-1" {
		t.Fatalf("expected requester person, got %+v", repository.updateRequest)
	}
}

func TestScheduleHandlerRejectsUpdateCreatorMismatch(t *testing.T) {
	nextRunAt := time.Now().UTC().Add(time.Hour)
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{{
			ScheduleID:      "schedule-1",
			CreatorPersonID: "person-1",
			Kind:            task.ScheduleKindInterval,
			IntervalSecond:  3600,
			NextRunAt:       &nextRunAt,
		}},
	}
	handler := ScheduleHandler{ListRepository: repository}
	request := httptest.NewRequest(http.MethodPost, "/admin/api/schedule/update", strings.NewReader(`{"taskScheduleID":"schedule-1","creatorPersonID":"person-2","name":"New name"}`))
	responseRecorder := httptest.NewRecorder()

	handler.HandleUpdate(responseRecorder, request)

	if responseRecorder.Code != http.StatusForbidden {
		t.Fatalf("expected forbidden response, got %d: %s", responseRecorder.Code, responseRecorder.Body.String())
	}
	if repository.updateRequest.RequesterPersonID != "" {
		t.Fatalf("expected no update request, got %+v", repository.updateRequest)
	}
}

type scheduleSummaryRepositoryStub struct {
	summary task.ScheduleSummary
}

func (repository scheduleSummaryRepositoryStub) SummarizeActiveSchedules(time.Time) (task.ScheduleSummary, error) {
	return repository.summary, nil
}

type scheduleListRepositoryStub struct {
	request       task.ScheduleListRequest
	updateRequest task.ScheduleUpdateRequest
	deleteRequest task.ScheduleDeleteRequest
	cancelRequest task.ScheduleCancelRequest
	schedules     []task.Schedule

	upsertedSchedule task.Schedule
}

func (repository *scheduleListRepositoryStub) ListSchedules(request task.ScheduleListRequest) (task.ScheduleListResult, error) {
	repository.request = request
	return task.ScheduleListResult{Schedules: repository.schedules, TotalCount: len(repository.schedules), Page: request.Page, PageSize: request.PageSize}, nil
}

func (repository *scheduleListRepositoryStub) UpsertSchedule(schedule task.Schedule) error {
	repository.upsertedSchedule = schedule
	repository.schedules = append(repository.schedules, schedule)
	return nil
}

func (repository *scheduleListRepositoryStub) UpdateSchedule(request task.ScheduleUpdateRequest) (task.ScheduleUpdateResult, error) {
	repository.updateRequest = request
	for index, schedule := range repository.schedules {
		if schedule.ScheduleID != request.ScheduleID || schedule.CreatorPersonID != request.RequesterPersonID || schedule.NextRunAt == nil {
			continue
		}
		updatedSchedule := schedule
		if request.UpdateSchedule != nil {
			var errorValue error
			updatedSchedule, errorValue = request.UpdateSchedule(schedule)
			if errorValue != nil {
				return task.ScheduleUpdateResult{}, errorValue
			}
		}
		repository.schedules[index] = updatedSchedule
		return task.ScheduleUpdateResult{Schedule: updatedSchedule, IsFound: true}, nil
	}
	return task.ScheduleUpdateResult{}, nil
}

func (repository *scheduleListRepositoryStub) DeleteSchedule(request task.ScheduleDeleteRequest) (task.ScheduleDeleteResult, error) {
	repository.deleteRequest = request
	for index, schedule := range repository.schedules {
		if schedule.ScheduleID != request.ScheduleID || schedule.CreatorPersonID != request.RequesterPersonID {
			continue
		}
		repository.schedules = append(repository.schedules[:index], repository.schedules[index+1:]...)
		return task.ScheduleDeleteResult{Schedule: schedule, IsFound: true}, nil
	}
	return task.ScheduleDeleteResult{}, nil
}

func (repository *scheduleListRepositoryStub) CancelSchedules(request task.ScheduleCancelRequest) (task.ScheduleCancelResult, error) {
	repository.cancelRequest = request
	cancelledSchedules := []task.Schedule{}
	for index, schedule := range repository.schedules {
		if schedule.CreatorPersonID != request.RequesterPersonID || !containsScheduleID(request.ScheduleIDs, schedule.ScheduleID) {
			continue
		}
		schedule.NextRunAt = nil
		schedule.ExpiresAt = &request.CancelledAt
		repository.schedules[index] = schedule
		cancelledSchedules = append(cancelledSchedules, schedule)
	}
	return task.ScheduleCancelResult{Schedules: cancelledSchedules}, nil
}

func containsScheduleID(scheduleIDs []string, scheduleID string) bool {
	for _, candidateScheduleID := range scheduleIDs {
		if candidateScheduleID == scheduleID {
			return true
		}
	}
	return false
}

func scheduleWriteHandler(repository *scheduleListRepositoryStub, signedPersonID string) ScheduleHandler {
	return ScheduleHandler{
		ListRepository: repository,
		ReaderPersonID: func(*http.Request) string { return signedPersonID },
	}
}

func postScheduleTool(handler ScheduleHandler, path string, body string) *httptest.ResponseRecorder {
	responseRecorder := httptest.NewRecorder()
	request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
	switch path {
	case "/admin/api/schedule/tool-create":
		handler.HandleToolCreate(responseRecorder, request)
	case "/admin/api/schedule/tool-update":
		handler.HandleToolUpdate(responseRecorder, request)
	default:
		handler.HandleToolCancel(responseRecorder, request)
	}
	return responseRecorder
}

func ownScheduleFixture(scheduleID string, creatorPersonID string, description string) task.Schedule {
	nextRunAt := time.Now().UTC().Add(time.Hour)
	return task.Schedule{
		ScheduleID:       scheduleID,
		CreatorPersonID:  creatorPersonID,
		Name:             description,
		Prompt:           "brief " + description,
		ExecutionMode:    task.ScheduleExecutionModeAgent,
		AgentProfileName: "default",
		Platform:         "buzz",
		ConversationID:   "channel-1",
		ReplyTargetID:    "post-1",
		TimeZone:         "Asia/Seoul",
		Kind:             task.ScheduleKindInterval,
		IntervalSecond:   3600,
		NextRunAt:        &nextRunAt,
	}
}

func TestScheduleToolWritesFailClosedWithoutSignedPrincipal(t *testing.T) {
	repository := &scheduleListRepositoryStub{}
	for _, handler := range []ScheduleHandler{
		{ListRepository: repository},
		scheduleWriteHandler(repository, ""),
	} {
		for path, body := range map[string]string{
			"/admin/api/schedule/tool-create": `{"taskInstruction":"brief me","kind":"once","platform":"buzz","conversationID":"channel-1","replyTargetID":"post-1"}`,
			"/admin/api/schedule/tool-update": `{"scheduleHint":"schedule-1","description":"새 이름"}`,
			"/admin/api/schedule/tool-cancel": `{"scheduleHints":["schedule-1"]}`,
		} {
			responseRecorder := postScheduleTool(handler, path, body)
			if responseRecorder.Code != http.StatusForbidden {
				t.Fatalf("%s unsigned status = %d, want %d", path, responseRecorder.Code, http.StatusForbidden)
			}
		}
	}
	if repository.upsertedSchedule.ScheduleID != "" || repository.cancelRequest.RequesterPersonID != "" {
		t.Fatalf("an unsigned request reached the repository: %+v", repository)
	}
}

func TestScheduleToolWritesRejectBodySuppliedCreator(t *testing.T) {
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{ownScheduleFixture("schedule-1", "person-이샘플", "주간 보고")},
	}
	handler := scheduleWriteHandler(repository, "person-이샘플")
	for path, body := range map[string]string{
		"/admin/api/schedule/tool-create": `{"taskInstruction":"brief me","kind":"once","runAt":"2099-01-01T00:00:00Z","platform":"buzz","conversationID":"channel-1","replyTargetID":"post-1","creatorPersonID":"person-박예시"}`,
		"/admin/api/schedule/tool-update": `{"scheduleHint":"schedule-1","description":"새 이름","creatorPersonID":"person-박예시"}`,
		"/admin/api/schedule/tool-cancel": `{"scheduleHints":["schedule-1"],"creatorPersonID":"person-박예시"}`,
	} {
		responseRecorder := postScheduleTool(handler, path, body)
		if responseRecorder.Code != http.StatusBadRequest {
			t.Fatalf("%s body creator status = %d, want %d: %s", path, responseRecorder.Code, http.StatusBadRequest, responseRecorder.Body.String())
		}
	}
	if repository.upsertedSchedule.ScheduleID != "" || repository.cancelRequest.RequesterPersonID != "" {
		t.Fatalf("a body-supplied creator reached the repository: %+v", repository)
	}
}

func TestScheduleToolCreateRefusesWithoutDeliveryBinding(t *testing.T) {
	repository := &scheduleListRepositoryStub{}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-create",
		`{"taskInstruction":"brief me","kind":"once","runAt":"2099-01-01T00:00:00Z"}`)

	if responseRecorder.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusBadRequest, responseRecorder.Body.String())
	}
	if !strings.Contains(responseRecorder.Body.String(), task.ErrScheduleConversationRequired.Error()) {
		t.Fatalf("expected the delivery refusal reason, got %s", responseRecorder.Body.String())
	}
	if repository.upsertedSchedule.ScheduleID != "" {
		t.Fatalf("a schedule without delivery binding was stored: %+v", repository.upsertedSchedule)
	}
}

func TestScheduleToolCreateRefusesScheduleWithNoFutureRun(t *testing.T) {
	repository := &scheduleListRepositoryStub{}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-create",
		`{"taskInstruction":"brief me","kind":"once","runAt":"2020-01-01T00:00:00Z","platform":"buzz","conversationID":"channel-1","replyTargetID":"post-1"}`)

	if responseRecorder.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusBadRequest, responseRecorder.Body.String())
	}
	if !strings.Contains(responseRecorder.Body.String(), task.ErrScheduleNoFutureRun.Error()) {
		t.Fatalf("expected the no-future-run refusal, got %s", responseRecorder.Body.String())
	}
	if repository.upsertedSchedule.ScheduleID != "" {
		t.Fatalf("a schedule with no future run was stored: %+v", repository.upsertedSchedule)
	}
}

func TestScheduleToolCreateStoresTheSignedPrincipalsSchedule(t *testing.T) {
	repository := &scheduleListRepositoryStub{}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-create",
		`{"taskInstruction":"주간 보고를 정리한다","description":"주간 보고","kind":"cron","cronExpression":"0 9 * * 1","repeatPolicy":"unbounded","timeZone":"Asia/Seoul","platform":"buzz","conversationID":"channel-1","replyTargetID":"post-1"}`)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusOK, responseRecorder.Body.String())
	}
	var mutation task.ScheduleMutationResult
	if errorValue := json.NewDecoder(responseRecorder.Body).Decode(&mutation); errorValue != nil {
		t.Fatal(errorValue)
	}
	if mutation.Description != "주간 보고" || mutation.TaskInstruction != "주간 보고를 정리한다" || mutation.NextRunAt == nil {
		t.Fatalf("unexpected mutation projection: %+v", mutation)
	}
	if mutation.AgentProfileName != "default" || mutation.ConversationID != "channel-1" || mutation.ReplyTargetID != "post-1" {
		t.Fatalf("unexpected delivery projection: %+v", mutation)
	}
	if repository.upsertedSchedule.CreatorPersonID != "person-이샘플" {
		t.Fatalf("the stored creator escaped the signed principal: %+v", repository.upsertedSchedule)
	}
	if repository.upsertedSchedule.IsApproved() {
		t.Fatalf("a schedule whose creation nobody said was approved is stored approved: %+v", repository.upsertedSchedule)
	}
}

func TestScheduleToolCreateRecordsTheApprovalItsCallCarries(t *testing.T) {
	repository := &scheduleListRepositoryStub{}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-create",
		`{"taskInstruction":"주간 보고를 팀 채널에 올린다","kind":"cron","cronExpression":"0 17 * * 5","repeatPolicy":"unbounded","platform":"buzz","conversationID":"channel-1","replyTargetID":"post-1","isApproved":true}`)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusOK, responseRecorder.Body.String())
	}
	if !repository.upsertedSchedule.IsApproved() {
		t.Fatalf("an approved creation stored a schedule without its approval: %+v", repository.upsertedSchedule)
	}
}

func TestScheduleToolUpdateResolvesDescriptionHintAndRewritesTaskInstruction(t *testing.T) {
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{ownScheduleFixture("schedule-1", "person-이샘플", "주간 보고")},
	}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-update",
		`{"scheduleHint":"주간","taskInstruction":"주간 보고와 지표를 함께 정리한다","intervalSecond":7200,"repeatPolicy":"unbounded"}`)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusOK, responseRecorder.Body.String())
	}
	var mutation task.ScheduleMutationResult
	if errorValue := json.NewDecoder(responseRecorder.Body).Decode(&mutation); errorValue != nil {
		t.Fatal(errorValue)
	}
	if mutation.ScheduleID != "schedule-1" || mutation.TaskInstruction != "주간 보고와 지표를 함께 정리한다" || mutation.IntervalSecond != 7200 {
		t.Fatalf("unexpected mutation projection: %+v", mutation)
	}
	if repository.updateRequest.RequesterPersonID != "person-이샘플" {
		t.Fatalf("the update escaped the signed principal: %+v", repository.updateRequest)
	}
}

func TestScheduleToolUpdateAnswersAmbiguousHintWithCandidatesAndMutatesNothing(t *testing.T) {
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{
			ownScheduleFixture("schedule-1", "person-이샘플", "주간 보고 월요일"),
			ownScheduleFixture("schedule-2", "person-이샘플", "주간 보고 금요일"),
		},
	}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-update",
		`{"scheduleHint":"주간 보고","description":"새 이름"}`)

	if responseRecorder.Code != http.StatusConflict {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusConflict, responseRecorder.Body.String())
	}
	var conflict scheduleHintConflict
	if errorValue := json.NewDecoder(responseRecorder.Body).Decode(&conflict); errorValue != nil {
		t.Fatal(errorValue)
	}
	if conflict.Hint != "주간 보고" || len(conflict.Candidates) != 2 || conflict.Error == "" {
		t.Fatalf("unexpected conflict body: %+v", conflict)
	}
	if repository.updateRequest.RequesterPersonID != "" {
		t.Fatalf("an ambiguous hint reached the repository: %+v", repository.updateRequest)
	}
}

func TestScheduleToolCancelCancelsNothingWhenOneHintIsAmbiguous(t *testing.T) {
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{
			ownScheduleFixture("schedule-1", "person-이샘플", "일일 점검"),
			ownScheduleFixture("schedule-2", "person-이샘플", "주간 보고 월요일"),
			ownScheduleFixture("schedule-3", "person-이샘플", "주간 보고 금요일"),
		},
	}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-cancel",
		`{"scheduleHints":["일일 점검","주간 보고"]}`)

	if responseRecorder.Code != http.StatusConflict {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusConflict, responseRecorder.Body.String())
	}
	var conflict scheduleHintConflict
	if errorValue := json.NewDecoder(responseRecorder.Body).Decode(&conflict); errorValue != nil {
		t.Fatal(errorValue)
	}
	if conflict.Hint != "주간 보고" || len(conflict.Candidates) != 2 {
		t.Fatalf("unexpected conflict body: %+v", conflict)
	}
	if repository.cancelRequest.RequesterPersonID != "" {
		t.Fatalf("a partly unresolved cancel reached the repository: %+v", repository.cancelRequest)
	}
}

func TestScheduleToolCancelCancelsEveryResolvedHint(t *testing.T) {
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{
			ownScheduleFixture("schedule-1", "person-이샘플", "일일 점검"),
			ownScheduleFixture("schedule-2", "person-이샘플", "주간 보고"),
		},
	}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-cancel",
		`{"scheduleHints":["일일 점검","schedule-2"]}`)

	if responseRecorder.Code != http.StatusOK {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusOK, responseRecorder.Body.String())
	}
	var result scheduleToolCancelResult
	if errorValue := json.NewDecoder(responseRecorder.Body).Decode(&result); errorValue != nil {
		t.Fatal(errorValue)
	}
	if len(result.Cancelled) != 2 || result.Cancelled[0].ScheduleID != "schedule-1" || result.Cancelled[1].Description != "주간 보고" {
		t.Fatalf("unexpected cancel result: %+v", result)
	}
}

func TestScheduleToolWritesNeverReachAColleaguesSchedule(t *testing.T) {
	repository := &scheduleListRepositoryStub{
		schedules: []task.Schedule{
			ownScheduleFixture("schedule-colleague", "person-박예시", "최견본 주간 보고"),
			ownScheduleFixture("schedule-own", "person-이샘플", "내 일일 점검"),
		},
	}
	handler := scheduleWriteHandler(repository, "person-이샘플")

	for path, body := range map[string]string{
		"/admin/api/schedule/tool-update": `{"scheduleHint":"schedule-colleague","description":"새 이름"}`,
		"/admin/api/schedule/tool-cancel": `{"scheduleHints":["schedule-colleague"]}`,
	} {
		responseRecorder := postScheduleTool(handler, path, body)
		if responseRecorder.Code != http.StatusConflict {
			t.Fatalf("%s status = %d, want %d: %s", path, responseRecorder.Code, http.StatusConflict, responseRecorder.Body.String())
		}
		var conflict scheduleHintConflict
		if errorValue := json.NewDecoder(responseRecorder.Body).Decode(&conflict); errorValue != nil {
			t.Fatal(errorValue)
		}
		if len(conflict.Candidates) != 0 {
			t.Fatalf("%s offered a colleague's schedule as a candidate: %+v", path, conflict.Candidates)
		}
	}

	responseRecorder := postScheduleTool(handler, "/admin/api/schedule/tool-update", `{"scheduleHint":"주간 보고","description":"새 이름"}`)
	if responseRecorder.Code != http.StatusConflict {
		t.Fatalf("status = %d, want %d: %s", responseRecorder.Code, http.StatusConflict, responseRecorder.Body.String())
	}
	if repository.updateRequest.RequesterPersonID != "" || repository.cancelRequest.RequesterPersonID != "" {
		t.Fatalf("a colleague's schedule reached the repository: %+v", repository)
	}
}

func TestScheduleToolResponsesMatchTheirDeclaredOutputSchemas(t *testing.T) {
	for _, responseCase := range []struct {
		path   string
		body   string
		schema json.RawMessage
	}{
		{
			path:   "/admin/api/schedule/tool-create",
			body:   `{"taskInstruction":"주간 보고를 정리한다","description":"주간 보고","kind":"cron","cronExpression":"0 9 * * 1","repeatPolicy":"unbounded","timeZone":"Asia/Seoul","platform":"buzz","conversationID":"channel-1","replyTargetID":"post-1"}`,
			schema: scheduleToolMutationOutputSchema,
		},
		{
			path:   "/admin/api/schedule/tool-update",
			body:   `{"scheduleHint":"주간 보고","intervalSecond":7200,"repeatPolicy":"unbounded"}`,
			schema: scheduleToolMutationOutputSchema,
		},
		{
			path:   "/admin/api/schedule/tool-cancel",
			body:   `{"scheduleHints":["주간 보고"]}`,
			schema: scheduleToolCancelOutputSchema,
		},
	} {
		repository := &scheduleListRepositoryStub{
			schedules: []task.Schedule{ownScheduleFixture("schedule-1", "person-이샘플", "주간 보고")},
		}
		responseRecorder := postScheduleTool(scheduleWriteHandler(repository, "person-이샘플"), responseCase.path, responseCase.body)
		if responseRecorder.Code != http.StatusOK {
			t.Fatalf("%s status = %d, want %d: %s", responseCase.path, responseRecorder.Code, http.StatusOK, responseRecorder.Body.String())
		}
		if errorValue := validateAgainstScheduleSchema(responseCase.schema, responseRecorder.Body.Bytes()); errorValue != nil {
			t.Fatalf("%s answered a body its declared output schema rejects: %v", responseCase.path, errorValue)
		}
	}
}