/
codespawn
/
tgstream
Обзор
Документация
Войти
/
codespawn
/
tgstream
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
16
CI/CD
Аналитика
Безопасность
master
internal/adapter/httpserver/handler_test.go
528 строк
19 KB
codespawn
fix: empty feed returns StatusEmpty and stable ETag/304
20 июл 2026, 09:17
20 июл 2026, 09:17
2dc086e
Код
Авторство
О чём код?
package httpserver import ( "context" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "gitverse.ru/codespawn/tgstream/internal/domain" "gitverse.ru/codespawn/tgstream/internal/port" "gitverse.ru/codespawn/tgstream/internal/service" ) // ----- фейки ----- // fakeFeed — фейк FeedFetcher для тестов HTTP-слоя (L4). type fakeFeed struct { posts []domain.Post ch domain.Channel status service.Status err error calls int last string } func (f *fakeFeed) Feed(_ context.Context, channel string) ([]domain.Post, domain.Channel, service.Status, error) { f.calls++ f.last = channel return f.posts, f.ch, f.status, f.err } // stubFormatter — детерминированный Formatter: тело = маркер, чтобы тесты не // зависели от RSS-разметки, а проверяли HTTP-поведение. type stubFormatter struct { body string ct string } func (s stubFormatter) Format(_ domain.Channel, _ []domain.Post) ([]byte, error) { return []byte(s.body), nil } func (s stubFormatter) ContentType() string { return s.ct } var _ port.Formatter = stubFormatter{} // rssFmt — форматер, дающий стабильное тело, с rss content-type. func rssFmt(body string) NamedFormatter { return NamedFormatter{Key: "rss", Formatter: stubFormatter{body: body, ct: "application/rss+xml"}} } // newHandler — собирает Handler с одним RSS-форматером и тестовым фейком. func newHandler(t *testing.T, feed *fakeFeed) *Handler { t.Helper() return New(Config{ Feed: feed, Formatters: []NamedFormatter{rssFmt("<rss/>")}, CacheTTL: 300 * time.Second, RetryAfter: 60 * time.Second, }) } // samplePosts — два поста с фиксированным временем (для Last-Modified). func samplePosts() []domain.Post { return []domain.Post{ {ID: "1", Channel: "durov", Title: "first", Text: "first", HTML: "first", PublishedAt: mustParse("2026-06-25T17:28:00Z"), URL: "https://t.me/durov/1"}, {ID: "2", Channel: "durov", Title: "second", Text: "second", HTML: "second", PublishedAt: mustParse("2026-06-23T08:00:00Z"), URL: "https://t.me/durov/2"}, } } func mustParse(s string) time.Time { t, err := time.Parse(time.RFC3339, s) if err != nil { panic(err) } return t.UTC() } // do выполняет запрос к Handler и возвращает запись ответа. func do(h http.Handler, method, target string, headers ...string) *httptest.ResponseRecorder { req := httptest.NewRequest(method, target, nil) for i := 0; i+1 < len(headers); i += 2 { req.Header.Set(headers[i], headers[i+1]) } rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } func body(rr *httptest.ResponseRecorder) string { b, _ := io.ReadAll(rr.Body) return string(b) } // ----- тесты ----- // TestHealthz — GET /healthz → 200 {"status":"ok"} (Этап 7.1). func TestHealthz(t *testing.T) { h := newHandler(t, &fakeFeed{}) rr := do(h, "GET", "/healthz", "If-None-Match", "x") if rr.Code != http.StatusOK { t.Fatalf("code = %d, want %d", rr.Code, http.StatusOK) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { t.Errorf("Content-Type = %q, want application/json", ct) } if body(rr) != `{"status":"ok"}` { t.Errorf("body = %q, want {\"status\":\"ok\"}", body(rr)) } // healthz не должен дёргать feed. } // TestFeed_Routing_NotFound — неизвестный путь → 404 ServeMux (Этап 7.1). func TestFeed_Routing_NotFound(t *testing.T) { h := newHandler(t, &fakeFeed{}) for _, p := range []string{"/", "/feed/", "/nope", "/feed/durov/extra"} { rr := do(h, "GET", p) if rr.Code != http.StatusNotFound { t.Errorf("GET %q: code = %d, want 404", p, rr.Code) } } } // TestFeed_MethodNotAllowed — не-GET → 405 (ServeMux) (Этап 7.1). func TestFeed_MethodNotAllowed(t *testing.T) { h := newHandler(t, &fakeFeed{}) rr := do(h, "POST", "/feed/durov") if rr.Code != http.StatusMethodNotAllowed { t.Errorf("POST code = %d, want 405", rr.Code) } } // TestFeed_InvalidName — имя не проходит валидацию → 400 (Этап 7.2). func TestFeed_InvalidName(t *testing.T) { feed := &fakeFeed{} h := newHandler(t, feed) // '@', точка, кириллица — вне разрешённого набора [a-zA-Z0-9_-]. for _, name := range []string{"@durov", "du.rov", "Дуров"} { rr := do(h, "GET", "/feed/"+name) if rr.Code != http.StatusBadRequest { t.Errorf("GET /feed/%s: code = %d, want 400", name, rr.Code) } } if feed.calls != 0 { t.Errorf("Feed was called %d times on invalid name, want 0", feed.calls) } } // TestFeed_TrailingSpaceRedirect — normalizeName теперь только trimit; ввод с // trailing-пробелами редиректится на каноничный (без пробелов) путь. Раньше // нормализация включала @-stripping + lowercase (Telegram-специфика); теперь // это убрано — mixed-case id сохраняются. func TestFeed_TrailingSpaceRedirect(t *testing.T) { feed := &fakeFeed{} h := newHandler(t, feed) rr := do(h, "GET", "/feed/%20hacker-news%20") if rr.Code != http.StatusMovedPermanently { t.Fatalf("code = %d, want 301 (trailing-space redirect)", rr.Code) } if loc := rr.Header().Get("Location"); loc != "/feed/hacker-news" { t.Errorf("Location = %q, want /feed/hacker-news", loc) } if feed.calls != 0 { t.Errorf("Feed called on redirect, want 0; last=%q", feed.last) } // Редирект сохраняет query и суффикс. rr = do(h, "GET", "/feed/%20hacker-news%20.rss?foo=bar") if loc := rr.Header().Get("Location"); loc != "/feed/hacker-news.rss?foo=bar" { t.Errorf("Location = %q, want /feed/hacker-news.rss?foo=bar", loc) } } // TestFeed_FreshHit — успех → 200 + RSS + заголовки (Этап 7.5). func TestFeed_FreshHit(t *testing.T) { feed := &fakeFeed{ posts: samplePosts(), ch: domain.Channel{Name: "durov", Title: "Durov"}, status: service.StatusFresh, } h := newHandler(t, feed) rr := do(h, "GET", "/feed/durov") if rr.Code != http.StatusOK { t.Fatalf("code = %d, want 200", rr.Code) } if body(rr) != "<rss/>" { t.Errorf("body = %q, want <rss/>", body(rr)) } if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/rss+xml") { t.Errorf("Content-Type = %q", ct) } if cc := rr.Header().Get("Cache-Control"); cc != "public, max-age=300" { t.Errorf("Cache-Control = %q, want public, max-age=300", cc) } if xs := rr.Header().Get("X-Channel-Status"); xs != "fresh" { t.Errorf("X-Channel-Status = %q, want fresh", xs) } if etag := rr.Header().Get("ETag"); etag == "" || !strings.HasPrefix(etag, `"`) { t.Errorf("ETag = %q, want non-empty quoted", etag) } if lm := rr.Header().Get("Last-Modified"); lm == "" { t.Errorf("Last-Modified empty, want a date") } if feed.last != "durov" { t.Errorf("Feed called with %q, want durov", feed.last) } } // TestFeed_StatusStale — SWR: 200 + X-Channel-Status: stale (Этап 7.4). func TestFeed_StatusStale(t *testing.T) { feed := &fakeFeed{ posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusStale, } h := newHandler(t, feed) rr := do(h, "GET", "/feed/durov") if rr.Code != http.StatusOK { t.Fatalf("code = %d, want 200 (stale served, not error)", rr.Code) } if xs := rr.Header().Get("X-Channel-Status"); xs != "stale" { t.Errorf("X-Channel-Status = %q, want stale", xs) } } // TestFeed_StatusEmpty — канал без постов → 200 + X-Channel-Status: empty (Этап 7.4). func TestFeed_StatusEmpty(t *testing.T) { feed := &fakeFeed{ch: domain.Channel{Name: "emptychannel"}, status: service.StatusEmpty} h := newHandler(t, feed) rr := do(h, "GET", "/feed/emptychannel") if rr.Code != http.StatusOK { t.Fatalf("code = %d, want 200", rr.Code) } if xs := rr.Header().Get("X-Channel-Status"); xs != "empty" { t.Errorf("X-Channel-Status = %q, want empty", xs) } } // TestFeed_EmptyFeed_ETag304 — пустой фид получает стабильный ETag и отвечает // 304 на If-None-Match (после feed_service.go + rss/etag-фиксов). func TestFeed_EmptyFeed_ETag304(t *testing.T) { feed := &fakeFeed{ posts: []domain.Post{}, ch: domain.Channel{Name: "emptychannel", Title: "Empty"}, status: service.StatusEmpty, } h := newHandler(t, feed) rr := do(h, "GET", "/feed/emptychannel") if rr.Code != http.StatusOK { t.Fatalf("first: code = %d, want 200", rr.Code) } etag := rr.Header().Get("ETag") if etag == "" { t.Fatal("no ETag on empty feed") } rr2 := do(h, "GET", "/feed/emptychannel", "If-None-Match", etag) if rr2.Code != http.StatusNotModified { t.Fatalf("second: code = %d, want 304", rr2.Code) } if body(rr2) != "" { t.Errorf("304 body = %q, want empty", body(rr2)) } } // TestFeed_ChannelNotFound → 404 (Этап 7.4). func TestFeed_ChannelNotFound(t *testing.T) { feed := &fakeFeed{err: domain.ErrChannelNotFound} h := newHandler(t, feed) rr := do(h, "GET", "/feed/nosuchchannel") if rr.Code != http.StatusNotFound { t.Errorf("code = %d, want 404", rr.Code) } if rr.Header().Get("Retry-After") != "" { t.Errorf("404 must not have Retry-After") } } // TestFeed_SourceUnavailable → 503 + Retry-After (Этап 7.4). func TestFeed_SourceUnavailable(t *testing.T) { feed := &fakeFeed{err: errors.Join(domain.ErrSourceUnavailable, errors.New("timeout"))} h := newHandler(t, feed) rr := do(h, "GET", "/feed/durov") if rr.Code != http.StatusServiceUnavailable { t.Errorf("code = %d, want 503", rr.Code) } if ra := rr.Header().Get("Retry-After"); ra != "60" { t.Errorf("Retry-After = %q, want 60", ra) } } // TestFeed_ETag304 — If-None-Match совпал → 304, пустое тело (Этап 7.3). func TestFeed_ETag304(t *testing.T) { feed := &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh} h := newHandler(t, feed) // Первый запрос — получаем ETag. rr := do(h, "GET", "/feed/durov") etag := rr.Header().Get("ETag") if etag == "" { t.Fatal("no ETag on first response") } // Второй с If-None-Match → 304. rr2 := do(h, "GET", "/feed/durov", "If-None-Match", etag) if rr2.Code != http.StatusNotModified { t.Fatalf("code = %d, want 304", rr2.Code) } if body(rr2) != "" { t.Errorf("304 body = %q, want empty", body(rr2)) } // Заголовки кэширования сохранены на 304. for _, hkey := range []string{"ETag", "Cache-Control", "X-Channel-Status"} { if rr2.Header().Get(hkey) == "" { t.Errorf("304 missing %s", hkey) } } } // TestFeed_ETagMismatch — If-None-Match не совпал → 200 (Этап 7.3). func TestFeed_ETagMismatch(t *testing.T) { feed := &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh} h := newHandler(t, feed) rr := do(h, "GET", "/feed/durov", "If-None-Match", `"deadbeef"`) if rr.Code != http.StatusOK { t.Errorf("code = %d, want 200 (etag mismatch)", rr.Code) } } // TestFeed_IfModifiedSince304 — Last-Modified <= If-Modified-Since → 304 (Этап 7.3). func TestFeed_IfModifiedSince304(t *testing.T) { last := mustParse("2026-06-25T17:28:00Z") feed := &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh} h := newHandler(t, feed) // If-Modified-Since в будущем относительно last → 304. since := last.Add(time.Hour).UTC().Format(http.TimeFormat) rr := do(h, "GET", "/feed/durov", "If-Modified-Since", since) if rr.Code != http.StatusNotModified { t.Errorf("code = %d, want 304", rr.Code) } // If-Modified-Since в прошлом → 200. past := last.Add(-24 * time.Hour).UTC().Format(http.TimeFormat) rr2 := do(h, "GET", "/feed/durov", "If-Modified-Since", past) if rr2.Code != http.StatusOK { t.Errorf("code = %d, want 200", rr2.Code) } } // TestFeed_IfNoneMatchPrecedence — при If-None-Match проигрывается только он // (If-Modified-Since игнорируется, RFC 7232) (Этап 7.3). func TestFeed_IfNoneMatchPrecedence(t *testing.T) { feed := &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh} h := newHandler(t, feed) // ETag не совпал, но If-Modified-Since в будущем → всё равно 200 (INM приоритетен). rr := do(h, "GET", "/feed/durov", "If-None-Match", `"nomatch"`, "If-Modified-Since", "Wed, 31 Dec 2099 00:00:00 GMT") if rr.Code != http.StatusOK { t.Errorf("code = %d, want 200 (If-None-Match precedence, mismatch)", rr.Code) } } // TestETag_Stable — одинаковый вход → одинаковый ETag (детерминизм ETag). func TestETag_Stable(t *testing.T) { feed := &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh} h := newHandler(t, feed) e1 := do(h, "GET", "/feed/durov").Header().Get("ETag") e2 := do(h, "GET", "/feed/durov").Header().Get("ETag") if e1 == "" || e1 != e2 { t.Errorf("ETag not stable: %q vs %q", e1, e2) } } // TestContentNegotiation_FormatParam — ?format= выбирает форматер (Этап 7.6). func TestContentNegotiation_FormatParam(t *testing.T) { h := New(Config{ Feed: &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh}, Formatters: []NamedFormatter{ rssFmt("<rss/>"), {Key: "atom", Formatter: stubFormatter{body: "<atom/>", ct: "application/atom+xml"}}, }, CacheTTL: 300 * time.Second, }) // ?format=atom → atom. rr := do(h, "GET", "/feed/durov?format=atom") if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/atom+xml") { t.Errorf("?format=atom Content-Type = %q", ct) } if body(rr) != "<atom/>" { t.Errorf("?format=atom body = %q", body(rr)) } // ?format=RSS (разный регистр) → rss. rr = do(h, "GET", "/feed/durov?format=RSS") if !strings.HasPrefix(rr.Header().Get("Content-Type"), "application/rss+xml") { t.Errorf("?format=RSS Content-Type = %q", rr.Header().Get("Content-Type")) } // Неизвестный ?format → дефолт (rss). rr = do(h, "GET", "/feed/duarov?format=json") if rr.Code != http.StatusOK || !strings.HasPrefix(rr.Header().Get("Content-Type"), "application/rss+xml") { t.Errorf("unknown ?format should fall back to default rss; code=%d ct=%q", rr.Code, rr.Header().Get("Content-Type")) } } // TestContentNegotiation_Suffix — суффикс пути выбирает форматер (Этап 7.6). func TestContentNegotiation_Suffix(t *testing.T) { h := New(Config{ Feed: &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh}, Formatters: []NamedFormatter{ rssFmt("<rss/>"), {Key: "atom", Formatter: stubFormatter{body: "<atom/>", ct: "application/atom+xml"}}, }, }) rr := do(h, "GET", "/feed/durov.atom") if !strings.HasPrefix(rr.Header().Get("Content-Type"), "application/atom+xml") { t.Errorf(".atom Content-Type = %q", rr.Header().Get("Content-Type")) } // .rss → rss (явный). rr = do(h, "GET", "/feed/durov.rss") if !strings.HasPrefix(rr.Header().Get("Content-Type"), "application/rss+xml") { t.Errorf(".rss Content-Type = %q", rr.Header().Get("Content-Type")) } } // TestContentNegotiation_Accept — Accept-заголовок выбирает форматер (Этап 7.6). func TestContentNegotiation_Accept(t *testing.T) { h := New(Config{ Feed: &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh}, Formatters: []NamedFormatter{ rssFmt("<rss/>"), {Key: "atom", Formatter: stubFormatter{body: "<atom/>", ct: "application/atom+xml"}}, }, }) rr := do(h, "GET", "/feed/durov", "Accept", "application/atom+xml") if !strings.HasPrefix(rr.Header().Get("Content-Type"), "application/atom+xml") { t.Errorf("Accept atom Content-Type = %q", rr.Header().Get("Content-Type")) } // Accept: */* → дефолт rss. rr = do(h, "GET", "/feed/durov", "Accept", "*/*") if !strings.HasPrefix(rr.Header().Get("Content-Type"), "application/rss+xml") { t.Errorf("Accept */* Content-Type = %q", rr.Header().Get("Content-Type")) } } // TestContentNegotiation_Priority — ?format= выигрывает над суффиксом и Accept (Этап 7.6). func TestContentNegotiation_Priority(t *testing.T) { h := New(Config{ Feed: &fakeFeed{posts: samplePosts(), ch: domain.Channel{Name: "durov"}, status: service.StatusFresh}, Formatters: []NamedFormatter{ rssFmt("<rss/>"), {Key: "atom", Formatter: stubFormatter{body: "<atom/>", ct: "application/atom+xml"}}, }, }) // .atom, но ?format=rss → rss (?format приоритетнее). rr := do(h, "GET", "/feed/durov.atom?format=rss", "Accept", "application/atom+xml") if !strings.HasPrefix(rr.Header().Get("Content-Type"), "application/rss+xml") { t.Errorf("priority Content-Type = %q, want rss", rr.Header().Get("Content-Type")) } } // TestSplitFormatSuffix — разбор суффикса формата. func TestSplitFormatSuffix(t *testing.T) { cases := []struct { in, name, key string }{ {"durov", "durov", ""}, {"durov.rss", "durov", "rss"}, {"durov.atom", "durov", "atom"}, {"durov.json", "durov", "json"}, {"channel_1.rss", "channel_1", "rss"}, {"weird.unknown", "weird.unknown", ""}, // неизвестный суффикс — часть имени } for _, c := range cases { name, key := splitFormatSuffix(c.in) if name != c.name || key != c.key { t.Errorf("splitFormatSuffix(%q) = (%q,%q), want (%q,%q)", c.in, name, key, c.name, c.key) } } } // TestIsNotModified — условная логика (table-driven). func TestIsNotModified(t *testing.T) { last := mustParse("2026-06-25T17:28:00Z") etag := `"abc123"` cases := []struct { name string inm string ims string want bool }{ {"none", "", "", false}, {"inm_match", etag, "", true}, {"inm_star", "*", "", true}, {"inm_list_match", `"x", ` + etag, "", true}, {"inm_mismatch", `"other"`, "", false}, {"ims_future", "", last.Add(time.Hour).Format(http.TimeFormat), true}, {"ims_past", "", last.Add(-time.Hour).Format(http.TimeFormat), false}, {"inm_mismatch_ims_future_ignored", `"other"`, "Wed, 31 Dec 2099 00:00:00 GMT", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { req := httptest.NewRequest("GET", "/feed/x", nil) if c.inm != "" { req.Header.Set("If-None-Match", c.inm) } if c.ims != "" { req.Header.Set("If-Modified-Since", c.ims) } if got := isNotModified(req, etag, last); got != c.want { t.Errorf("isNotModified = %v, want %v", got, c.want) } }) } }