clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / internal / authoring / capture_test.go
capture_test.go 12.2K
1package authoring23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "os"9 "path/filepath"10 "strings"11 "sync"12 "sync/atomic"13 "testing"14 "time"15)1617func TestCaptureEnforcesRequestBudgetWithoutResidue(t *testing.T) {18 body := strings.Repeat("x", 9)19 server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {20 flusher, _ := rw.(http.Flusher)21 for _, value := range body {22 _, _ = rw.Write([]byte{byte(value)})23 if flusher != nil {24 flusher.Flush()25 }26 }27 }))28 defer server.Close()29 root := t.TempDir()30 cache := OpenCache(root)31 request := Request{32 Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", Locator: server.URL,33 IdentityLocator: "https://example.invalid/sample-region.geojson", MediaType: "application/geo+json", MaxBytes: 8,34 }35 finalizeRequest(&request)36 if _, _, err := cache.Acquire(context.Background(), request, false); err == nil || !strings.Contains(err.Error(), "request budget") {37 t.Fatalf("oversized capture = %v", err)38 }39 entries, err := os.ReadDir(root)40 if err != nil {41 t.Fatal(err)42 }43 if len(entries) != 0 {44 t.Fatalf("oversized capture left cache residue: %v", entries)45 }46 request.MaxBytes = int64(len(body))47 if _, _, err := cache.Acquire(context.Background(), request, false); err != nil {48 t.Fatalf("exact-limit capture: %v", err)49 }50}5152func TestOfflineCaptureNeverContactsTheSource(t *testing.T) {53 var calls atomic.Int6454 server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {55 calls.Add(1)56 rw.Header().Set("Content-Type", "application/geo+json")57 _, _ = rw.Write([]byte(`{"type":"FeatureCollection","features":[]}`))58 }))59 defer server.Close()60 cache := OpenCache(t.TempDir())61 request := Request{62 Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", Locator: server.URL,63 IdentityLocator: "https://example.invalid/sample-region.geojson", MediaType: "application/geo+json", MaxBytes: 1024,64 }65 finalizeRequest(&request)66 if _, _, err := cache.Acquire(context.Background(), request, false); err != nil {67 t.Fatal(err)68 }69 if _, _, err := cache.Acquire(context.Background(), request, true); err != nil {70 t.Fatal(err)71 }72 if got := calls.Load(); got != 1 {73 t.Fatalf("source calls = %d, want exactly one online call", got)74 }75}7677func TestCaptureReusesUnchangedEvidence(t *testing.T) {78 root := t.TempDir()79 source := filepath.Join(root, "sample-region.geojson")80 if err := os.WriteFile(source, []byte(`{"type":"FeatureCollection","features":[]}`), 0o644); err != nil {81 t.Fatal(err)82 }83 cache := OpenCache(filepath.Join(root, "cache"))84 clock := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)85 cache.now = func() time.Time { return clock }86 request := Request{ID: requestID(Request{Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", IdentityLocator: "sample-region.geojson"}), Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", Locator: source, IdentityLocator: "sample-region.geojson", MediaType: "application/geo+json"}87 first, cached, err := cache.Acquire(context.Background(), request, false)88 if err != nil || cached {89 t.Fatalf("first acquire = cached %v, err %v", cached, err)90 }91 clock = clock.Add(time.Hour)92 second, cached, err := cache.Acquire(context.Background(), request, false)93 if err != nil || !cached {94 t.Fatalf("second acquire = cached %v, err %v", cached, err)95 }96 if second != first {97 t.Fatalf("unchanged capture moved: first %+v second %+v", first, second)98 }99}100101func TestCaptureRecordsChangedEvidence(t *testing.T) {102 root := t.TempDir()103 source := filepath.Join(root, "sample-region.geojson")104 if err := os.WriteFile(source, []byte(`{"v":1}`), 0o644); err != nil {105 t.Fatal(err)106 }107 cache := OpenCache(filepath.Join(root, "cache"))108 clock := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)109 cache.now = func() time.Time { return clock }110 request := Request{Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson", Locator: source, IdentityLocator: "sample-region.geojson"}111 request.ID = requestID(request)112 first, _, err := cache.Acquire(context.Background(), request, false)113 if err != nil {114 t.Fatal(err)115 }116 if err := os.WriteFile(source, []byte(`{"v":2}`), 0o644); err != nil {117 t.Fatal(err)118 }119 clock = clock.Add(time.Hour)120 second, cached, err := cache.Acquire(context.Background(), request, false)121 if err != nil || cached {122 t.Fatalf("changed acquire = cached %v, err %v", cached, err)123 }124 if first.Body.SHA256 == second.Body.SHA256 || first.CapturedAt == second.CapturedAt {125 t.Fatalf("change was not recorded: first %+v second %+v", first, second)126 }127}128129func TestCaptureReusesFirstSeenEvidenceWhenBytesReappear(t *testing.T) {130 root := t.TempDir()131 source := filepath.Join(root, "sample-region.geojson")132 cache := OpenCache(filepath.Join(root, "cache"))133 clock := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)134 cache.now = func() time.Time { return clock }135 request := Request{136 Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson",137 Locator: source, IdentityLocator: "sample-region.geojson",138 }139 request.ID = requestID(request)140141 writeSource := func(body string) {142 t.Helper()143 if err := os.WriteFile(source, []byte(body), 0o644); err != nil {144 t.Fatal(err)145 }146 }147 writeSource(`{"v":1}`)148 first, _, err := cache.Acquire(context.Background(), request, false)149 if err != nil {150 t.Fatal(err)151 }152 clock = clock.Add(time.Hour)153 writeSource(`{"v":2}`)154 if _, _, err := cache.Acquire(context.Background(), request, false); err != nil {155 t.Fatal(err)156 }157 clock = clock.Add(time.Hour)158 writeSource(`{"v":1}`)159 reappeared, cached, err := cache.Acquire(context.Background(), request, false)160 if err != nil || !cached {161 t.Fatalf("reappeared acquire = cached %v, err %v", cached, err)162 }163 if reappeared != first {164 t.Fatalf("reappeared capture moved: first %+v reappeared %+v", first, reappeared)165 }166 selected, err := cache.Select(request.ID, first.Body.SHA256)167 if err != nil || selected != first {168 t.Fatalf("select first capture = %+v, %v", selected, err)169 }170}171172func TestConcurrentCaptureReturnsOnePersistedFirstSeenEnvelope(t *testing.T) {173 root := t.TempDir()174 source := filepath.Join(root, "sample-region.geojson")175 if err := os.WriteFile(source, []byte(`{"v":1}`), 0o644); err != nil {176 t.Fatal(err)177 }178 cache := OpenCache(filepath.Join(root, "cache"))179 var ticks atomic.Int64180 cache.now = func() time.Time {181 return time.Date(2026, 8, 8, 12, 0, 0, int(ticks.Add(1)), time.UTC)182 }183 request := Request{184 Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson",185 Locator: source, IdentityLocator: "sample-region.geojson",186 }187 request.ID = requestID(request)188189 type result struct {190 capture Capture191 cached bool192 err error193 }194 results := make(chan result, 2)195 start := make(chan struct{})196 var workers sync.WaitGroup197 for range 2 {198 workers.Add(1)199 go func() {200 defer workers.Done()201 <-start202 capture, cached, err := cache.Acquire(context.Background(), request, false)203 results <- result{capture: capture, cached: cached, err: err}204 }()205 }206 close(start)207 workers.Wait()208 close(results)209210 var captures []Capture211 cachedCount := 0212 for result := range results {213 if result.err != nil {214 t.Fatal(result.err)215 }216 captures = append(captures, result.capture)217 if result.cached {218 cachedCount++219 }220 }221 if captures[0] != captures[1] || cachedCount != 1 {222 t.Fatalf("concurrent captures = %+v, cached count %d", captures, cachedCount)223 }224 selected, err := cache.Select(request.ID, captures[0].Body.SHA256)225 if err != nil || selected != captures[0] {226 t.Fatalf("persisted capture = %+v, %v", selected, err)227 }228}229230func TestCacheRejectsInvalidHashesWithoutPanicking(t *testing.T) {231 cache := OpenCache(t.TempDir())232 if cache.Has("short") {233 t.Fatal("short request hash reported cached")234 }235 if _, err := cache.Latest("short"); err == nil {236 t.Fatal("Latest accepted a short request hash")237 }238 if _, err := cache.Select(strings.Repeat("a", 64), "../escape"); err == nil {239 t.Fatal("Select accepted a path-like body hash")240 }241 _ = cache.BlobPath(BlobRef{SHA256: "short"})242}243244func TestCacheRejectsCorruptCaptureEvidence(t *testing.T) {245 tests := []struct {246 name string247 mutate func(t *testing.T, cache *Cache, request Request, capture Capture)248 }{249 {250 name: "malformed envelope",251 mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {252 t.Helper()253 writeCaptureFile(t, cache, request.ID, capture.Body.SHA256, []byte("{"))254 },255 },256 {257 name: "wrong format",258 mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {259 capture.Format = "atlas-capture/unknown"260 writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)261 },262 },263 {264 name: "wrong request hash",265 mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {266 capture.RequestHash = strings.Repeat("b", 64)267 writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)268 },269 },270 {271 name: "wrong capture path",272 mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {273 wrong := strings.Repeat("b", 64)274 writeCapture(t, cache, request.ID, wrong, capture)275 if err := os.Remove(captureFilePath(cache, request.ID, capture.Body.SHA256)); err != nil {276 t.Fatal(err)277 }278 },279 },280 {281 name: "invalid captured time",282 mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {283 capture.CapturedAt = "eventually"284 writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)285 },286 },287 {288 name: "missing media type",289 mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {290 capture.Body.MediaType = ""291 writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)292 },293 },294 {295 name: "wrong declared length",296 mutate: func(t *testing.T, cache *Cache, request Request, capture Capture) {297 capture.Body.Length++298 writeCapture(t, cache, request.ID, capture.Body.SHA256, capture)299 },300 },301 {302 name: "missing body",303 mutate: func(t *testing.T, cache *Cache, _ Request, capture Capture) {304 if err := os.Remove(cache.BlobPath(capture.Body)); err != nil {305 t.Fatal(err)306 }307 },308 },309 {310 name: "body hash mismatch",311 mutate: func(t *testing.T, cache *Cache, _ Request, capture Capture) {312 path := cache.BlobPath(capture.Body)313 data, err := os.ReadFile(path)314 if err != nil {315 t.Fatal(err)316 }317 data[0] ^= 1318 if err := os.WriteFile(path, data, 0o644); err != nil {319 t.Fatal(err)320 }321 },322 },323 }324325 for _, tt := range tests {326 t.Run(tt.name, func(t *testing.T) {327 cache, request, capture := capturedFixture(t)328 tt.mutate(t, cache, request, capture)329 if cache.Has(request.ID) {330 t.Fatal("corrupt evidence reported cached")331 }332 if _, err := cache.Latest(request.ID); err == nil {333 t.Fatal("Latest accepted corrupt evidence")334 }335 if _, err := cache.Select(request.ID, capture.Body.SHA256); err == nil && tt.name != "wrong capture path" {336 t.Fatal("Select accepted corrupt evidence")337 }338 })339 }340}341342func capturedFixture(t *testing.T) (*Cache, Request, Capture) {343 t.Helper()344 root := t.TempDir()345 source := filepath.Join(root, "sample-region.geojson")346 if err := os.WriteFile(source, []byte(`{"type":"FeatureCollection","features":[]}`), 0o644); err != nil {347 t.Fatal(err)348 }349 cache := OpenCache(filepath.Join(root, "cache"))350 cache.now = func() time.Time { return time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC) }351 request := Request{352 Kind: RequestFeatures, Source: "sample-features", Adapter: "geojson",353 Locator: source, IdentityLocator: "sample-region.geojson", MediaType: "application/geo+json",354 }355 request.ID = requestID(request)356 capture, _, err := cache.Acquire(context.Background(), request, false)357 if err != nil {358 t.Fatal(err)359 }360 return cache, request, capture361}362363func writeCapture(t *testing.T, cache *Cache, requestID, bodySHA string, capture Capture) {364 t.Helper()365 data, err := json.Marshal(capture)366 if err != nil {367 t.Fatal(err)368 }369 writeCaptureFile(t, cache, requestID, bodySHA, append(data, '\n'))370}371372func writeCaptureFile(t *testing.T, cache *Cache, requestID, bodySHA string, data []byte) {373 t.Helper()374 if err := os.WriteFile(captureFilePath(cache, requestID, bodySHA), data, 0o644); err != nil {375 t.Fatal(err)376 }377}378379func captureFilePath(cache *Cache, requestID, bodySHA string) string {380 return filepath.Join(cache.Root(), "captures", requestID[:2], requestID, bodySHA+".json")381}