1package bundle_test23import (4 "archive/zip"5 "bytes"6 "os"7 "path/filepath"8 "strings"9 "testing"1011 "github.com/FelineStateMachine/atlas/internal/bundle"12 "github.com/FelineStateMachine/atlas/internal/bundle/bundletest"13)1415func validManifest() bundle.Manifest {16 return bundle.Manifest{17 Format: bundle.Format,18 FormatVersion: bundle.FormatVersion,19 Volume: bundle.Volume{Slug: "fixture", Title: "Fixture"},20 Version: bundle.Version{Stamp: "abc", CreatedAt: "2026-01-01T00:00:00Z"},21 Worlds: []bundle.WorldEntry{{Slug: "overworld", Title: "Overworld"}},22 }23}2425func TestManifestValidation(t *testing.T) {26 if err := validManifest().Validate(); err != nil {27 t.Fatalf("a valid manifest is refused: %v", err)28 }29 cases := map[string]func(*bundle.Manifest){30 "wrong format": func(m *bundle.Manifest) { m.Format = "zip-of-things" },31 "unknown version": func(m *bundle.Manifest) { m.FormatVersion = 99 },32 "empty game slug": func(m *bundle.Manifest) { m.Volume.Slug = "" },33 "unsafe game slug": func(m *bundle.Manifest) { m.Volume.Slug = "../escape" },34 "uppercase game slug": func(m *bundle.Manifest) { m.Volume.Slug = "Fallout76" },35 "untitled game": func(m *bundle.Manifest) { m.Volume.Title = "" },36 "missing stamp": func(m *bundle.Manifest) { m.Version.Stamp = "" },37 "missing created at": func(m *bundle.Manifest) { m.Version.CreatedAt = "" },38 "no maps": func(m *bundle.Manifest) { m.Worlds = nil },39 "unsafe map slug": func(m *bundle.Manifest) { m.Worlds[0].Slug = "a/b" },40 "duplicate map slugs": func(m *bundle.Manifest) { m.Worlds = append(m.Worlds, m.Worlds[0]) },41 }42 for name, corrupt := range cases {43 manifest := validManifest()44 corrupt(&manifest)45 if err := manifest.Validate(); err == nil {46 t.Errorf("a manifest with %s is accepted", name)47 }48 }49}5051func TestWriterRefusesUnsafeAndRepeatedEntries(t *testing.T) {52 writer, err := bundle.NewWriter(&bytes.Buffer{}, validManifest())53 if err != nil {54 t.Fatal(err)55 }56 for _, name := range []string{"", "/etc/hosts", "worlds/../escape", "tiles/", bundle.ManifestName} {57 if err := writer.AddDeflated(name, []byte("x")); err == nil {58 t.Errorf("entry %q is accepted", name)59 }60 }61 if err := writer.AddDeflated("worlds/overworld.json", []byte("{}")); err != nil {62 t.Fatal(err)63 }64 if err := writer.AddDeflated("worlds/overworld.json", []byte("{}")); err == nil {65 t.Error("the same entry is accepted twice")66 }67}6869func TestOpenRoundTripsWhatBuildWrites(t *testing.T) {70 path := bundletest.Build(t, t.TempDir(), bundletest.Spec{71 Slug: "fixture",72 Worlds: []bundletest.WorldSpec{{73 Slug: "overworld",74 Pins: []bundletest.Pin{{Title: "Origin", Lat: 12.5, Lng: -3.25}, {Title: "Peak", Lat: 80, Lng: 170}},75 }},76 })77 opened, err := bundle.Open(path)78 if err != nil {79 t.Fatal(err)80 }81 defer opened.Close()8283 if opened.Manifest.Volume.Slug != "fixture" {84 t.Errorf("game slug = %q", opened.Manifest.Volume.Slug)85 }86 if got := opened.Manifest.Worlds[0].Points; got != 2 {87 t.Errorf("point count = %d, want 2", got)88 }89 if err := opened.Validate(); err != nil {90 t.Errorf("a fixture bundle fails validation: %v", err)91 }9293 packed, err := opened.ReadEntry("worlds/overworld.bin")94 if err != nil {95 t.Fatal(err)96 }97 locations, err := bundle.UnpackLocations(packed)98 if err != nil {99 t.Fatal(err)100 }101 if len(locations) != 2 || locations[0].Title != "Origin" || locations[1].Title != "Peak" {102 t.Fatalf("locations round-tripped as %+v", locations)103 }104 if locations[0].Lat != 12.5 || locations[0].Lng != -3.25 {105 t.Errorf("Origin landed at %v,%v", locations[0].Lat, locations[0].Lng)106 }107108 reader, size, err := opened.OpenEntry("tiles/overworld/0/0/0.jpg")109 if err != nil {110 t.Fatal(err)111 }112 defer reader.Close()113 if size <= 0 {114 t.Errorf("tile size = %d", size)115 }116}117118func TestOpenRefusesWhatIsNotABundle(t *testing.T) {119 dir := t.TempDir()120121 plain := filepath.Join(dir, "plain.atlas")122 if err := os.WriteFile(plain, []byte("not a zip at all"), 0o644); err != nil {123 t.Fatal(err)124 }125 if _, err := bundle.Open(plain); err == nil {126 t.Error("a file that is not an archive is accepted")127 }128129 // A real zip with no manifest is somebody's archive wearing our suffix.130 bare := filepath.Join(dir, "bare.atlas")131 if err := os.WriteFile(bare, zipWithout(t, bare), 0o644); err != nil {132 t.Fatal(err)133 }134 if _, err := bundle.Open(bare); err == nil || !strings.Contains(err.Error(), bundle.ManifestName) {135 t.Errorf("a zip without a manifest is refused with %v, wanting a complaint about %s", err, bundle.ManifestName)136 }137}138139func TestValidateCatchesBrokenPromises(t *testing.T) {140 dir := t.TempDir()141142 // The manifest promises two maps; only one is written.143 manifest := validManifest()144 manifest.Worlds = append(manifest.Worlds, bundle.WorldEntry{Slug: "underworld", Title: "Underworld"})145 path := filepath.Join(dir, "fixture.atlas")146 file, err := os.Create(path)147 if err != nil {148 t.Fatal(err)149 }150 writer, err := bundle.NewWriter(file, manifest)151 if err != nil {152 t.Fatal(err)153 }154 must := func(err error) {155 t.Helper()156 if err != nil {157 t.Fatal(err)158 }159 }160 must(writer.AddDeflated("worlds/overworld.json",161 []byte(`{"lenses":[{"tiles":"overworld","minZoom":0,"maxZoom":0,"formats":["jpg"]}],"collections":[]}`)))162 must(writer.AddStored("worlds/overworld.bin", bytes.NewReader(bundle.PackLocations(nil))))163 must(writer.AddDeflated("worlds/overworld.text", []byte(`{}`)))164 must(writer.AddStored("tiles/overworld/0/0/0.jpg", bytes.NewReader([]byte("raster"))))165 must(writer.Close())166 file.Close()167168 opened, err := bundle.Open(path)169 if err != nil {170 t.Fatal(err)171 }172 defer opened.Close()173 if err := opened.Validate(); err == nil || !strings.Contains(err.Error(), "underworld") {174 t.Errorf("a missing map validates with %v, wanting a complaint about underworld", err)175 }176}177178// buildForValidation writes a one-world bundle whose payload JSON, text, and179// per-kind manifest counts a case chooses, so each promise Validate makes can180// be broken one at a time.181func buildForValidation(t *testing.T, detail, text string, points, paths, areas int) *bundle.Bundle {182 t.Helper()183 manifest := validManifest()184 manifest.Worlds[0].Points = points185 manifest.Worlds[0].Paths = paths186 manifest.Worlds[0].Areas = areas187 path := filepath.Join(t.TempDir(), "fixture.atlas")188 file, err := os.Create(path)189 if err != nil {190 t.Fatal(err)191 }192 writer, err := bundle.NewWriter(file, manifest)193 if err != nil {194 t.Fatal(err)195 }196 // At most one location is packed however many the manifest promises, so197 // a case can promise more than the payload holds.198 locations := make([]bundle.Location, min(points, 1))199 for index := range locations {200 locations[index] = bundle.Location{ID: int64(index + 1), Title: "Origin"}201 }202 steps := []error{203 writer.AddDeflated("worlds/overworld.json", []byte(detail)),204 writer.AddStored("worlds/overworld.bin", bytes.NewReader(bundle.PackLocations(locations))),205 writer.AddDeflated("worlds/overworld.text", []byte(text)),206 writer.AddStored("tiles/overworld/0/0/0.jpg", bytes.NewReader([]byte("raster"))),207 writer.Close(),208 file.Close(),209 }210 for _, err := range steps {211 if err != nil {212 t.Fatal(err)213 }214 }215 opened, err := bundle.Open(path)216 if err != nil {217 t.Fatal(err)218 }219 t.Cleanup(func() { opened.Close() })220 return opened221}222223const validationLenses = `"lenses":[{"tiles":"overworld","minZoom":0,"maxZoom":0,"formats":["jpg"]}]`224225func TestValidateRefusesRuntimeURLsAndWrongCounts(t *testing.T) {226 pointOnly := `{` + validationLenses + `,"collections":[{"id":1,"title":"Marker","kind":"point","visible":true}]}`227228 if err := buildForValidation(t, pointOnly, `{"1":{"d":"see https://mapgenie.io/x"}}`, 1, 0, 0).229 Validate(); err == nil || !strings.Contains(err.Error(), "runtime URL") {230 t.Errorf("a payload carrying a live URL validates with %v", err)231 }232 if err := buildForValidation(t, pointOnly, `{}`, 7, 0, 0).233 Validate(); err == nil || !strings.Contains(err.Error(), "7") {234 t.Errorf("a wrong point count validates with %v", err)235 }236 if err := buildForValidation(t, pointOnly, `{}`, 1, 0, 0).Validate(); err != nil {237 t.Errorf("a sound bundle is refused: %v", err)238 }239}240241// The v3 wire's structural promises: every collection is the kind it says,242// paths carry their stroke, labels are curated only where labels draw, points243// never inline, and the manifest's per-kind counts hold.244func TestValidateHoldsCollectionsToTheirKinds(t *testing.T) {245 area := func(attrs string) string {246 return `{` + validationLenses + `,"collections":[{"id":9,"title":"Districts","kind":"area",` +247 `"visible":true` + attrs + `,"features":[{"id":5,"title":"R-5",` +248 `"geometry":[{"type":"MultiPolygon","coordinates":[[[[0,0],[1,0],[1,1],[0,0]]]]}]}]}]}`249 }250 line := `{"type":"MultiLineString","coordinates":[[[0,0],[1,1]]]}`251 cases := []struct {252 name string253 detail string254 paths, areas int255 wantSomewhere string256 }{257 {258 name: "geometry disagreeing with the declared kind",259 detail: `{` + validationLenses + `,"collections":[{"id":9,"title":"Districts","kind":"area","visible":true,"features":[{"id":5,"title":"R-5","geometry":[` + line + `]}]}]}`,260 areas: 1,261 wantSomewhere: "inlines a MultiLineString",262 },263 {264 name: "a path collection with no stroke width",265 detail: `{` + validationLenses + `,"collections":[{"id":9,"title":"Creeks","kind":"path","visible":true,"features":[{"id":5,"title":"Big Dry Creek","geometry":[` + line + `]}]}]}`,266 paths: 1,267 wantSomewhere: "declares no atlas.stroke.width_px",268 },269 {270 name: "a manifest counting more areas than the payload holds",271 detail: area(""),272 areas: 2,273 wantSomewhere: "manifest says",274 },275 {276 name: "a label policy on a path collection",277 detail: `{` + validationLenses + `,"collections":[{"id":9,"title":"Creeks","kind":"path","visible":true,` +278 `"attrs":{"atlas.stroke.width_px":"10","atlas.label.policy":"quiet"},` +279 `"features":[{"id":5,"title":"Big Dry Creek","geometry":[` + line + `]}]}]}`,280 paths: 1,281 wantSomewhere: "label policy",282 },283 {284 name: "a point collection carrying inline features",285 detail: `{` + validationLenses + `,"collections":[{"id":1,"title":"Marker","kind":"point","visible":true,` +286 `"features":[{"id":5,"title":"Stray","geometry":[]}]}]}`,287 wantSomewhere: "inline features",288 },289 {290 name: "a sound area collection",291 detail: area(`,"attrs":{"atlas.label.policy":"quiet"}`),292 areas: 1,293 wantSomewhere: "",294 },295 }296 for _, c := range cases {297 t.Run(c.name, func(t *testing.T) {298 err := buildForValidation(t, c.detail, `{}`, 0, c.paths, c.areas).Validate()299 if c.wantSomewhere == "" {300 if err != nil {301 t.Fatalf("a sound bundle is refused: %v", err)302 }303 return304 }305 if err == nil || !strings.Contains(err.Error(), c.wantSomewhere) {306 t.Fatalf("validated with %v, want a complaint about %q", err, c.wantSomewhere)307 }308 })309 }310}311312// The file name is derived from nothing but the manifest, so the same build313// carries the same name wherever and whenever it is written.314func TestVersionedFileNameIsDeterministic(t *testing.T) {315 manifest := validManifest()316 manifest.Version.CreatedAt = "2026-08-01T09:30:00Z"317 manifest.Version.Stamp = strings.Repeat("ab", 32)318 if got, want := bundle.VersionedFileName(manifest), "fixture-20260801-abababababab.atlas"; got != want {319 t.Errorf("name = %q, want %q", got, want)320 }321}322323func TestMoreRecentIsTotalAndPrefersNewer(t *testing.T) {324 older := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{Slug: "game", CreatedAt: "2026-01-01T00:00:00Z"}))325 newer := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{Slug: "game", CreatedAt: "2026-06-01T00:00:00Z"}))326327 if !bundle.MoreRecent(newer, older) || bundle.MoreRecent(older, newer) {328 t.Error("creation time does not decide which bundle wins")329 }330 if bundle.MoreRecent(older, older) {331 t.Error("a bundle shadows itself")332 }333}334335// Builds of one capture share a creation time; the policy revision decides336// between them, so a rebuild under a newer rule supersedes deterministically337// rather than by whichever stamp happens to sort higher.338func TestMoreRecentPrefersTheNewerRevisionOfOneCapture(t *testing.T) {339 captured := "2026-06-01T00:00:00Z"340 plain := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{341 Slug: "game", CreatedAt: captured,342 Stamp: strings.Repeat("ff", 32),343 }))344 revised := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{345 Slug: "game", CreatedAt: captured, Revision: 1,346 Stamp: strings.Repeat("00", 32),347 }))348349 if !bundle.MoreRecent(revised, plain) || bundle.MoreRecent(plain, revised) {350 t.Error("the revision does not decide between builds of one capture")351 }352353 newerCapture := open(t, bundletest.Build(t, t.TempDir(), bundletest.Spec{354 Slug: "game", CreatedAt: "2026-07-01T00:00:00Z",355 }))356 if !bundle.MoreRecent(newerCapture, revised) {357 t.Error("a revision outweighed a newer capture")358 }359}360361func open(t *testing.T, path string) *bundle.Bundle {362 t.Helper()363 opened, err := bundle.Open(path)364 if err != nil {365 t.Fatal(err)366 }367 t.Cleanup(func() { opened.Close() })368 return opened369}370371// zipWithout builds a genuine zip holding a single unrelated entry,372// exercising the reader against an archive that is not a bundle.373func zipWithout(t *testing.T, _ string) []byte {374 t.Helper()375 var buffer bytes.Buffer376 zw := zip.NewWriter(&buffer)377 entry, err := zw.Create("README.txt")378 if err != nil {379 t.Fatal(err)380 }381 if _, err := entry.Write([]byte("just an archive")); err != nil {382 t.Fatal(err)383 }384 if err := zw.Close(); err != nil {385 t.Fatal(err)386 }387 return buffer.Bytes()388}