From a8a53c9581a0c86ba24d9833a0fb6b593eac2d89 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 12 Jun 2026 10:05:52 +0200 Subject: [PATCH] test(api/v2): cover the v2 file and CSV migrator endpoints Webtests for the file migrators (status, migrate, auth, missing-file) and the CSV importer (status, detect, preview, migrate happy path, missing/malformed config, empty file, auth). Each rejected upload is asserted to map to a 4xx domain error rather than a 500. --- pkg/webtests/huma_migration_csv_test.go | 125 ++++++++++++++++++++++ pkg/webtests/huma_migration_file_test.go | 128 +++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 pkg/webtests/huma_migration_csv_test.go create mode 100644 pkg/webtests/huma_migration_file_test.go diff --git a/pkg/webtests/huma_migration_csv_test.go b/pkg/webtests/huma_migration_csv_test.go new file mode 100644 index 000000000..ff269f46e --- /dev/null +++ b/pkg/webtests/huma_migration_csv_test.go @@ -0,0 +1,125 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const csvTestFile = `Title,Description,Done,Priority +Task 1,Description 1,true,high +Task 2,Description 2,false,low` + +const csvTestConfig = `{"delimiter":",","quote_char":"\"","date_format":"2006-01-02","mapping":[` + + `{"column_index":0,"column_name":"Title","attribute":"title"},` + + `{"column_index":1,"column_name":"Description","attribute":"description"},` + + `{"column_index":2,"column_name":"Done","attribute":"done"},` + + `{"column_index":3,"column_name":"Priority","attribute":"priority"}]}` + +// TestHumaMigrationCSV covers the generic CSV importer's v2 endpoints: +// status, detect, preview and migrate. No v1 webtest exists to mirror. +func TestHumaMigrationCSV(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + t.Run("status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + + t.Run("detect returns columns and a suggested mapping", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"columns"`) + assert.Contains(t, rec.Body.String(), `"suggested_mapping"`) + assert.Contains(t, rec.Body.String(), "Title") + }) + + t.Run("preview returns tasks without importing", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/preview", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"tasks"`) + assert.Contains(t, rec.Body.String(), "Task 1") + }) + + t.Run("migrate imports the file", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"Everything was migrated successfully."`) + + // The status now reflects a finished migration. + rec = humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.NotContains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, + "after migrating, the status must carry a real started_at; body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationCSV_BadInput covers the negative paths: missing config, +// malformed config JSON, and an empty file. +func TestHumaMigrationCSV_BadInput(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + t.Run("missing config is rejected with 422", func(t *testing.T) { + // The config form value is required:"true", so Huma's multipart + // validation refuses the request before the handler runs. + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("malformed config JSON is rejected with 400", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": "{not json"}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("empty file is rejected with a domain error", func(t *testing.T) { + body, contentType := multipartImportBody(t, "empty.csv", []byte{}, map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, token) + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationCSV_Unauthenticated proves all CSV ops require auth. +func TestHumaMigrationCSV_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/csv/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("detect", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/detect", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + body, contentType := multipartImportBody(t, "import.csv", []byte(csvTestFile), map[string]string{"config": csvTestConfig}) + rec := migrationUploadRequest(t, e, "/api/v2/migration/csv/migrate", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} diff --git a/pkg/webtests/huma_migration_file_test.go b/pkg/webtests/huma_migration_file_test.go new file mode 100644 index 000000000..9430127aa --- /dev/null +++ b/pkg/webtests/huma_migration_file_test.go @@ -0,0 +1,128 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartImportBody builds a multipart/form-data body with the file under the +// "import" field plus any extra string form values (e.g. the CSV "config"), +// matching the v2 file/CSV migrator form schemas. +func multipartImportBody(t *testing.T, filename string, content []byte, values map[string]string) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + fw, err := w.CreateFormFile("import", filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + for k, v := range values { + require.NoError(t, w.WriteField(k, v)) + } + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func migrationUploadRequest(t *testing.T, e *echo.Echo, path string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// TestHumaMigrationFile covers the always-registered file migrators +// (vikunja-file, ticktick, wekan) status + migrate endpoints. There is no v1 +// webtest for these handlers to mirror, so this is the parity baseline. +func TestHumaMigrationFile(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + // payload is shaped per migrator to hit a *domain* rejection (4xx) rather + // than a raw parse error: a wekan board with no title/cards is "empty", a + // ticktick CSV with no data rows is "empty", and a vikunja-file that isn't + // a zip is rejected as such. (Syntactically-malformed input would surface a + // raw json/zip error that maps to 500 in both v1 and v2 alike.) + migrators := map[string][]byte{ + "vikunja-file": []byte("not a zip archive"), + "ticktick": []byte("Title,Content\n"), + "wekan": []byte(`{"title":"","cards":[]}`), + } + + for name, payload := range migrators { + t.Run(name+" status - never migrated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/"+name+"/status", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // A user who never migrated has a zero-value status. + assert.Contains(t, rec.Body.String(), `"started_at":"0001-01-01T00:00:00Z"`, "body: %s", rec.Body.String()) + }) + + t.Run(name+" migrate maps a rejected file to a 4xx domain error", func(t *testing.T) { + // Drives the request through the multipart binding and into the + // migrator, which rejects it with a domain error that + // translateDomainError turns into a 4xx — proving the v2 plumbing + // (bind, run, error bridge) is wired, not the parsing itself. + body, contentType := multipartImportBody(t, "bad."+name, payload, nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/"+name+"/migrate", body, contentType, token) + assert.GreaterOrEqual(t, rec.Code, http.StatusBadRequest, "body: %s", rec.Body.String()) + assert.Less(t, rec.Code, http.StatusInternalServerError, + "a rejected upload must map to a 4xx domain error, not a 500; body: %s", rec.Body.String()) + }) + } +} + +// TestHumaMigrationFile_Unauthenticated proves the file migrator ops require auth. +func TestHumaMigrationFile_Unauthenticated(t *testing.T) { + e := setupMigrationTestEnv(t) + + t.Run("status", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/migration/ticktick/status", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("migrate", func(t *testing.T) { + body, contentType := multipartImportBody(t, "x.csv", []byte("x"), nil) + rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaMigrationFile_MissingFile proves the required "import" form field is +// enforced by Huma's multipart validation (422), not a 500. +func TestHumaMigrationFile_MissingFile(t *testing.T) { + e := setupMigrationTestEnv(t) + token := humaTokenFor(t, &testuser1) + + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + require.NoError(t, w.Close()) + + rec := migrationUploadRequest(t, e, "/api/v2/migration/ticktick/migrate", buf, w.FormDataContentType(), token) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) +}