carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "net/http/httptest"
8 "testing"
9)
10
11func TestDirStorePageListerOrdersAndBounds(t *testing.T) {
12 ctx := context.Background()
13 d := NewDirStore(t.TempDir())
14 for _, k := range []string{"q/c", "q/a", "q/e", "q/b", "q/d", "other/z"} {
15 if _, err := d.Put(ctx, k, []byte("x"), ""); err != nil {
16 t.Fatal(err)
17 }
18 }
19
20 // Sorted, bounded, and the prefix is respected.
21 got, err := d.ListPage(ctx, "q/", "", 3)
22 if err != nil {
23 t.Fatal(err)
24 }
25 want := []string{"q/a", "q/b", "q/c"}
26 if fmt.Sprint(got) != fmt.Sprint(want) {
27 t.Fatalf("first page = %v, want %v", got, want)
28 }
29
30 // startAfter is EXCLUSIVE, so paging is "the last key I saw".
31 got, err = d.ListPage(ctx, "q/", "q/c", 3)
32 if err != nil {
33 t.Fatal(err)
34 }
35 if want := []string{"q/d", "q/e"}; fmt.Sprint(got) != fmt.Sprint(want) {
36 t.Fatalf("second page = %v, want %v", got, want)
37 }
38
39 // Paging to the end terminates rather than repeating.
40 got, err = d.ListPage(ctx, "q/", "q/e", 3)
41 if err != nil || len(got) != 0 {
42 t.Fatalf("past the end = %v, %v", got, err)
43 }
44
45 // limit <= 0 means no bound.
46 got, err = d.ListPage(ctx, "q/", "", 0)
47 if err != nil || len(got) != 5 {
48 t.Fatalf("unbounded = %v, %v", got, err)
49 }
50}
51
52// The S3 half, against an in-process fake rather than a live bucket.
53//
54// The point of this test is that ListPage puts start-after and max-keys ON THE
55// WIRE. Nothing else in the package does — the other listings paginate through
56// everything — and a drain that silently enumerated a flooded app's whole
57// queue every 30 seconds would still look correct from the outside.
58func TestS3StorePageListerBoundsTheRequest(t *testing.T) {
59 var gotStartAfter, gotMaxKeys, gotPrefix string
60 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61 q := r.URL.Query()
62 gotStartAfter, gotMaxKeys, gotPrefix = q.Get("start-after"), q.Get("max-keys"), q.Get("prefix")
63 w.Header().Set("Content-Type", "application/xml")
64 fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
65<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
66 <Name>b</Name><KeyCount>2</KeyCount><IsTruncated>true</IsTruncated>
67 <Contents><Key>q/d</Key><Size>1</Size></Contents>
68 <Contents><Key>q/e</Key><Size>1</Size></Contents>
69</ListBucketResult>`)
70 }))
71 defer srv.Close()
72
73 rgwEnv(t, srv.URL)
74 s, err := NewS3Store(context.Background(), "b")
75 if err != nil {
76 t.Fatal(err)
77 }
78
79 keys, err := s.ListPage(context.Background(), "q/", "q/c", 2)
80 if err != nil {
81 t.Fatal(err)
82 }
83 if fmt.Sprint(keys) != fmt.Sprint([]string{"q/d", "q/e"}) {
84 t.Fatalf("keys = %v", keys)
85 }
86 if gotStartAfter != "q/c" {
87 t.Fatalf("start-after = %q, want the caller's cursor on the wire", gotStartAfter)
88 }
89 if gotMaxKeys != "2" {
90 t.Fatalf("max-keys = %q, want the bound on the wire — otherwise the cap is a lie", gotMaxKeys)
91 }
92 if gotPrefix != "q/" {
93 t.Fatalf("prefix = %q", gotPrefix)
94 }
95}
96
97// An empty cursor sends no start-after at all, rather than an empty one.
98func TestS3StorePageListerOmitsEmptyCursor(t *testing.T) {
99 var sawStartAfter bool
100 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
101 _, sawStartAfter = r.URL.Query()["start-after"]
102 w.Header().Set("Content-Type", "application/xml")
103 fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
104<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>b</Name><KeyCount>0</KeyCount></ListBucketResult>`)
105 }))
106 defer srv.Close()
107
108 rgwEnv(t, srv.URL)
109 s, err := NewS3Store(context.Background(), "b")
110 if err != nil {
111 t.Fatal(err)
112 }
113 if _, err := s.ListPage(context.Background(), "q/", "", 10); err != nil {
114 t.Fatal(err)
115 }
116 if sawStartAfter {
117 t.Fatal("an empty cursor was sent as start-after=")
118 }
119}
120
121// "limit <= 0 means no bound" has to be true of BOTH stores, or a caller
122// asking for everything gets everything from DirStore and the first thousand
123// keys from S3 — with nothing to say there was more. One ListObjectsV2 is
124// capped at a thousand and reports IsTruncated; the unbounded path has to
125// follow it.
126func TestS3StorePageListerUnboundedFollowsTruncation(t *testing.T) {
127 var pages int
128 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129 pages++
130 w.Header().Set("Content-Type", "application/xml")
131 if pages == 1 {
132 // A truncated first page, exactly as S3 answers one.
133 fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
134<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
135 <Name>b</Name><KeyCount>2</KeyCount><IsTruncated>true</IsTruncated>
136 <NextContinuationToken>tok</NextContinuationToken>
137 <Contents><Key>q/a</Key><Size>1</Size></Contents>
138 <Contents><Key>q/b</Key><Size>1</Size></Contents>
139</ListBucketResult>`)
140 return
141 }
142 if got := r.URL.Query().Get("continuation-token"); got != "tok" {
143 t.Errorf("second request carried continuation-token %q, want the token the first page gave", got)
144 }
145 fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
146<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
147 <Name>b</Name><KeyCount>1</KeyCount><IsTruncated>false</IsTruncated>
148 <Contents><Key>q/c</Key><Size>1</Size></Contents>
149</ListBucketResult>`)
150 }))
151 defer srv.Close()
152
153 rgwEnv(t, srv.URL)
154 s, err := NewS3Store(context.Background(), "b")
155 if err != nil {
156 t.Fatal(err)
157 }
158 keys, err := s.ListPage(context.Background(), "q/", "", 0)
159 if err != nil {
160 t.Fatal(err)
161 }
162 if fmt.Sprint(keys) != fmt.Sprint([]string{"q/a", "q/b", "q/c"}) {
163 t.Fatalf("unbounded listing = %v, want every key across both pages", keys)
164 }
165 if pages != 2 {
166 t.Fatalf("made %d requests, want the truncation followed", pages)
167 }
168}
169
170// And the BOUNDED case stays one request, which is the whole reason this
171// interface exists: a drain that paged through a flooded app's entire queue
172// every tick would be paying for the listing it deliberately declined to act
173// on.
174func TestS3StorePageListerBoundedStaysOneRequest(t *testing.T) {
175 var requests int
176 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
177 requests++
178 w.Header().Set("Content-Type", "application/xml")
179 fmt.Fprint(w, `<?xml version="1.0" encoding="UTF-8"?>
180<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
181 <Name>b</Name><KeyCount>1</KeyCount><IsTruncated>true</IsTruncated>
182 <NextContinuationToken>tok</NextContinuationToken>
183 <Contents><Key>q/a</Key><Size>1</Size></Contents>
184</ListBucketResult>`)
185 }))
186 defer srv.Close()
187
188 rgwEnv(t, srv.URL)
189 s, err := NewS3Store(context.Background(), "b")
190 if err != nil {
191 t.Fatal(err)
192 }
193 if _, err := s.ListPage(context.Background(), "q/", "", 1); err != nil {
194 t.Fatal(err)
195 }
196 if requests != 1 {
197 t.Fatalf("a bounded listing made %d requests, want exactly one", requests)
198 }
199}
200