| 1 | package sheets |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "mime/multipart" |
| 6 | "net/http" |
| 7 | "net/http/httptest" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | // The corpus these tests upload is the committed XLSX round-trip |
| 16 | // corpus, read as bytes. Real files rather than hand-built ones, and |
| 17 | // read rather than generated, because excelize is confined to the xlsx |
| 18 | // package (guardstest' TestExcelizeConfinedToXLSXPackage walks test |
| 19 | // files too) — so this package cannot build a workbook even if it |
| 20 | // wanted to, and reaching for the corpus is both the only way and the |
| 21 | // better one. |
| 22 | func corpusFile(t *testing.T, name string) []byte { |
| 23 | t.Helper() |
| 24 | path := filepath.Join("..", "..", "testdata", "corpus", "xlsx", name) |
| 25 | body, err := os.ReadFile(path) |
| 26 | if err != nil { |
| 27 | t.Fatalf("read corpus %s: %v (run GENCORPUS=1 go test ./internal/sheets/xlsx/ -run TestGenerateCorpus)", name, err) |
| 28 | } |
| 29 | return body |
| 30 | } |
| 31 | |
| 32 | // uploadRequest builds the multipart POST the import form sends. |
| 33 | func uploadRequest(t *testing.T, filename string, body []byte) *http.Request { |
| 34 | t.Helper() |
| 35 | var buf bytes.Buffer |
| 36 | w := multipart.NewWriter(&buf) |
| 37 | part, err := w.CreateFormFile(importField, filename) |
| 38 | if err != nil { |
| 39 | t.Fatalf("CreateFormFile: %v", err) |
| 40 | } |
| 41 | if _, err := part.Write(body); err != nil { |
| 42 | t.Fatalf("write part: %v", err) |
| 43 | } |
| 44 | if err := w.Close(); err != nil { |
| 45 | t.Fatalf("close writer: %v", err) |
| 46 | } |
| 47 | req := httptest.NewRequest(http.MethodPost, "/sheets/import", &buf) |
| 48 | req.Header.Set("Content-Type", w.FormDataContentType()) |
| 49 | return req |
| 50 | } |
| 51 | |
| 52 | // awaitJob polls the status page the way a person's browser does, and |
| 53 | // returns where it sent them plus what the page last said. |
| 54 | // |
| 55 | // Polling rather than reaching into the registry: the thing worth |
| 56 | // testing is that the status page reports a job somebody can actually |
| 57 | // reach, and a test that inspects jobs.Get directly would pass with the |
| 58 | // route unmounted or its parameter misnamed. |
| 59 | func awaitJob(t *testing.T, app *App, cookies []*http.Cookie, jobPath string) (location, body string) { |
| 60 | t.Helper() |
| 61 | deadline := time.Now().Add(20 * time.Second) |
| 62 | for time.Now().Before(deadline) { |
| 63 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, jobPath, nil), cookies)) |
| 64 | switch rec.Code { |
| 65 | case http.StatusSeeOther: |
| 66 | return rec.Header().Get("Location"), rec.Body.String() |
| 67 | case http.StatusOK: |
| 68 | page := rec.Body.String() |
| 69 | // The partial drops data-poll the moment the job is no |
| 70 | // longer running, which is exactly the signal the shim |
| 71 | // stops on — so it is the right one to stop on here. |
| 72 | if !strings.Contains(page, "data-poll=") { |
| 73 | return "", page |
| 74 | } |
| 75 | default: |
| 76 | t.Fatalf("job status page answered %d, want 200 or 303", rec.Code) |
| 77 | } |
| 78 | time.Sleep(20 * time.Millisecond) |
| 79 | } |
| 80 | t.Fatal("job did not finish inside the deadline") |
| 81 | return "", "" |
| 82 | } |
| 83 | |
| 84 | // importCorpus runs the whole path a person walks: post the file, |
| 85 | // follow the 303 to the job, wait for it, and hand back where it |
| 86 | // landed. |
| 87 | func importCorpus(t *testing.T, app *App, cookies []*http.Cookie, filename, corpus string) (location, body string) { |
| 88 | t.Helper() |
| 89 | rec := do(t, app, as(uploadRequest(t, filename, corpusFile(t, corpus)), cookies)) |
| 90 | if rec.Code != http.StatusSeeOther { |
| 91 | t.Fatalf("import answered %d, want 303 (body %q)", rec.Code, rec.Body.String()) |
| 92 | } |
| 93 | jobPath := rec.Header().Get("Location") |
| 94 | if !strings.HasPrefix(jobPath, "/jobs/") { |
| 95 | t.Fatalf("import redirected to %q, want a /jobs/ path", jobPath) |
| 96 | } |
| 97 | return awaitJob(t, app, cookies, jobPath) |
| 98 | } |
| 99 | |
| 100 | // TestImportedSheetIsThereAndEditable is the feature in one test: drop |
| 101 | // a real workbook on the list, and end up on a sheet holding its |
| 102 | // contents that the table view will let you edit. |
| 103 | // |
| 104 | // It uses formulas.xlsx on purpose. Its B column is nothing but |
| 105 | // formulas over the A column, so a B3 reading 45 proves this app's own |
| 106 | // engine re-evaluated them (2+3=5, SUM=9, 5*9=45) rather than importing |
| 107 | // whatever cached values the file happened to carry — which is the one |
| 108 | // thing about XLSX import that could look right while being wrong. |
| 109 | func TestImportedSheetIsThereAndEditable(t *testing.T) { |
| 110 | app, cookies := signIn(t) |
| 111 | |
| 112 | location, page := importCorpus(t, app, cookies, "budget.xlsx", "formulas.xlsx") |
| 113 | if location == "" { |
| 114 | t.Fatalf("import did not finish on a sheet; last page said %q", page) |
| 115 | } |
| 116 | if !strings.HasPrefix(location, "/sheets/") { |
| 117 | t.Fatalf("job sent us to %q, want a /sheets/ path", location) |
| 118 | } |
| 119 | |
| 120 | // The sheet is in the list, named after the file. |
| 121 | viewer := signedInMember(t, app) |
| 122 | list, err := app.listSheets(viewer) |
| 123 | if err != nil { |
| 124 | t.Fatalf("listSheets: %v", err) |
| 125 | } |
| 126 | if len(list) != 1 { |
| 127 | t.Fatalf("want exactly one sheet after one import, got %d", len(list)) |
| 128 | } |
| 129 | if list[0].Title != "budget" { |
| 130 | t.Errorf("sheet title = %q, want %q (the filename without its extension)", list[0].Title, "budget") |
| 131 | } |
| 132 | |
| 133 | // The cells are really there, and the formulas were recomputed. |
| 134 | h, err := app.openTabFor(list[0]) |
| 135 | if err != nil { |
| 136 | t.Fatalf("openTabFor: %v", err) |
| 137 | } |
| 138 | defer h.Release() |
| 139 | // cellText is the table view's own reader, so this asserts what a |
| 140 | // person would actually see rather than a parallel formatting path |
| 141 | // that could agree with the engine while the screen does not. |
| 142 | idx := newPosIndex(h) |
| 143 | for _, want := range []struct { |
| 144 | row, col int |
| 145 | value string |
| 146 | }{ |
| 147 | {0, 0, "2"}, {1, 0, "3"}, {2, 0, "4"}, |
| 148 | {0, 1, "5"}, {1, 1, "9"}, {2, 1, "45"}, |
| 149 | } { |
| 150 | got := cellText(h, idx, want.row, want.col) |
| 151 | if got != want.value { |
| 152 | t.Errorf("cell at row %d col %d = %q, want %q", want.row, want.col, got, want.value) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // And the scriptless view opens it, which is what "available to |
| 157 | // edit" means for the path that does not need JavaScript. |
| 158 | rec := do(t, app, as(httptest.NewRequest(http.MethodGet, location+"/table", nil), cookies)) |
| 159 | if rec.Code != http.StatusOK { |
| 160 | t.Fatalf("table view answered %d, want 200", rec.Code) |
| 161 | } |
| 162 | if !strings.Contains(rec.Body.String(), "45") { |
| 163 | t.Error("table view does not show the recomputed 45") |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // TestImportKeepsAnImportedFill pins that formatting survives the HTTP |
| 168 | // path too, not just the xlsx package's own round trip. |
| 169 | func TestImportKeepsAnImportedFill(t *testing.T) { |
| 170 | app, cookies := signIn(t) |
| 171 | |
| 172 | if location, page := importCorpus(t, app, cookies, "colours.xlsx", "fills.xlsx"); location == "" { |
| 173 | t.Fatalf("import did not finish on a sheet; last page said %q", page) |
| 174 | } |
| 175 | viewer := signedInMember(t, app) |
| 176 | list, err := app.listSheets(viewer) |
| 177 | if err != nil { |
| 178 | t.Fatalf("listSheets: %v", err) |
| 179 | } |
| 180 | h, err := app.openTabFor(list[0]) |
| 181 | if err != nil { |
| 182 | t.Fatalf("openTabFor: %v", err) |
| 183 | } |
| 184 | defer h.Release() |
| 185 | |
| 186 | rowID, colID := h.GridRows()[0].ID, h.GridCols()[0].ID |
| 187 | f := h.Format(rowID, colID) |
| 188 | if f == nil || f.FillHex == "" { |
| 189 | t.Fatal("A1 lost its imported fill on the way through the upload path") |
| 190 | } |
| 191 | } |
| 192 | |