package controller import ( "bytes" "encoding/json" "mime/multipart" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "github.com/gin-gonic/gin" "hy2xs-admin/dao" ) // Разбор загруженного файла проверяется без базы: все три отказа наступают до // первого обращения к слою данных, и это часть контракта — испорченный файл не // должен доходить до транзакции. func postImportBytes(t *testing.T, fileName string, content []byte) apiResult { t.Helper() gin.SetMode(gin.TestMode) body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile("file", fileName) if err != nil { t.Fatalf("не удалось собрать форму: %v", err) } if _, err := part.Write(content); err != nil { t.Fatalf("не удалось записать файл в форму: %v", err) } if err := writer.Close(); err != nil { t.Fatalf("не удалось закрыть форму: %v", err) } engine := gin.New() engine.POST("/peer-import", ImportPeer) request := httptest.NewRequest(http.MethodPost, "/peer-import", body) request.Header.Set("Content-Type", writer.FormDataContentType()) recorder := httptest.NewRecorder() engine.ServeHTTP(recorder, request) var result apiResult if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String()) } return result } func postImportFile(t *testing.T, fileName string, content string) apiResult { t.Helper() return postImportBytes(t, fileName, []byte(content)) } func TestImportPeerRejectsInvalidUTF8(t *testing.T) { for _, content := range [][]byte{ {'[', '"', 0xFF, '"', ']'}, {'[', '"', 0xC3, '"', ']'}, {'[', '"', 0xED, 0xA0, 0x80, '"', ']'}, } { result := postImportBytes(t, "peers.json", content) if result.Type != "no" { t.Fatalf("повреждённый UTF-8 принят: %+v", result) } } } // Регрессия: json.Decoder читает ПЕРВЫЙ документ и останавливается. Файл с // хвостом принимался целиком, оператор видел «импорт выполнен», а вторая // половина файла молча не применялась. func TestImportPeerRejectsTrailingDocument(t *testing.T) { result := postImportFile(t, "peers.json", `[{"name":"alpha1"}] {"name":"ignored"}`) if result.Type != "no" { t.Fatalf("файл с хвостовым документом не отклонён: %+v", result) } if !strings.Contains(result.Message, "exactly one JSON document") { t.Fatalf("невнятный отказ: %s", result.Message) } } func TestImportPeerRejectsTrailingGarbage(t *testing.T) { for _, content := range []string{ "[{\"name\":\"alpha1\"}] trailing", "[{\"name\":\"alpha1\"}][{\"name\":\"bravo2\"}]", "[{\"name\":\"alpha1\"}]\n[]", } { result := postImportFile(t, "peers.json", content) if result.Type != "no" { t.Errorf("файл %q не отклонён: %+v", content, result) } } } func TestImportPeerRejectsUnknownFields(t *testing.T) { result := postImportFile(t, "peers.json", `[{"name":"alpha1","totallyUnknown":1}]`) if result.Type != "no" { t.Fatalf("неизвестное поле не отклонено: %+v", result) } } func TestImportPeerRejectsNonJsonFileName(t *testing.T) { result := postImportFile(t, "peers.txt", `[{"name":"alpha1"}]`) if result.Type != "no" { t.Fatalf("файл не с расширением .json не отклонён: %+v", result) } } // Положительный путь целиком: корректный одиночный документ обязан не только // разобраться, но и доехать до базы. Без него проверки выше доказывали бы // только то, что отклоняется всё подряд. func TestImportPeerAcceptsSingleDocument(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") if err := dao.InitSqliteDBAt(dbPath); err != nil { t.Fatalf("не удалось открыть тестовую базу: %v", err) } if err := dao.RunMigrations(); err != nil { t.Fatalf("не удалось применить миграции: %v", err) } t.Cleanup(func() { _ = dao.CloseSqliteDB() }) result := postImportFile(t, "peers.json", `[{"name":"alpha1","quotaBytes":-1,"maxDevices":3}]`) if result.Type != "ok" { t.Fatalf("корректный файл отклонён: %+v", result) } peers, err := dao.ListPeer("name = ?", "alpha1") if err != nil { t.Fatalf("не удалось прочитать пиров: %v", err) } if len(peers) != 1 { t.Fatalf("импорт не создал пира: найдено %d", len(peers)) } }