restructure: console→client-console, add admin-console skeleton, move docs to doc/

This commit is contained in:
“Naeel”
2026-05-25 09:48:55 +04:00
parent 3e36ff13a8
commit 34a8068da5
110 changed files with 615 additions and 0 deletions
@@ -0,0 +1,282 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNormalizeAIMode(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "blank defaults to chat", in: "", want: aiModeChat},
{name: "case-insensitive codegen", in: " CODEGEN ", want: aiModeCodegen},
{name: "case-insensitive explain", in: "Explain", want: aiModeExplain},
{name: "unknown defaults to chat", in: "boom", want: aiModeChat},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeAIMode(tt.in); got != tt.want {
t.Fatalf("normalizeAIMode(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestDefaultPromptFunctionName(t *testing.T) {
tests := []struct {
lang string
want string
}{
{lang: "python", want: "main"},
{lang: "nodejs", want: "main"},
{lang: "go", want: "Handler"},
{lang: "php", want: "handler"},
{lang: "ruby", want: "handler"},
{lang: "unknown", want: "main"},
}
for _, tt := range tests {
t.Run(tt.lang, func(t *testing.T) {
if got := defaultPromptFunctionName(tt.lang); got != tt.want {
t.Fatalf("defaultPromptFunctionName(%q) = %q, want %q", tt.lang, got, tt.want)
}
})
}
}
func TestBuildLLMChatRequestCodegenPython(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{
Mode: aiModeCodegen,
Language: "python",
Name: " ",
Description: " сделать простой echo endpoint ",
})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
if req.Model != "gpt-oss-120b" {
t.Fatalf("unexpected model %q", req.Model)
}
if got, want := len(req.Messages), 2; got != want {
t.Fatalf("messages len = %d, want %d", got, want)
}
sys := req.Messages[0].Content
usr := req.Messages[1].Content
if !strings.Contains(sys, "Fission") || !strings.Contains(sys, "main.main") || !strings.Contains(sys, "def main():") {
t.Fatalf("python system prompt is missing Fission rules: %s", sys)
}
if !strings.Contains(sys, "не генерируй def main(ctx)") && !strings.Contains(strings.ToLower(sys), "def main(ctx)") {
t.Fatalf("python system prompt must forbid ctx main: %s", sys)
}
if !strings.Contains(usr, "Language: python") {
t.Fatalf("python user prompt missing language: %s", usr)
}
if !strings.Contains(usr, "Function name: main") {
t.Fatalf("blank name should fall back to main: %s", usr)
}
if !strings.Contains(usr, "echo endpoint") {
t.Fatalf("description must be present: %s", usr)
}
if !strings.Contains(usr, "Верни только код") || !strings.Contains(usr, "entrypoint") {
t.Fatalf("python user prompt missing constraints: %s", usr)
}
}
func TestBuildLLMChatRequestCodegenNodeJS(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{
Mode: aiModeCodegen,
Language: "nodejs",
Name: "handler",
Description: "возвращает status/body и не падает на пустом input",
})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
usr := req.Messages[1].Content
for _, want := range []string{"CommonJS", "module.exports", "Fission node wrapper", "status, body, headers"} {
if !strings.Contains(sys, want) {
t.Fatalf("node system prompt missing %q: %s", want, sys)
}
}
if !strings.Contains(usr, "Language: nodejs") || !strings.Contains(usr, "Function name: handler") {
t.Fatalf("node user prompt missing fields: %s", usr)
}
}
func TestBuildLLMChatRequestCodegenGo(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{
Mode: aiModeCodegen,
Language: "go",
Description: "печатает ответ и ставит content-type",
})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
usr := req.Messages[1].Content
for _, want := range []string{"package main", "func Handler(w http.ResponseWriter, r *http.Request)", "net/http"} {
if !strings.Contains(strings.ToLower(sys), strings.ToLower(want)) {
t.Fatalf("go system prompt missing %q: %s", want, sys)
}
}
if !strings.Contains(usr, "Function name: Handler") {
t.Fatalf("go user prompt must default to Handler: %s", usr)
}
}
func TestBuildLLMChatRequestCodegenPHP(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Language: "php", Description: "simple greeting"})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
for _, want := range []string{"main.php", "main.php::handler", "function handler($context)", "$context[\"response\"]"} {
if !strings.Contains(sys, want) {
t.Fatalf("php system prompt missing %q: %s", want, sys)
}
}
}
func TestBuildLLMChatRequestCodegenRuby(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Language: "ruby", Description: "return hello"})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
for _, want := range []string{"handler.rb", "entrypoint handler", "def handler"} {
if !strings.Contains(sys, want) {
t.Fatalf("ruby system prompt missing %q: %s", want, sys)
}
}
}
func TestBuildLLMChatRequestExplain(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{
Mode: aiModeExplain,
Language: "python",
Code: "def main():\n return 'ok'\n",
})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
sys := req.Messages[0].Content
usr := req.Messages[1].Content
for _, want := range []string{"объясни", "совместим ли он с Fission", "Если entrypoint", "не переписывай код"} {
if !strings.Contains(strings.ToLower(sys+"\n"+usr), strings.ToLower(want)) {
t.Fatalf("explain prompt missing %q: sys=%s usr=%s", want, sys, usr)
}
}
}
func TestBuildLLMChatRequestChat(t *testing.T) {
req, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeChat, Question: "что делает слой 2?"})
if err != nil {
t.Fatalf("buildLLMChatRequest error = %v", err)
}
if len(req.Messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(req.Messages))
}
if req.Messages[1].Content != "что делает слой 2?" {
t.Fatalf("chat question mismatch: %q", req.Messages[1].Content)
}
}
func TestBuildLLMChatRequestRejectsMissingFields(t *testing.T) {
if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeCodegen, Description: "x"}); err == nil {
t.Fatal("expected codegen without language to fail")
}
if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeExplain, Language: "python"}); err == nil {
t.Fatal("expected explain without code to fail")
}
if _, err := buildLLMChatRequest(aiPromptRequest{Mode: aiModeChat}); err == nil {
t.Fatal("expected chat without question to fail")
}
}
func TestHandleAIAskUsesCodegenPrompt(t *testing.T) {
var gotReq llmChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode upstream request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"module.exports = async function () { return { status: 200, body: \"ok\" }; }"}}]}`))
}))
defer server.Close()
s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"}
req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"mode":"codegen","language":"python","description":"сделай echo"}`))
rec := httptest.NewRecorder()
s.handleAIAsk(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if gotReq.Model != "gpt-oss-120b" {
t.Fatalf("model = %q", gotReq.Model)
}
if len(gotReq.Messages) != 2 {
t.Fatalf("messages len = %d", len(gotReq.Messages))
}
if !strings.Contains(gotReq.Messages[0].Content, "Python") || !strings.Contains(gotReq.Messages[1].Content, "echo") {
t.Fatalf("unexpected upstream prompts: %#v", gotReq.Messages)
}
}
func TestHandleAIAskUsesExplainPrompt(t *testing.T) {
var gotReq llmChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode upstream request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"код делает hello"}}]}`))
}))
defer server.Close()
s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"}
req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"mode":"explain","language":"go","code":"package main\nfunc Handler(w http.ResponseWriter, r *http.Request) {}"}`))
rec := httptest.NewRecorder()
s.handleAIAsk(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(gotReq.Messages[0].Content, "Go") || !strings.Contains(gotReq.Messages[1].Content, "совместим") {
t.Fatalf("unexpected explain prompts: %#v", gotReq.Messages)
}
}
func TestHandleAIAskKeepsChatModeSimple(t *testing.T) {
var gotReq llmChatRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatalf("decode upstream request: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
}))
defer server.Close()
s := &Server{http: server.Client(), llmURL: server.URL, llmKey: "secret"}
req := httptest.NewRequest(http.MethodPost, "/console/api/ai/ask", bytes.NewBufferString(`{"question":"что такое слой 1?"}`))
rec := httptest.NewRecorder()
s.handleAIAsk(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if gotReq.Messages[1].Content != "что такое слой 1?" {
t.Fatalf("chat question mismatch: %#v", gotReq.Messages)
}
}