From 2ead84ca335dc2040a762720be005127e3bc1dc1 Mon Sep 17 00:00:00 2001 From: moha Date: Fri, 10 Apr 2026 18:28:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A5=E5=8A=9B=E5=80=A4=E3=81=AE?= =?UTF-8?q?=E6=96=87=E5=AD=97=E6=95=B0=E5=88=B6=E9=99=90=E3=83=90=E3=83=AA?= =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タイトル(100文字)・説明(500文字)・アイテム名(100文字)・担当者名(50文字)に 最大文字数制限を追加し、超過分を自動的に切り詰めるようにした。 マルチバイト文字(日本語)対応のルーン単位での切り詰め処理を実装。 対応するユニットテスト7件を追加。 --- handler.go | 25 ++++++++-- handler_test.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/handler.go b/handler.go index 9b638ab..1988c3d 100644 --- a/handler.go +++ b/handler.go @@ -89,6 +89,23 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { } } +// 入力値の最大文字数制限。 +const ( + maxTitleLen = 100 + maxDescriptionLen = 500 + maxItemNameLen = 100 + maxAssigneeLen = 50 +) + +// truncateRunes は文字列を最大 n ルーン以内に切り詰める。 +func truncateRunes(s string, n int) string { + runes := []rune(s) + if len(runes) > n { + return string(runes[:n]) + } + return s +} + func handleCreateList(store *Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { title := strings.TrimSpace(r.FormValue("title")) @@ -96,7 +113,8 @@ func handleCreateList(store *Store) http.HandlerFunc { http.Error(w, "タイトルは必須です", http.StatusBadRequest) return } - desc := strings.TrimSpace(r.FormValue("description")) + title = truncateRunes(title, maxTitleLen) + desc := truncateRunes(strings.TrimSpace(r.FormValue("description")), maxDescriptionLen) l := store.CreateList(title, desc) http.Redirect(w, r, "/lists/"+l.ShareToken, http.StatusSeeOther) } @@ -129,7 +147,8 @@ func handleAddItem(store *Store) http.HandlerFunc { http.Redirect(w, r, "/lists/"+token, http.StatusSeeOther) return } - assignee := strings.TrimSpace(r.FormValue("assignee")) + name = truncateRunes(name, maxItemNameLen) + assignee := truncateRunes(strings.TrimSpace(r.FormValue("assignee")), maxAssigneeLen) required := r.FormValue("required") == "on" store.AddItem(token, name, assignee, required) http.Redirect(w, r, "/lists/"+token, http.StatusSeeOther) @@ -158,7 +177,7 @@ func handleUpdateAssignee(store *Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") id := r.PathValue("id") - assignee := strings.TrimSpace(r.FormValue("assignee")) + assignee := truncateRunes(strings.TrimSpace(r.FormValue("assignee")), maxAssigneeLen) store.UpdateAssignee(token, id, assignee) http.Redirect(w, r, "/lists/"+token, http.StatusSeeOther) } diff --git a/handler_test.go b/handler_test.go index dabde8f..b6a069a 100644 --- a/handler_test.go +++ b/handler_test.go @@ -373,3 +373,131 @@ func TestResponseWriterStatusCode(t *testing.T) { t.Fatalf("expected statusCode=404, got %d", rw.statusCode) } } + +func TestTruncateRunes(t *testing.T) { + tests := []struct { + input string + maxLen int + expected string + }{ + {"hello", 10, "hello"}, + {"hello", 3, "hel"}, + {"あいうえお", 3, "あいう"}, + {"", 5, ""}, + {"abc", 0, ""}, + {"テスト文字列", 6, "テスト文字列"}, + {"テスト文字列", 4, "テスト文"}, + } + for _, tt := range tests { + result := truncateRunes(tt.input, tt.maxLen) + if result != tt.expected { + t.Errorf("truncateRunes(%q, %d) = %q, want %q", tt.input, tt.maxLen, result, tt.expected) + } + } +} + +func TestCreateListTitleTruncation(t *testing.T) { + mux, store := setupTestServer() + + // 101文字のタイトル(最大100文字に切り詰められる) + longTitle := strings.Repeat("あ", 101) + form := url.Values{"title": {longTitle}} + req := httptest.NewRequest("POST", "/lists", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d", w.Code) + } + + loc := w.Header().Get("Location") + token := strings.TrimPrefix(loc, "/lists/") + l := store.GetList(token) + if l == nil { + t.Fatal("expected list to be created") + } + titleRunes := []rune(l.Title) + if len(titleRunes) != maxTitleLen { + t.Fatalf("expected title to be truncated to %d runes, got %d", maxTitleLen, len(titleRunes)) + } +} + +func TestCreateListDescriptionTruncation(t *testing.T) { + mux, store := setupTestServer() + + longDesc := strings.Repeat("x", 501) + form := url.Values{"title": {"テスト"}, "description": {longDesc}} + req := httptest.NewRequest("POST", "/lists", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + loc := w.Header().Get("Location") + token := strings.TrimPrefix(loc, "/lists/") + l := store.GetList(token) + if len(l.Description) != maxDescriptionLen { + t.Fatalf("expected description to be truncated to %d chars, got %d", maxDescriptionLen, len(l.Description)) + } +} + +func TestAddItemNameTruncation(t *testing.T) { + mux, store := setupTestServer() + l := store.CreateList("テスト", "") + token := l.ShareToken + + longName := strings.Repeat("い", 150) + form := url.Values{"name": {longName}, "assignee": {"太郎"}} + req := httptest.NewRequest("POST", "/lists/"+token+"/items", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + items := store.GetList(token).Items + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + nameRunes := []rune(items[0].Name) + if len(nameRunes) != maxItemNameLen { + t.Fatalf("expected item name to be truncated to %d runes, got %d", maxItemNameLen, len(nameRunes)) + } +} + +func TestAddItemAssigneeTruncation(t *testing.T) { + mux, store := setupTestServer() + l := store.CreateList("テスト", "") + token := l.ShareToken + + longAssignee := strings.Repeat("う", 80) + form := url.Values{"name": {"アイテム"}, "assignee": {longAssignee}} + req := httptest.NewRequest("POST", "/lists/"+token+"/items", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + items := store.GetList(token).Items + assigneeRunes := []rune(items[0].Assignee) + if len(assigneeRunes) != maxAssigneeLen { + t.Fatalf("expected assignee to be truncated to %d runes, got %d", maxAssigneeLen, len(assigneeRunes)) + } +} + +func TestUpdateAssigneeTruncation(t *testing.T) { + mux, store := setupTestServer() + l := store.CreateList("テスト", "") + store.AddItem(l.ShareToken, "アイテム", "太郎", true) + + item := store.GetList(l.ShareToken).Items[0] + longAssignee := strings.Repeat("え", 60) + form := url.Values{"assignee": {longAssignee}} + req := httptest.NewRequest("POST", "/lists/"+l.ShareToken+"/items/"+item.ID+"/assignee", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + updated := store.GetList(l.ShareToken).Items[0] + assigneeRunes := []rune(updated.Assignee) + if len(assigneeRunes) != maxAssigneeLen { + t.Fatalf("expected assignee to be truncated to %d runes, got %d", maxAssigneeLen, len(assigneeRunes)) + } +}