github.com/go-kivik/kivik/v4@v4.3.2/couchdb/attachments_test.go (about) 1 // Licensed under the Apache License, Version 2.0 (the "License"); you may not 2 // use this file except in compliance with the License. You may obtain a copy of 3 // the License at 4 // 5 // http://www.apache.org/licenses/LICENSE-2.0 6 // 7 // Unless required by applicable law or agreed to in writing, software 8 // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 9 // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 10 // License for the specific language governing permissions and limitations under 11 // the License. 12 13 package couchdb 14 15 import ( 16 "context" 17 "errors" 18 "fmt" 19 "io" 20 "mime" 21 "net/http" 22 "strings" 23 "testing" 24 25 "gitlab.com/flimzy/testy" 26 27 kivik "github.com/go-kivik/kivik/v4" 28 "github.com/go-kivik/kivik/v4/driver" 29 internal "github.com/go-kivik/kivik/v4/int/errors" 30 "github.com/go-kivik/kivik/v4/int/mock" 31 ) 32 33 type closer struct { 34 io.Reader 35 closed bool 36 } 37 38 var _ io.ReadCloser = &closer{} 39 40 func (c *closer) Close() error { 41 c.closed = true 42 return nil 43 } 44 45 func TestPutAttachment(t *testing.T) { 46 type paoTest struct { 47 name string 48 db *db 49 id string 50 att *driver.Attachment 51 options kivik.Option 52 53 newRev string 54 status int 55 err string 56 final func(*testing.T) 57 } 58 tests := []paoTest{ 59 { 60 name: "missing docID", 61 status: http.StatusBadRequest, 62 err: "kivik: docID required", 63 }, 64 { 65 name: "nil attachment", 66 id: "foo", 67 options: kivik.Rev("1-xxx"), 68 status: http.StatusBadRequest, 69 err: "kivik: att required", 70 }, 71 { 72 name: "missing filename", 73 id: "foo", 74 options: kivik.Rev("1-xxx"), 75 att: &driver.Attachment{}, 76 status: http.StatusBadRequest, 77 err: "kivik: att.Filename required", 78 }, 79 { 80 name: "no body", 81 id: "foo", 82 options: kivik.Rev("1-xxx"), 83 att: &driver.Attachment{ 84 Filename: "x.jpg", 85 ContentType: "image/jpeg", 86 }, 87 status: http.StatusBadRequest, 88 err: "kivik: att.Content required", 89 }, 90 { 91 name: "network error", 92 db: newTestDB(nil, errors.New("net error")), 93 id: "foo", 94 options: kivik.Rev("1-xxx"), 95 att: &driver.Attachment{ 96 Filename: "x.jpg", 97 ContentType: "image/jpeg", 98 Content: Body("x"), 99 }, 100 status: http.StatusBadGateway, 101 err: `Put "?http://example.com/testdb/foo/x.jpg\?rev=1-xxx"?: net error`, 102 }, 103 { 104 name: "1.6.1", 105 id: "foo", 106 options: kivik.Rev("1-4c6114c65e295552ab1019e2b046b10e"), 107 att: &driver.Attachment{ 108 Filename: "foo.txt", 109 ContentType: "text/plain", 110 Content: Body("Hello, World!"), 111 }, 112 db: newCustomDB(func(req *http.Request) (*http.Response, error) { 113 defer req.Body.Close() // nolint: errcheck 114 if ct, _, _ := mime.ParseMediaType(req.Header.Get("Content-Type")); ct != "text/plain" { 115 return nil, fmt.Errorf("Unexpected Content-Type: %s", ct) 116 } 117 expectedRev := "1-4c6114c65e295552ab1019e2b046b10e" 118 if rev := req.URL.Query().Get("rev"); rev != expectedRev { 119 return nil, fmt.Errorf("Unexpected rev: %s", rev) 120 } 121 body, err := io.ReadAll(req.Body) 122 if err != nil { 123 return nil, err 124 } 125 expected := "Hello, World!" 126 if d := testy.DiffText(expected, string(body)); d != nil { 127 t.Errorf("Unexpected body:\n%s", d) 128 } 129 return &http.Response{ 130 StatusCode: 201, 131 Header: http.Header{ 132 "Server": {"CouchDB/1.6.1 (Erlang OTP/17)"}, 133 "Location": {"http://localhost:5984/foo/foo/foo.txt"}, 134 "ETag": {`"2-8ee3381d24ee4ac3e9f8c1f6c7395641"`}, 135 "Date": {"Thu, 26 Oct 2017 20:51:35 GMT"}, 136 "Content-Type": {"text/plain; charset=utf-8"}, 137 "Content-Length": {"66"}, 138 "Cache-Control": {"must-revalidate"}, 139 }, 140 Body: Body(`{"ok":true,"id":"foo","rev":"2-8ee3381d24ee4ac3e9f8c1f6c7395641"}`), 141 }, nil 142 }), 143 newRev: "2-8ee3381d24ee4ac3e9f8c1f6c7395641", 144 }, 145 { 146 name: "no rev", 147 db: newCustomDB(func(req *http.Request) (*http.Response, error) { 148 if _, ok := req.URL.Query()["rev"]; ok { 149 t.Errorf("'rev' should not be present in the query") 150 } 151 return nil, errors.New("ignore this error") 152 }), 153 id: "foo", 154 att: &driver.Attachment{ 155 Filename: "foo.txt", 156 ContentType: "text/plain", 157 Content: Body("x"), 158 }, 159 status: http.StatusBadGateway, 160 err: `Put "?http://example.com/testdb/foo/foo.txt"?: ignore this error`, 161 }, 162 { 163 name: "with options", 164 db: newTestDB(nil, errors.New("success")), 165 id: "foo", 166 att: &driver.Attachment{ 167 Filename: "foo.txt", 168 ContentType: "text/plain", 169 Content: Body("x"), 170 }, 171 options: kivik.Params(map[string]interface{}{ 172 "foo": "oink", 173 "rev": "1-xxx", 174 }), 175 status: http.StatusBadGateway, 176 err: "foo=oink", 177 }, 178 { 179 name: "invalid options", 180 db: &db{}, 181 id: "foo", 182 att: &driver.Attachment{ 183 Filename: "foo.txt", 184 ContentType: "text/plain", 185 Content: Body("x"), 186 }, 187 options: kivik.Param("foo", make(chan int)), 188 status: http.StatusBadRequest, 189 err: "kivik: invalid type chan int for options", 190 }, 191 { 192 name: "full commit", 193 db: newCustomDB(func(req *http.Request) (*http.Response, error) { 194 if err := consume(req.Body); err != nil { 195 return nil, err 196 } 197 if fullCommit := req.Header.Get("X-Couch-Full-Commit"); fullCommit != "true" { // nolint: goconst 198 return nil, errors.New("X-Couch-Full-Commit not true") 199 } 200 return nil, errors.New("success") 201 }), 202 id: "foo", 203 att: &driver.Attachment{ 204 Filename: "foo.txt", 205 ContentType: "text/plain", 206 Content: Body("x"), 207 }, 208 options: multiOptions{ 209 OptionFullCommit(), 210 kivik.Rev("1-xxx"), 211 }, 212 status: http.StatusBadGateway, 213 err: "success", 214 }, 215 func() paoTest { 216 body := &closer{Reader: strings.NewReader("x")} 217 return paoTest{ 218 name: "ReadCloser", 219 db: newCustomDB(func(req *http.Request) (*http.Response, error) { 220 if err := consume(req.Body); err != nil { 221 return nil, err 222 } 223 if fullCommit := req.Header.Get("X-Couch-Full-Commit"); fullCommit != "true" { 224 return nil, errors.New("X-Couch-Full-Commit not true") 225 } 226 return nil, errors.New("success") 227 }), 228 id: "foo", 229 att: &driver.Attachment{ 230 Filename: "foo.txt", 231 ContentType: "text/plain", 232 Content: Body("x"), 233 }, 234 options: multiOptions{ 235 kivik.Rev("1-xxx"), 236 OptionFullCommit(), 237 }, 238 status: http.StatusBadGateway, 239 err: "success", 240 final: func(t *testing.T) { //nolint:thelper // Not a helper 241 if !body.closed { 242 t.Fatal("body wasn't closed") 243 } 244 }, 245 } 246 }(), 247 } 248 for _, test := range tests { 249 t.Run(test.name, func(t *testing.T) { 250 opts := test.options 251 if opts == nil { 252 opts = mock.NilOption 253 } 254 newRev, err := test.db.PutAttachment(context.Background(), test.id, test.att, opts) 255 if d := internal.StatusErrorDiffRE(test.err, test.status, err); d != "" { 256 t.Error(d) 257 } 258 if err != nil { 259 return 260 } 261 if newRev != test.newRev { 262 t.Errorf("Expected %s, got %s\n", test.newRev, newRev) 263 } 264 if test.final != nil { 265 test.final(t) 266 } 267 }) 268 } 269 } 270 271 func TestGetAttachmentMeta(t *testing.T) { 272 tests := []struct { 273 name string 274 db *db 275 id, filename string 276 277 expected *driver.Attachment 278 status int 279 err string 280 }{ 281 { 282 name: "network error", 283 id: "foo", 284 filename: "foo.txt", 285 db: newTestDB(nil, errors.New("net error")), 286 status: http.StatusBadGateway, 287 err: `^Head "?http://example.com/testdb/foo/foo.txt"?: net error$`, 288 }, 289 { 290 name: "1.6.1", 291 id: "foo", 292 filename: "foo.txt", 293 db: newTestDB(&http.Response{ 294 StatusCode: 200, 295 Header: http.Header{ 296 "Server": {"CouchDB/1.6.1 (Erlang OTP/17)"}, 297 "ETag": {`"gSr8dSmynwAoomH7V6RVYw=="`}, 298 "Date": {"Thu, 26 Oct 2017 21:15:13 GMT"}, 299 "Content-Type": {"text/plain"}, 300 "Content-Length": {"13"}, 301 "Cache-Control": {"must-revalidate"}, 302 "Accept-Ranges": {"none"}, 303 }, 304 Body: Body(""), 305 }, nil), 306 expected: &driver.Attachment{ 307 ContentType: "text/plain", 308 Digest: "gSr8dSmynwAoomH7V6RVYw==", 309 Content: Body(""), 310 }, 311 }, 312 } 313 for _, test := range tests { 314 t.Run(test.name, func(t *testing.T) { 315 att, err := test.db.GetAttachmentMeta(context.Background(), test.id, test.filename, mock.NilOption) 316 if d := internal.StatusErrorDiffRE(test.err, test.status, err); d != "" { 317 t.Error(d) 318 } 319 if d := testy.DiffInterface(test.expected, att); d != nil { 320 t.Errorf("Unexpected attachment:\n%s", d) 321 } 322 }) 323 } 324 } 325 326 func TestGetDigest(t *testing.T) { 327 tests := []struct { 328 name string 329 resp *http.Response 330 expected string 331 status int 332 err string 333 }{ 334 { 335 name: "no etag header", 336 resp: &http.Response{}, 337 status: http.StatusBadGateway, 338 err: "ETag header not found", 339 }, 340 { 341 name: "Standard ETag header", 342 resp: &http.Response{ 343 Header: http.Header{"ETag": []string{`"ENGoH7oK8V9R3BMnfDHZmw=="`}}, 344 }, 345 expected: "ENGoH7oK8V9R3BMnfDHZmw==", 346 }, 347 { 348 name: "normalized Etag header", 349 resp: &http.Response{ 350 Header: http.Header{"Etag": []string{`"ENGoH7oK8V9R3BMnfDHZmw=="`}}, 351 }, 352 expected: "ENGoH7oK8V9R3BMnfDHZmw==", 353 }, 354 } 355 for _, test := range tests { 356 t.Run(test.name, func(t *testing.T) { 357 digest, err := getDigest(test.resp) 358 if !testy.ErrorMatches(test.err, err) { 359 t.Errorf("Unexpected error: %s", err) 360 } 361 if digest != test.expected { 362 t.Errorf("Unexpected result: %0x", digest) 363 } 364 }) 365 } 366 } 367 368 func TestGetAttachment(t *testing.T) { 369 tests := []struct { 370 name string 371 db *db 372 id, filename string 373 options kivik.Option 374 375 expected *driver.Attachment 376 content string 377 status int 378 err string 379 }{ 380 { 381 name: "network error", 382 id: "foo", 383 filename: "foo.txt", 384 db: newTestDB(nil, errors.New("net error")), 385 status: http.StatusBadGateway, 386 err: `Get "?http://example.com/testdb/foo/foo.txt"?: net error`, 387 }, 388 { 389 name: "1.6.1", 390 id: "foo", 391 filename: "foo.txt", 392 db: newCustomDB(func(*http.Request) (*http.Response, error) { 393 return &http.Response{ 394 StatusCode: 200, 395 Header: http.Header{ 396 "Server": {"CouchDB/1.6.1 (Erlang OTP/17)"}, 397 "ETag": {`"gSr8dSmynwAoomH7V6RVYw=="`}, 398 "Date": {"Fri, 27 Oct 2017 11:24:50 GMT"}, 399 "Content-Type": {"text/plain"}, 400 "Content-Length": {"13"}, 401 "Cache-Control": {"must-revalidate"}, 402 "Accept-Ranges": {"none"}, 403 }, 404 Body: Body(`Hello, world!`), 405 }, nil 406 }), 407 expected: &driver.Attachment{ 408 ContentType: "text/plain", 409 Digest: "gSr8dSmynwAoomH7V6RVYw==", 410 }, 411 content: "Hello, world!", 412 }, 413 } 414 for _, test := range tests { 415 t.Run(test.name, func(t *testing.T) { 416 opts := test.options 417 if opts == nil { 418 opts = mock.NilOption 419 } 420 att, err := test.db.GetAttachment(context.Background(), test.id, test.filename, opts) 421 if d := internal.StatusErrorDiffRE(test.err, test.status, err); d != "" { 422 t.Error(d) 423 } 424 if err != nil { 425 return 426 } 427 fileContent, err := io.ReadAll(att.Content) 428 if err != nil { 429 t.Fatal(err) 430 } 431 if d := testy.DiffText(test.content, string(fileContent)); d != nil { 432 t.Errorf("Unexpected content:\n%s", d) 433 } 434 _ = att.Content.Close() 435 att.Content = nil // Determinism 436 if d := testy.DiffInterface(test.expected, att); d != nil { 437 t.Errorf("Unexpected attachment:\n%s", d) 438 } 439 }) 440 } 441 } 442 443 func TestFetchAttachment(t *testing.T) { 444 tests := []struct { 445 name string 446 db *db 447 method, id, filename string 448 options kivik.Option 449 450 resp *http.Response 451 status int 452 err string 453 }{ 454 { 455 name: "no method", 456 status: http.StatusInternalServerError, 457 err: "method required", 458 }, 459 { 460 name: "no docID", 461 method: "GET", 462 status: http.StatusBadRequest, 463 err: "kivik: docID required", 464 }, 465 { 466 name: "no filename", 467 method: "GET", 468 id: "foo", 469 status: http.StatusBadRequest, 470 err: "kivik: filename required", 471 }, 472 { 473 name: "no rev", 474 method: "GET", 475 id: "foo", 476 filename: "foo.txt", 477 db: newTestDB(nil, errors.New("ignore this error")), 478 status: http.StatusBadGateway, 479 err: "http://example.com/testdb/foo/foo.txt", 480 }, 481 { 482 name: "success", 483 method: "GET", 484 id: "foo", 485 filename: "foo.txt", 486 db: newTestDB(&http.Response{ 487 StatusCode: 200, 488 }, nil), 489 resp: &http.Response{ 490 StatusCode: 200, 491 }, 492 }, 493 { 494 name: "options", 495 db: newTestDB(nil, errors.New("success")), 496 method: "GET", 497 id: "foo", 498 filename: "foo.txt", 499 options: kivik.Param("foo", "bar"), 500 status: http.StatusBadGateway, 501 err: "foo=bar", 502 }, 503 { 504 name: "invalid option", 505 db: &db{}, 506 method: "GET", 507 id: "foo", 508 filename: "foo.txt", 509 options: kivik.Param("foo", make(chan int)), 510 status: http.StatusBadRequest, 511 err: "kivik: invalid type chan int for options", 512 }, 513 { 514 name: "If-None-Match", 515 db: newCustomDB(func(req *http.Request) (*http.Response, error) { 516 if err := consume(req.Body); err != nil { 517 return nil, err 518 } 519 if inm := req.Header.Get("If-None-Match"); inm != `"foo"` { 520 return nil, fmt.Errorf(`If-None-Match: %s != "foo"`, inm) 521 } 522 return nil, errors.New("success") 523 }), 524 method: "GET", 525 id: "foo", 526 filename: "foo.txt", 527 options: OptionIfNoneMatch("foo"), 528 status: http.StatusBadGateway, 529 err: "success", 530 }, 531 } 532 for _, test := range tests { 533 t.Run(test.name, func(t *testing.T) { 534 opts := test.options 535 if opts == nil { 536 opts = mock.NilOption 537 } 538 resp, err := test.db.fetchAttachment(context.Background(), test.method, test.id, test.filename, opts) 539 if d := internal.StatusErrorDiffRE(test.err, test.status, err); d != "" { 540 t.Error(d) 541 } 542 if err != nil { 543 return 544 } 545 if d := testy.DiffJSON(test.resp.Body, resp.Body); d != nil { 546 t.Errorf("Response body: %s", d) 547 } 548 // Normalize the response for diffing 549 resp.Request = nil 550 resp.Body = nil 551 test.resp.Body = nil 552 553 if d := testy.DiffInterface(test.resp, resp); d != nil { 554 t.Error(d) 555 } 556 }) 557 } 558 } 559 560 func TestDecodeAttachment(t *testing.T) { 561 tests := []struct { 562 name string 563 resp *http.Response 564 expected *driver.Attachment 565 content string 566 status int 567 err string 568 }{ 569 { 570 name: "no content type", 571 resp: &http.Response{}, 572 status: http.StatusBadGateway, 573 err: "no Content-Type in response", 574 }, 575 { 576 name: "no etag header", 577 resp: &http.Response{ 578 Header: http.Header{"Content-Type": {"text/plain"}}, 579 }, 580 status: http.StatusBadGateway, 581 err: "ETag header not found", 582 }, 583 { 584 name: "success", 585 resp: &http.Response{ 586 Header: http.Header{ 587 "Content-Type": {"text/plain"}, 588 "ETag": {`"gSr8dSmynwAoomH7V6RVYw=="`}, 589 }, 590 Body: Body("Hello, World!"), 591 }, 592 expected: &driver.Attachment{ 593 ContentType: "text/plain", 594 Digest: "gSr8dSmynwAoomH7V6RVYw==", 595 }, 596 content: "Hello, World!", 597 }, 598 } 599 for _, test := range tests { 600 t.Run(test.name, func(t *testing.T) { 601 att, err := decodeAttachment(test.resp) 602 if d := internal.StatusErrorDiff(test.err, test.status, err); d != "" { 603 t.Error(d) 604 } 605 if err != nil { 606 return 607 } 608 fileContent, err := io.ReadAll(att.Content) 609 if err != nil { 610 t.Fatal(err) 611 } 612 if d := testy.DiffText(test.content, string(fileContent)); d != nil { 613 t.Errorf("Unexpected content:\n%s", d) 614 } 615 _ = att.Content.Close() 616 att.Content = nil // Determinism 617 if d := testy.DiffInterface(test.expected, att); d != nil { 618 t.Errorf("Unexpected attachment:\n%s", d) 619 } 620 }) 621 } 622 } 623 624 func TestDeleteAttachment(t *testing.T) { 625 tests := []struct { 626 name string 627 db *db 628 id, filename string 629 options kivik.Option 630 631 newRev string 632 status int 633 err string 634 }{ 635 { 636 name: "no doc id", 637 status: http.StatusBadRequest, 638 err: "kivik: docID required", 639 }, 640 { 641 name: "no rev", 642 id: "foo", 643 status: http.StatusBadRequest, 644 err: "kivik: rev required", 645 }, 646 { 647 name: "no filename", 648 id: "foo", 649 options: kivik.Rev("1-xxx"), 650 status: http.StatusBadRequest, 651 err: "kivik: filename required", 652 }, 653 { 654 name: "network error", 655 id: "foo", 656 options: kivik.Rev("1-xxx"), 657 filename: "foo.txt", 658 db: newTestDB(nil, errors.New("net error")), 659 status: http.StatusBadGateway, 660 err: `(Delete "?http://example.com/testdb/foo/foo.txt\\?rev=1-xxx"?: )?net error`, 661 }, 662 { 663 name: "success 1.6.1", 664 id: "foo", 665 options: kivik.Rev("2-8ee3381d24ee4ac3e9f8c1f6c7395641"), 666 filename: "foo.txt", 667 db: newTestDB(&http.Response{ 668 StatusCode: 200, 669 Header: http.Header{ 670 "Server": {"CouchDB/1.6.1 (Erlang OTP/17)"}, 671 "ETag": {`"3-231a932924f61816915289fecd35b14a"`}, 672 "Date": {"Fri, 27 Oct 2017 13:30:40 GMT"}, 673 "Content-Type": {"text/plain; charset=utf-8"}, 674 "Content-Length": {"66"}, 675 "Cache-Control": {"must-revalidate"}, 676 }, 677 Body: Body(`{"ok":true,"id":"foo","rev":"3-231a932924f61816915289fecd35b14a"}`), 678 }, nil), 679 newRev: "3-231a932924f61816915289fecd35b14a", 680 }, 681 { 682 name: "with options", 683 db: newCustomDB(func(req *http.Request) (*http.Response, error) { 684 if err := consume(req.Body); err != nil { 685 return nil, err 686 } 687 if foo := req.URL.Query().Get("foo"); foo != "oink" { 688 return nil, fmt.Errorf("Unexpected query foo=%s", foo) 689 } 690 return nil, errors.New("success") 691 }), 692 id: "foo", 693 filename: "foo.txt", 694 options: kivik.Params(map[string]interface{}{ 695 "rev": "1-xxx", 696 "foo": "oink", 697 }), 698 status: http.StatusBadGateway, 699 err: "success", 700 }, 701 { 702 name: "invalid option", 703 db: &db{}, 704 id: "foo", 705 filename: "foo.txt", 706 options: kivik.Params(map[string]interface{}{ 707 "rev": "1-xxx", 708 "foo": make(chan int), 709 }), 710 status: http.StatusBadRequest, 711 err: "kivik: invalid type chan int for options", 712 }, 713 { 714 name: "full commit", 715 db: newCustomDB(func(req *http.Request) (*http.Response, error) { 716 if err := consume(req.Body); err != nil { 717 return nil, err 718 } 719 if fullCommit := req.Header.Get("X-Couch-Full-Commit"); fullCommit != "true" { 720 return nil, errors.New("X-Couch-Full-Commit not true") 721 } 722 return nil, errors.New("success") 723 }), 724 id: "foo", 725 filename: "foo.txt", 726 options: multiOptions{ 727 kivik.Rev("1-xxx"), 728 OptionFullCommit(), 729 }, 730 status: http.StatusBadGateway, 731 err: "success", 732 }, 733 } 734 for _, test := range tests { 735 t.Run(test.name, func(t *testing.T) { 736 opts := test.options 737 if opts == nil { 738 opts = mock.NilOption 739 } 740 newRev, err := test.db.DeleteAttachment(context.Background(), test.id, test.filename, opts) 741 if d := internal.StatusErrorDiffRE(test.err, test.status, err); d != "" { 742 t.Error(d) 743 } 744 if newRev != test.newRev { 745 t.Errorf("Unexpected new rev: %s", newRev) 746 } 747 }) 748 } 749 }