test: function_clone — 21 unit tests (validation, clone, rollback, download, triggers)
This commit is contained in:
@@ -0,0 +1,629 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
k8sruntime "k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
|
dynamicfake "k8s.io/client-go/dynamic/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// newCloneTestServer создаёт Server с фиктивным k8s-клиентом и
|
||||||
|
// опциональным HTTP-сервером, имитирующим storagesvc.
|
||||||
|
func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs ...*unstructured.Unstructured) (*Server, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
scheme := k8sruntime.NewScheme()
|
||||||
|
listKinds := map[schema.GroupVersionResource]string{
|
||||||
|
fission.FunctionGVR: "FunctionList",
|
||||||
|
fission.PackageGVR: "PackageList",
|
||||||
|
fission.HTTPTrigGVR: "HTTPTriggerList",
|
||||||
|
}
|
||||||
|
runtimeObjs := make([]k8sruntime.Object, len(objs))
|
||||||
|
for i, o := range objs {
|
||||||
|
runtimeObjs[i] = o
|
||||||
|
}
|
||||||
|
dynClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(scheme, listKinds, runtimeObjs...)
|
||||||
|
|
||||||
|
var storagesvc *httptest.Server
|
||||||
|
httpClient := &http.Client{}
|
||||||
|
if storagesvcHandler != nil {
|
||||||
|
storagesvc = httptest.NewServer(storagesvcHandler)
|
||||||
|
httpClient = storagesvc.Client()
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &Server{
|
||||||
|
dyn: dynClient,
|
||||||
|
ns: "fission-test",
|
||||||
|
http: httpClient,
|
||||||
|
}
|
||||||
|
return s, storagesvc
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneRequest выполняет POST /functions/:srcName/clone с телом body.
|
||||||
|
func cloneRequest(t *testing.T, s *Server, srcName, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost,
|
||||||
|
"/console/api/functions/"+srcName+"/clone",
|
||||||
|
strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Auth-Token", "test-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleCloneFunction(rec, req, srcName)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeZip возвращает минимальный zip-архив с одним файлом filename/content.
|
||||||
|
func makeZip(t *testing.T, filename, content string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zip.NewWriter(&buf)
|
||||||
|
f, err := w.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zip.Create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := io.WriteString(f, content); err != nil {
|
||||||
|
t.Fatalf("zip.Write: %v", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatalf("zip.Close: %v", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageWithLiteral создаёт Package CRD с type:literal (base64-encoded bytes).
|
||||||
|
func packageWithLiteral(ns, name, fnName string, data []byte) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": name, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deployment": map[string]any{
|
||||||
|
"type": "literal",
|
||||||
|
"literal": base64.StdEncoding.EncodeToString(data),
|
||||||
|
},
|
||||||
|
"environment": map[string]any{"name": fnName, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// packageWithURL создаёт Package CRD с type:url.
|
||||||
|
func packageWithURL(ns, pkgName, envName, archiveURL string) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": pkgName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deployment": map[string]any{
|
||||||
|
"type": "url",
|
||||||
|
"url": archiveURL,
|
||||||
|
},
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"source": map[string]any{},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// functionWithPkg создаёт Function CRD, ссылающуюся на pkgName.
|
||||||
|
func functionWithPkg(ns, fnName, pkgName, envName, entrypoint string) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Function",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": fnName,
|
||||||
|
"namespace": ns,
|
||||||
|
"annotations": map[string]any{
|
||||||
|
"fission-console/language": envName,
|
||||||
|
"fission-console/source-type": "code",
|
||||||
|
"fission-console/created-at": "2026-05-09T00:00:00Z",
|
||||||
|
"fission-console/updated-at": "2026-05-09T00:00:00Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"environment": map[string]any{"name": envName, "namespace": ns},
|
||||||
|
"functionTimeout": int64(120),
|
||||||
|
"package": map[string]any{
|
||||||
|
"packageref": map[string]any{"name": pkgName, "namespace": ns},
|
||||||
|
"functionName": entrypoint,
|
||||||
|
},
|
||||||
|
"InvokeStrategy": map[string]any{
|
||||||
|
"ExecutionStrategy": map[string]any{"ExecutorType": "poolmgr"},
|
||||||
|
"StrategyType": "execution",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpTriggerObject создаёт HTTPTrigger CRD для функции fnName.
|
||||||
|
func httpTriggerObject(ns, trigName, fnName, route string, methods []any) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "HTTPTrigger",
|
||||||
|
"metadata": map[string]any{"name": trigName, "namespace": ns},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"relativeurl": route,
|
||||||
|
"methods": methods,
|
||||||
|
"createingress": true,
|
||||||
|
"functionref": map[string]any{"type": "name", "name": fnName},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тесты валидации имени ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_EmptyName(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":""}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "new_name is required") {
|
||||||
|
t.Fatalf("body should mention 'new_name is required', got: %s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_UpperCase(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":"MyFunc"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_StartsWithDash(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":"-bad"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_EndsWithDash(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":"bad-"}`)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidName_TooLong(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
longName := strings.Repeat("a", 58)
|
||||||
|
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, longName))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_ValidName_MaxLength(t *testing.T) {
|
||||||
|
// 57 символов — допустимо; но функция src не существует → 404
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
name57 := strings.Repeat("a", 57)
|
||||||
|
rec := cloneRequest(t, s, "src", fmt.Sprintf(`{"new_name":%q}`, name57))
|
||||||
|
// Ожидаем не 400 (прошло валидацию), а 404 (src не найден)
|
||||||
|
if rec.Code == http.StatusBadRequest {
|
||||||
|
t.Fatalf("57-char name should pass validation, got 400; body=%s", rec.Body)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_ValidName_WithDashes(t *testing.T) {
|
||||||
|
// Имя вида "a-b-c" — допустимо
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "nonexist", `{"new_name":"a-b-c"}`)
|
||||||
|
if rec.Code == http.StatusBadRequest {
|
||||||
|
t.Fatalf("'a-b-c' should pass validation, got 400; body=%s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: источник не существует ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_SourceNotFound(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
rec := cloneRequest(t, s, "ghost", `{"new_name":"ghost-copy"}`)
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "ghost") {
|
||||||
|
t.Fatalf("body should mention 'ghost', got: %s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: успешный клон с type:literal ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_Success_Literal(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'hello'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "src-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "src", "src-pkg", "python", "main.main")
|
||||||
|
trig := httpTriggerObject(ns, "src-route", "src", "/test-ns/src", []any{"GET", "POST"})
|
||||||
|
|
||||||
|
// storagesvc принимает загрузку и возвращает URL
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Имитируем загрузку — возвращаем ID архива
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"id":"fission/new-archive-id-123"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn, trig)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "src", `{"new_name":"src-copy"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]any
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["name"] != "src-copy" {
|
||||||
|
t.Fatalf("response.name = %v, want 'src-copy'", resp["name"])
|
||||||
|
}
|
||||||
|
if resp["cloned_from"] != "src" {
|
||||||
|
t.Fatalf("response.cloned_from = %v, want 'src'", resp["cloned_from"])
|
||||||
|
}
|
||||||
|
if resp["package"] == "" || resp["package"] == nil {
|
||||||
|
t.Fatalf("response.package is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Проверяем что Function создана
|
||||||
|
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "src-copy", metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone Function not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аннотация cloned-from
|
||||||
|
ann := cloneFn.GetAnnotations()
|
||||||
|
if ann["fission-console/cloned-from"] != "src" {
|
||||||
|
t.Fatalf("annotation cloned-from = %q, want 'src'", ann["fission-console/cloned-from"])
|
||||||
|
}
|
||||||
|
// Аннотации language и source-type скопированы
|
||||||
|
if ann["fission-console/language"] != "python" {
|
||||||
|
t.Fatalf("annotation language = %q, want 'python'", ann["fission-console/language"])
|
||||||
|
}
|
||||||
|
if ann["fission-console/source-type"] != "code" {
|
||||||
|
t.Fatalf("annotation source-type = %q, want 'code'", ann["fission-console/source-type"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entrypoint скопирован
|
||||||
|
ep, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "functionName")
|
||||||
|
if ep != "main.main" {
|
||||||
|
t.Fatalf("entrypoint = %q, want 'main.main'", ep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout скопирован
|
||||||
|
timeout, _, _ := unstructured.NestedInt64(cloneFn.Object, "spec", "functionTimeout")
|
||||||
|
if timeout != 120 {
|
||||||
|
t.Fatalf("timeout = %d, want 120", timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем что HTTPTrigger создан
|
||||||
|
trigName := "src-copy-route"
|
||||||
|
cloneTrig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, trigName, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone HTTPTrigger not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Методы скопированы из оригинального триггера
|
||||||
|
methods, _, _ := unstructured.NestedStringSlice(cloneTrig.Object, "spec", "methods")
|
||||||
|
if len(methods) != 2 {
|
||||||
|
t.Fatalf("methods = %v, want [GET POST]", methods)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Маршрут сгенерирован автоматически (содержит new_name)
|
||||||
|
route, _, _ := unstructured.NestedString(cloneTrig.Object, "spec", "relativeurl")
|
||||||
|
if !strings.HasSuffix(route, "/src-copy") {
|
||||||
|
t.Fatalf("route = %q, should end with '/src-copy'", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: маршрут задан явно ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_CustomRoute(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "fn-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "fn", "fn-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-456"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "fn", `{"new_name":"fn-clone","route":"/custom/path"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn-clone-route", metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HTTPTrigger not found: %v", err)
|
||||||
|
}
|
||||||
|
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||||||
|
if route != "/custom/path" {
|
||||||
|
t.Fatalf("route = %q, want '/custom/path'", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: маршрут без слеша — добавляется автоматически ─────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_RouteWithoutLeadingSlash(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'hi'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "fn2-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "fn2", "fn2-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-789"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "fn2", `{"new_name":"fn2-clone","route":"no-leading-slash"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
trig, err := s.dyn.Resource(fission.HTTPTrigGVR).Namespace(ns).Get(ctx, "fn2-clone-route", metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HTTPTrigger not found: %v", err)
|
||||||
|
}
|
||||||
|
route, _, _ := unstructured.NestedString(trig.Object, "spec", "relativeurl")
|
||||||
|
if !strings.HasPrefix(route, "/") {
|
||||||
|
t.Fatalf("route = %q, should start with '/'", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: дублирующее имя → 409 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_DuplicateName_Conflict(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'a'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "dup-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "dup-src", "dup-pkg", "python", "main.main")
|
||||||
|
// Уже существующая функция с именем "dup-copy"
|
||||||
|
existingFn := functionWithPkg(ns, "dup-copy", "other-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-dup"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "dup-src", `{"new_name":"dup-copy"}`)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "dup-copy") {
|
||||||
|
t.Fatalf("body should mention 'dup-copy', got: %s", rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: откат при ошибке создания Function ────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_Rollback_OnFunctionConflict(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'rb'\n")
|
||||||
|
|
||||||
|
pkg := packageWithLiteral(ns, "rb-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "rb-src", "rb-pkg", "python", "main.main")
|
||||||
|
// Уже существующая функция с именем клона
|
||||||
|
existingFn := functionWithPkg(ns, "rb-clone", "other-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-rb"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn, existingFn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "rb-src", `{"new_name":"rb-clone"}`)
|
||||||
|
if rec.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// После rollback Package с именем "rb-clone-*" не должен остаться
|
||||||
|
ctx := context.Background()
|
||||||
|
pkgs, err := s.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list packages: %v", err)
|
||||||
|
}
|
||||||
|
for _, p := range pkgs.Items {
|
||||||
|
if strings.HasPrefix(p.GetName(), "rb-clone-") {
|
||||||
|
t.Fatalf("rollback failed: package %q still exists", p.GetName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: downloadPackageBytes — type:literal ────────────────────────────────
|
||||||
|
|
||||||
|
func TestDownloadPackageBytes_Literal(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
|
||||||
|
original := []byte("hello from literal")
|
||||||
|
pkg := packageWithLiteral("fission-test", "lit-pkg", "python", original)
|
||||||
|
|
||||||
|
got, err := s.downloadPackageBytes(context.Background(), pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("downloadPackageBytes error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, original) {
|
||||||
|
t.Fatalf("got %q, want %q", got, original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: downloadPackageBytes — type:url ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestDownloadPackageBytes_URL(t *testing.T) {
|
||||||
|
const content = "archive-content-from-storagesvc"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, content)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
pkg := packageWithURL("fission-test", "url-pkg", "python", srv.URL+"/v1/archive?id=abc")
|
||||||
|
s := &Server{http: srv.Client()}
|
||||||
|
|
||||||
|
got, err := s.downloadPackageBytes(context.Background(), pkg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("downloadPackageBytes error: %v", err)
|
||||||
|
}
|
||||||
|
if string(got) != content {
|
||||||
|
t.Fatalf("got %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: downloadPackageBytes — пустой Package → ошибка ────────────────────
|
||||||
|
|
||||||
|
func TestDownloadPackageBytes_EmptyPackage_Error(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
emptyPkg := &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "Package",
|
||||||
|
"metadata": map[string]any{"name": "empty", "namespace": "fission-test"},
|
||||||
|
"spec": map[string]any{},
|
||||||
|
}}
|
||||||
|
_, err := s.downloadPackageBytes(context.Background(), emptyPkg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty package, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: downloadFromStoragesvc — non-200 → ошибка ────────────────────────
|
||||||
|
|
||||||
|
func TestDownloadFromStoragesvc_Non200(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
s := &Server{http: srv.Client()}
|
||||||
|
_, err := s.downloadFromStoragesvc(context.Background(), srv.URL+"/v1/archive?id=gone")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 404 response, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "404") {
|
||||||
|
t.Fatalf("error should mention 404, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: getTriggerMethods — триггер существует ────────────────────────────
|
||||||
|
|
||||||
|
func TestGetTriggerMethods_Found(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
trig := httpTriggerObject(ns, "my-trig", "my-fn", "/my", []any{"GET", "POST"})
|
||||||
|
s, _ := newCloneTestServer(t, nil, trig)
|
||||||
|
|
||||||
|
methods := s.getTriggerMethods(context.Background(), ns, "my-fn")
|
||||||
|
if len(methods) != 2 {
|
||||||
|
t.Fatalf("methods = %v, want 2 elements", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: getTriggerMethods — триггера нет → nil ────────────────────────────
|
||||||
|
|
||||||
|
func TestGetTriggerMethods_NotFound(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
methods := s.getTriggerMethods(context.Background(), "fission-test", "no-such-fn")
|
||||||
|
if methods != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", methods)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: невалидный JSON body → 400 ────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_InvalidJSON(t *testing.T) {
|
||||||
|
s, _ := newCloneTestServer(t, nil)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/console/api/functions/src/clone",
|
||||||
|
strings.NewReader(`not-json`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.handleCloneFunction(rec, req, "src")
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Тест: успешный клон — Package содержит ссылку на Function ───────────────
|
||||||
|
|
||||||
|
func TestHandleCloneFunction_PackageRefsCloneFunction(t *testing.T) {
|
||||||
|
const ns = "fission-test"
|
||||||
|
zipData := makeZip(t, "main.py", "def main():\n return 'ref'\n")
|
||||||
|
pkg := packageWithLiteral(ns, "ref-pkg", "python", zipData)
|
||||||
|
fn := functionWithPkg(ns, "ref-src", "ref-pkg", "python", "main.main")
|
||||||
|
|
||||||
|
storagesvc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"id":"fission/arc-ref"}`)
|
||||||
|
}))
|
||||||
|
defer storagesvc.Close()
|
||||||
|
|
||||||
|
s, _ := newCloneTestServer(t, nil, pkg, fn)
|
||||||
|
s.storagesvcURL = storagesvc.URL
|
||||||
|
s.http = storagesvc.Client()
|
||||||
|
|
||||||
|
rec := cloneRequest(t, s, "ref-src", `{"new_name":"ref-clone"}`)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cloneFn, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, "ref-clone", metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone function not found: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgRef, _, _ := unstructured.NestedString(cloneFn.Object, "spec", "package", "packageref", "name")
|
||||||
|
if !strings.HasPrefix(pkgRef, "ref-clone-") {
|
||||||
|
t.Fatalf("clone Function.spec.package.packageref.name = %q, should start with 'ref-clone-'", pkgRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package с этим именем должен существовать
|
||||||
|
_, err = s.dyn.Resource(fission.PackageGVR).Namespace(ns).Get(ctx, pkgRef, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clone Package %q not found: %v", pkgRef, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user