kereru-forms/backend/internal/api/api_test.go

642 lines
21 KiB
Go
Raw Permalink Normal View History

package api
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog"
"kereru.forms/internal/database"
)
func newTestRouter(t *testing.T) (*gin.Engine, database.Store) {
t.Helper()
path := filepath.Join(t.TempDir(), "api-test.db")
store, err := database.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
server := NewServer(store, zerolog.Nop(), "test-version")
router := NewRouter(server, zerolog.Nop(), RouterOptions{CORSOrigins: []string{"http://localhost:5173"}})
return router, store
}
func doRequest(t *testing.T, router *gin.Engine, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
reader = bytes.NewReader(payload)
}
req := httptest.NewRequest(method, path, reader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "kereru-test-agent")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %q: %v", w.Body.String(), err)
}
return out
}
func boolPtr(b bool) *bool { return &b }
func f32Ptr(f float32) *float32 { return &f }
func strPtr(s string) *string { return &s }
func createSampleForm(t *testing.T, router *gin.Engine) Form {
t.Helper()
options := []QuestionOption{
{Value: "red", Label: "Red"},
{Value: "blue", Label: "Blue"},
}
body := CreateFormRequest{
Title: "Customer feedback",
Description: strPtr("A short survey"),
Introduction: strPtr("Thanks for helping us improve."),
RequiresAccessCode: boolPtr(true),
LogoUrl: strPtr("https://example.com/logo.png"),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
{Key: "happy", Label: "Are you happy?", Type: Boolean, Required: true},
{Key: "score", Label: "Score", Type: Number, MinValue: f32Ptr(1), MaxValue: f32Ptr(10)},
{Key: "colour", Label: "Favourite colour", Type: Select, Options: &options},
{Key: "when", Label: "Date", Type: Date},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create form: status %d body %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func TestHealth(t *testing.T) {
router, _ := newTestRouter(t)
for _, path := range []string{"/health", "/api/health"} {
w := doRequest(t, router, http.MethodGet, path, nil)
if w.Code != http.StatusOK {
t.Fatalf("health %s: status %d", path, w.Code)
}
health := decode[Health](t, w)
if health.Status != "ok" || health.Version != "test-version" {
t.Fatalf("unexpected health: %+v", health)
}
if health.UptimeSeconds == nil {
t.Fatal("uptimeSeconds should be present")
}
}
}
func TestFormLifecycle(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
if form.Version != 1 || !form.IsActive || len(form.Questions) != 5 {
t.Fatalf("unexpected form: %+v", form)
}
w := doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get form: %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodGet, "/forms", nil)
if w.Code != http.StatusOK {
t.Fatalf("list forms: %d", w.Code)
}
forms := decode[[]FormSummary](t, w)
if len(forms) != 1 || forms[0].QuestionCount != 5 {
t.Fatalf("unexpected forms: %+v", forms)
}
if forms[0].LogoUrl == nil || *forms[0].LogoUrl != "https://example.com/logo.png" {
t.Fatalf("logo url missing from summary: %+v", forms[0])
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/access-codes",
CreateAccessCodesRequest{Count: 2, Prefix: strPtr("demo")})
if w.Code != http.StatusCreated {
t.Fatalf("create codes: %d %s", w.Code, w.Body.String())
}
codes := decode[[]AccessCode](t, w)
if len(codes) != 2 || !strings.HasPrefix(codes[0].Code, "DEMO-") {
t.Fatalf("unexpected codes: %+v", codes)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/inactivate", nil)
if w.Code != http.StatusOK {
t.Fatalf("inactivate: %d %s", w.Code, w.Body.String())
}
if inactive := decode[Form](t, w); inactive.IsActive {
t.Fatal("form should be inactive")
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/activate", nil)
if w.Code != http.StatusOK {
t.Fatalf("activate: %d", w.Code)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/versions", nil)
if w.Code != http.StatusCreated {
t.Fatalf("create version: %d %s", w.Code, w.Body.String())
}
v2 := decode[Form](t, w)
if v2.Version != 2 || v2.FormGroupId != form.FormGroupId {
t.Fatalf("unexpected version: %+v", v2)
}
if v2.LogoUrl == nil || *v2.LogoUrl != "https://example.com/logo.png" {
t.Fatalf("new version should keep the logo: %+v", v2)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
old := decode[Form](t, w)
if old.IsActive {
t.Fatal("previous version should be inactive")
}
}
func TestSubmitFlow(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
byKey := map[string]Question{}
for _, q := range form.Questions {
byKey[q.Key] = q
}
answers := []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["score"].Id, Value: 8},
{QuestionId: byKey["colour"].Id, Value: "blue"},
{QuestionId: byKey["when"].Id, Value: "2026-10-23"},
}
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/access-codes",
CreateAccessCodesRequest{Count: 1})
if w.Code != http.StatusCreated {
t.Fatalf("create code: %d", w.Code)
}
code := decode[[]AccessCode](t, w)[0].Code
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: answers,
Tracking: &ClientTracking{Locale: strPtr("en-NZ")},
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing access code should be 400, got %d %s", w.Code, w.Body.String())
}
submit.AccessCode = &code
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("submit: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.FormVersion != 1 || len(submission.Answers) != 5 {
t.Fatalf("unexpected submission: %+v", submission)
}
var storedDate string
for _, answer := range submission.Answers {
if answer.QuestionKey == "when" {
storedDate, _ = answer.Value.(string)
}
}
if storedDate != "2026-10-23" {
t.Fatalf("date answer not stored correctly: %+v", submission.Answers)
}
if submission.Tracking == nil || submission.Tracking.UserAgent == nil ||
*submission.Tracking.UserAgent != "kereru-test-agent" {
t.Fatalf("server-side tracking missing: %+v", submission.Tracking)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusConflict {
t.Fatalf("reused code should be 409, got %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/submissions", nil)
if w.Code != http.StatusOK {
t.Fatalf("list submissions: %d", w.Code)
}
subs := decode[[]SubmissionSummary](t, w)
if len(subs) != 1 || subs[0].Id != submission.Id {
t.Fatalf("unexpected submissions: %+v", subs)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d", w.Code)
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/inactivate", nil)
if w.Code != http.StatusOK {
t.Fatalf("inactivate: %d", w.Code)
}
second := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: answers,
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", second)
if w.Code != http.StatusGone {
t.Fatalf("inactive form should be 410, got %d %s", w.Code, w.Body.String())
}
}
func TestSubmitValidation(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
byKey := map[string]Question{}
for _, q := range form.Questions {
byKey[q.Key] = q
}
cases := []struct {
name string
answers []Answer
}{
{
name: "missing required",
answers: []Answer{{QuestionId: byKey["score"].Id, Value: 5}},
},
{
name: "number out of range",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["score"].Id, Value: 42},
},
},
{
name: "bad select option",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["colour"].Id, Value: "green"},
},
},
{
name: "bad date",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: true},
{QuestionId: byKey["when"].Id, Value: "23/10/2026"},
},
},
{
name: "wrong type",
answers: []Answer{
{QuestionId: byKey["name"].Id, Value: "Ada"},
{QuestionId: byKey["happy"].Id, Value: "yes"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions",
SubmitFormRequest{UserId: "ada@example.com", UserIdType: Email, Answers: tc.answers})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d %s", w.Code, w.Body.String())
}
})
}
}
func TestCreateFormValidation(t *testing.T) {
router, _ := newTestRouter(t)
options := []QuestionOption{{Value: "a", Label: "A"}}
cases := []CreateFormRequest{
{Title: "", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "1bad", Label: "A", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "dup", Label: "A", Type: Text}, {Key: "dup", Label: "B", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: "datetime"}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Select}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Number, MinValue: f32Ptr(10), MaxValue: f32Ptr(1)}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Select, Options: &options}, {Key: "a", Label: "B", Type: Text}}},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}, LogoUrl: strPtr("javascript:alert(1)")},
{Title: "Test", Questions: []QuestionInput{{Key: "a", Label: "A", Type: Text}}, LogoUrl: strPtr("not a url")},
}
for i, body := range cases {
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusBadRequest {
t.Fatalf("case %d: expected 400, got %d %s", i, w.Code, w.Body.String())
}
}
}
func TestPDFAndCORS(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
w := doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/pdf", nil)
if w.Code != http.StatusOK {
t.Fatalf("pdf: %d %s", w.Code, w.Body.String())
}
if ct := w.Header().Get("Content-Type"); ct != "application/pdf" {
t.Fatalf("content type = %q", ct)
}
if !bytes.HasPrefix(w.Body.Bytes(), []byte("%PDF")) {
t.Fatal("response is not a PDF")
}
req := httptest.NewRequest(http.MethodOptions, "/forms", nil)
req.Header.Set("Origin", "http://localhost:5173")
req.Header.Set("Access-Control-Request-Method", "POST")
corsW := httptest.NewRecorder()
router.ServeHTTP(corsW, req)
if corsW.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d", corsW.Code)
}
if got := corsW.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:5173" {
t.Fatalf("allow origin = %q", got)
}
}
func TestNotFound(t *testing.T) {
router, _ := newTestRouter(t)
missing := uuid.NewString()
for _, tc := range []struct {
method string
path string
}{
{http.MethodGet, "/forms/" + missing},
{http.MethodDelete, "/forms/" + missing},
{http.MethodPost, "/forms/" + missing + "/inactivate"},
{http.MethodGet, "/forms/" + missing + "/submissions"},
{http.MethodGet, "/submissions/" + missing},
{http.MethodGet, "/submissions/" + missing + "/offline-pdf"},
} {
w := doRequest(t, router, tc.method, tc.path, nil)
if w.Code != http.StatusNotFound {
t.Fatalf("%s %s: expected 404, got %d", tc.method, tc.path, w.Code)
}
}
}
func createSignatureForm(t *testing.T, router *gin.Engine) Form {
t.Helper()
body := CreateFormRequest{
Title: "Signed declaration",
SignatureRequired: boolPtr(true),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create signature form: %d %s", w.Code, w.Body.String())
}
return decode[Form](t, w)
}
func pngDataURL() string {
data := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data)
}
func pdfDataURL(data []byte) string {
return "data:application/pdf;base64," + base64.StdEncoding.EncodeToString(data)
}
func TestUpdateFormCreatesVersion(t *testing.T) {
router, _ := newTestRouter(t)
form := createSampleForm(t, router)
update := UpdateFormRequest{
Title: "Customer feedback v2",
PdfUrl: strPtr("https://example.com/prebuilt.pdf"),
ExternalReferenceRequired: boolPtr(true),
Questions: []QuestionInput{
{Key: "name", Label: "Your name", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPut, "/forms/"+form.Id.String(), update)
if w.Code != http.StatusOK {
t.Fatalf("update form: %d %s", w.Code, w.Body.String())
}
updated := decode[Form](t, w)
if updated.Id == form.Id {
t.Fatal("editing should create a new form id")
}
if updated.Version != 2 || updated.FormGroupId != form.FormGroupId {
t.Fatalf("unexpected versioned form: %+v", updated)
}
if updated.PdfUrl == nil || *updated.PdfUrl != "https://example.com/prebuilt.pdf" {
t.Fatalf("pdf url not saved: %+v", updated.PdfUrl)
}
if updated.SignatureRequired == nil || *updated.SignatureRequired {
t.Fatalf("signature flag should preserve false: %+v", updated.SignatureRequired)
}
if updated.ExternalReferenceRequired == nil || !*updated.ExternalReferenceRequired ||
updated.ExternalReferenceLabel == nil || *updated.ExternalReferenceLabel != "External reference" {
t.Fatalf("external reference was not enabled with a default label: %+v", updated)
}
if len(updated.Questions) != 1 || updated.Questions[0].Id.String() == "" {
t.Fatalf("expected a fresh question: %+v", updated.Questions)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get old form: %d", w.Code)
}
old := decode[Form](t, w)
if old.IsActive {
t.Fatal("previous version should be inactive")
}
w = doRequest(t, router, http.MethodGet, "/forms?includeInactive=true", nil)
if w.Code != http.StatusOK {
t.Fatalf("list forms: %d", w.Code)
}
forms := decode[[]FormSummary](t, w)
if len(forms) != 2 {
t.Fatalf("expected two versions, got %+v", forms)
}
}
func TestExternalReference(t *testing.T) {
router, _ := newTestRouter(t)
body := CreateFormRequest{
Title: "Invoice approval",
ExternalReferenceRequired: boolPtr(true),
ExternalReferenceLabel: strPtr("Invoice number"),
Questions: []QuestionInput{
{Key: "note", Label: "Note", Type: Text, Required: true},
},
}
w := doRequest(t, router, http.MethodPost, "/forms", body)
if w.Code != http.StatusCreated {
t.Fatalf("create form: %d %s", w.Code, w.Body.String())
}
form := decode[Form](t, w)
if form.ExternalReferenceRequired == nil || !*form.ExternalReferenceRequired ||
form.ExternalReferenceLabel == nil || *form.ExternalReferenceLabel != "Invoice number" {
t.Fatalf("unexpected external reference config: %+v", form)
}
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{
QuestionId: form.Questions[0].Id,
Value: "Approved",
}},
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing external reference should be 400, got %d %s", w.Code, w.Body.String())
}
submit.ExternalReference = strPtr("INV-42")
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("submit with reference: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.ExternalReference == nil || *submission.ExternalReference != "INV-42" {
t.Fatalf("external reference not returned: %+v", submission.ExternalReference)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d %s", w.Code, w.Body.String())
}
stored := decode[Submission](t, w)
if stored.ExternalReference == nil || *stored.ExternalReference != "INV-42" {
t.Fatalf("external reference not persisted: %+v", stored.ExternalReference)
}
w = doRequest(t, router, http.MethodGet, "/forms/"+form.Id.String()+"/submissions", nil)
if w.Code != http.StatusOK {
t.Fatalf("list submissions: %d %s", w.Code, w.Body.String())
}
summaries := decode[[]SubmissionSummary](t, w)
if len(summaries) != 1 || summaries[0].ExternalReference == nil ||
*summaries[0].ExternalReference != "INV-42" {
t.Fatalf("reference missing from summary: %+v", summaries)
}
}
func TestSignatureSubmission(t *testing.T) {
router, _ := newTestRouter(t)
form := createSignatureForm(t, router)
questionID := form.Questions[0].Id
base := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
Answers: []Answer{{QuestionId: questionID, Value: "Ada"}},
}
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusBadRequest {
t.Fatalf("missing signature should be 400, got %d %s", w.Code, w.Body.String())
}
base.Signature = &SignatureInput{
Type: Drawn,
DataUrl: pngDataURL(),
Name: "Ada Lovelace",
Title: strPtr("Analyst"),
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusCreated {
t.Fatalf("signed submission: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if submission.Signature == nil || submission.Signature.Name != "Ada Lovelace" ||
submission.Signature.Title == nil || *submission.Signature.Title != "Analyst" ||
submission.Signature.SignedAt.IsZero() {
t.Fatalf("signature not returned: %+v", submission.Signature)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String(), nil)
if w.Code != http.StatusOK {
t.Fatalf("get submission: %d %s", w.Code, w.Body.String())
}
stored := decode[Submission](t, w)
if stored.Signature == nil || stored.Signature.DataUrl != pngDataURL() {
t.Fatalf("signature not persisted: %+v", stored.Signature)
}
base.Signature = &SignatureInput{Type: Drawn, DataUrl: pngDataURL(), Name: ""}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", base)
if w.Code != http.StatusBadRequest {
t.Fatalf("empty signature name should be 400, got %d %s", w.Code, w.Body.String())
}
}
func TestOfflinePDFSubmission(t *testing.T) {
router, _ := newTestRouter(t)
form := createSignatureForm(t, router)
pdf := []byte("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF\n")
submit := SubmitFormRequest{
UserId: "ada@example.com",
UserIdType: Email,
OfflinePdf: strPtr(pdfDataURL(pdf)),
OfflinePdfName: strPtr("signed-declaration.pdf"),
}
invalid := submit
invalid.OfflinePdf = strPtr(pdfDataURL([]byte("not a pdf")))
w := doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", invalid)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid pdf should be 400, got %d %s", w.Code, w.Body.String())
}
w = doRequest(t, router, http.MethodPost, "/forms/"+form.Id.String()+"/submissions", submit)
if w.Code != http.StatusCreated {
t.Fatalf("offline submission: %d %s", w.Code, w.Body.String())
}
submission := decode[Submission](t, w)
if len(submission.Answers) != 0 || submission.OfflinePdfName == nil ||
*submission.OfflinePdfName != "signed-declaration.pdf" || submission.Signature != nil {
t.Fatalf("unexpected offline submission: %+v", submission)
}
w = doRequest(t, router, http.MethodGet, "/submissions/"+submission.Id.String()+"/offline-pdf", nil)
if w.Code != http.StatusOK {
t.Fatalf("download offline pdf: %d %s", w.Code, w.Body.String())
}
if got := w.Header().Get("Content-Type"); got != "application/pdf" {
t.Fatalf("content type = %q", got)
}
if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "signed-declaration.pdf") {
t.Fatalf("content disposition = %q", got)
}
if !bytes.Equal(w.Body.Bytes(), pdf) {
t.Fatalf("downloaded pdf differs from upload")
}
}