642 lines
21 KiB
Go
642 lines
21 KiB
Go
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")
|
|
}
|
|
}
|