oficina / sheets Public

Clone
git clone https://amadan.net/oficina/sheets

Plain git — no account needed to clone.

Download

Download this file

1package sheets
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "mime/multipart"
8 "net/http"
9 "net/http/httptest"
10 "strings"
11 "testing"
12
13 "amadan.net/oficina/sheets/internal/sheets/store"
14 "amadan.net/oficina/sheets/internal/sheets/xlsx"
15)
16
17// TestImportRefusesWhatItCannotRead covers every refusal a person can
18// hit before a job is ever started — each one answering on the import
19// page they posted from, with the reason above the form.
20//
21// That page used to be the sheet list, which was where the import form
22// lived. The form moved; the principle did not, and it is the reason a
23// refusal still needs no flash, no cookie and no redirect.
24func TestImportRefusesWhatItCannotRead(t *testing.T) {
25 for _, tc := range []struct {
26 name string
27 filename string
28 body []byte
29 want string
30 }{
31 {"not a workbook", "notes.pdf", []byte("%PDF-1.4"), refusalNotWorkbook},
32 {"no extension at all", "spreadsheet", []byte("anything"), refusalNotWorkbook},
33 {"too large", "huge.xlsx", make([]byte, maxImportBytes+1), refusalTooLarge},
34 } {
35 t.Run(tc.name, func(t *testing.T) {
36 app, cookies := signIn(t)
37 rec := do(t, app, as(uploadRequest(t, tc.filename, tc.body), cookies))
38
39 if rec.Code != http.StatusBadRequest {
40 t.Fatalf("answered %d, want 400", rec.Code)
41 }
42 if !strings.Contains(rec.Body.String(), tc.want) {
43 t.Errorf("page does not carry the refusal %q", tc.want)
44 }
45 // A refusal must not leave a sheet behind. The whole point
46 // of checking before createSheet is that nothing is made.
47 list, err := app.listSheets(signedInMember(t, app))
48 if err != nil {
49 t.Fatalf("listSheets: %v", err)
50 }
51 if len(list) != 0 {
52 t.Errorf("a refused import left %d sheet(s) in the list", len(list))
53 }
54 })
55 }
56}
57
58// TestImportWithNoFileSaysSo covers the empty form — a person who hit
59// Import without choosing anything.
60func TestImportWithNoFileSaysSo(t *testing.T) {
61 app, cookies := signIn(t)
62
63 var buf bytes.Buffer
64 w := multipart.NewWriter(&buf)
65 if err := w.Close(); err != nil {
66 t.Fatalf("close writer: %v", err)
67 }
68 req := httptest.NewRequest(http.MethodPost, "/sheets/import", &buf)
69 req.Header.Set("Content-Type", w.FormDataContentType())
70
71 rec := do(t, app, as(req, cookies))
72 if rec.Code != http.StatusBadRequest {
73 t.Fatalf("answered %d, want 400", rec.Code)
74 }
75 if !strings.Contains(rec.Body.String(), refusalNoFile) {
76 t.Errorf("page does not carry %q", refusalNoFile)
77 }
78}
79
80// TestAFailedImportLeavesNoSheetBehind pins discardSheet.
81//
82// A half-imported sheet looks complete, sits in a list the whole team
83// can see, and the only evidence it is short of rows is a status page
84// nobody else saw. The file here is a valid ZIP that is not a workbook,
85// so it passes the extension check and fails inside the job — which is
86// exactly the shape that reaches discardSheet.
87func TestAFailedImportLeavesNoSheetBehind(t *testing.T) {
88 app, cookies := signIn(t)
89
90 rec := do(t, app, as(uploadRequest(t, "broken.xlsx", []byte("not a zip at all")), cookies))
91 if rec.Code != http.StatusSeeOther {
92 t.Fatalf("import answered %d, want 303", rec.Code)
93 }
94 location, page := awaitJob(t, app, cookies, rec.Header().Get("Location"))
95 if location != "" {
96 t.Fatalf("a broken file finished successfully, landing on %q", location)
97 }
98 if !strings.Contains(page, "failed") {
99 t.Errorf("status page does not report a failure: %q", page)
100 }
101 list, err := app.listSheets(signedInMember(t, app))
102 if err != nil {
103 t.Fatalf("listSheets: %v", err)
104 }
105 if len(list) != 0 {
106 t.Errorf("a failed import left %d sheet(s) in the list", len(list))
107 }
108}
109
110// TestImportFailureNeverEchoesItsCause is the no-content-in-logs rule
111// (AGENTS.md, spec §2.4) at the one boundary that can break it.
112//
113// rastrillo's job runner slogs the error this function returns
114// ("jobs: failed", err), and `carlos logs` is platform-held storage
115// this app cannot grep or redact. So the guarantee has to be that
116// importFailure REPLACES its input rather than wrapping it — and the
117// way to prove that is to hand it an error whose text is a canary and
118// require the canary gone.
119func TestImportFailureNeverEchoesItsCause(t *testing.T) {
120 const canary = "SECRET-PAYROLL-FIGURE-93f7"
121
122 for _, tc := range []struct {
123 name string
124 in error
125 want string
126 }{
127 {
128 "a cell that would not parse",
129 fmt.Errorf("reading %s: %w", canary, &xlsx.CellError{Ref: "B7", Op: "cannot parse cell"}),
130 "B7",
131 },
132 {
133 "a workbook too big for one tab",
134 fmt.Errorf("at %s: %w", canary, store.ErrTabTooLarge),
135 "more cells",
136 },
137 {
138 "anything else",
139 errors.New("excelize exploded holding " + canary),
140 "could not be read",
141 },
142 } {
143 t.Run(tc.name, func(t *testing.T) {
144 got := importFailure(tc.in).Error()
145 if strings.Contains(got, canary) {
146 t.Fatalf("import failure text carries cell content: %q", got)
147 }
148 if !strings.Contains(got, tc.want) {
149 t.Errorf("import failure text = %q, want it to mention %q", got, tc.want)
150 }
151 })
152 }
153}
154
155// TestTitleFromFilename covers the names a file picker actually hands
156// over, including the Windows path some browsers have historically
157// sent whole.
158func TestTitleFromFilename(t *testing.T) {
159 for _, tc := range []struct{ in, want string }{
160 {"budget.xlsx", "budget"},
161 {"Q3 forecast.XLSX", "Q3 forecast"},
162 {`C:\Users\ada\books.xlsx`, "books"},
163 {"/home/ada/books.xlsm", "books"},
164 {"no-extension", "no-extension"},
165 {".xlsx", "Untitled sheet"},
166 {" ", "Untitled sheet"},
167 } {
168 if got := titleFromFilename(tc.in); got != tc.want {
169 t.Errorf("titleFromFilename(%q) = %q, want %q", tc.in, got, tc.want)
170 }
171 }
172}
173
174// TestImportIsScriptless pins the floor: the drop zone is inside a
175// plain multipart form with a file input and a submit button, so a
176// person with scripting off imports exactly the same way.
177//
178// PRODUCT.md's second principle is that the scriptless path is the
179// product rather than the fallback, and drag-and-drop is the easiest
180// place in this app to break it — a fetch-based uploader would look
181// identical in a browser and leave nothing behind for anyone else.
182// Asked of /sheets/import, not of /sheets. The form moved to a page of
183// its own when the list page's two inline forms came off it; the
184// question this test asks is unchanged and is the reason the form
185// became a page rather than an item in the File menu, where a hidden
186// input behind a label would have needed script to submit at all.
187func TestImportIsScriptless(t *testing.T) {
188 app, cookies := signIn(t)
189 rec := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets/import", nil), cookies))
190 if rec.Code != http.StatusOK {
191 t.Fatalf("import page answered %d, want 200", rec.Code)
192 }
193 body := rec.Body.String()
194
195 for _, want := range []string{
196 `action="/sheets/import"`,
197 `enctype="multipart/form-data"`,
198 `type="file"`,
199 `name="` + importField + `"`,
200 `type="submit"`,
201 } {
202 if !strings.Contains(body, want) {
203 t.Errorf("the import form is missing %s, so it does not work without scripting", want)
204 }
205 }
206 // The drop zone must live INSIDE that form, or the enhancement has
207 // nothing to submit.
208 form := body[strings.Index(body, `action="/sheets/import"`):]
209 zone := strings.Index(form, "data-drop")
210 end := strings.Index(form, "</form>")
211 if zone == -1 || end == -1 || zone > end {
212 t.Error("the drop zone is not inside the import form")
213 }
214}
215
216// TestJobFragmentIsNotWrappedInTheLayout pins the polled endpoint.
217//
218// The shim REPLACES the polling element with whatever this returns, so
219// a fragment that came back wrapped in the page layout would nest a
220// whole second copy of the page inside the first on every tick — and it
221// would do it only in a real browser, where no Go test looks. Hence a
222// test that looks.
223func TestJobFragmentIsNotWrappedInTheLayout(t *testing.T) {
224 app, cookies := signIn(t)
225
226 rec := do(t, app, as(uploadRequest(t, "budget.xlsx", corpusFile(t, "formulas.xlsx")), cookies))
227 if rec.Code != http.StatusSeeOther {
228 t.Fatalf("import answered %d, want 303", rec.Code)
229 }
230 fragment := rec.Header().Get("Location") + "/fragment"
231
232 got := do(t, app, as(httptest.NewRequest(http.MethodGet, fragment, nil), cookies))
233 switch got.Code {
234 case http.StatusNoContent:
235 // The job already finished, so the fragment hands the shim
236 // somewhere to go instead of markup. That is the other half of
237 // the contract and just as worth pinning.
238 if loc := got.Header().Get("Rastrillo-Location"); !strings.HasPrefix(loc, "/sheets/") {
239 t.Errorf("finished fragment sent the shim to %q, want a /sheets/ path", loc)
240 }
241 case http.StatusOK:
242 body := got.Body.String()
243 if strings.Contains(body, "<html") || strings.Contains(body, "rst-shell-bar") {
244 t.Error("the polled fragment carries the page layout, so every poll nests another copy of the page")
245 }
246 if !strings.Contains(body, "rst-job") {
247 t.Errorf("the fragment is not the job-status partial: %q", body)
248 }
249 default:
250 t.Fatalf("fragment answered %d, want 200 or 204", got.Code)
251 }
252}
253
254// TestJobPageNeedsASession pins the mount point. jobs.Handlers answers
255// a job only to its owner and reads that owner from sessions.Current,
256// so a route mounted outside the session group 404s every job — a
257// working import that looks broken.
258func TestJobPageNeedsASession(t *testing.T) {
259 app, _ := signIn(t)
260 rec := do(t, app, httptest.NewRequest(http.MethodGet, "/jobs/whatever", nil))
261 if rec.Code == http.StatusOK {
262 t.Error("the job status page answered 200 with no session")
263 }
264}
265