package cache import ( "encoding/json" "os" "path/filepath" "sync" "testing" ) func tmpCache(t *testing.T) *Cache { t.Helper() return New(filepath.Join(t.TempDir(), "sub", "cache.json")) } func TestGeoRoundTrip(t *testing.T) { c := tmpCache(t) if _, ok := c.Geo("Krakow"); ok { t.Fatal("empty cache reported a hit") } want := Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"} if err := c.PutGeo("Krakow", want); err != nil { t.Fatal(err) } got, ok := c.Geo("Krakow") if !ok || got != want { t.Fatalf("got %+v (%v), want %+v", got, ok, want) } } // "" is a real answer -- not in Poland -- and must be distinguishable from // never having asked, or every foreign location re-queries GUGiK forever. func TestTerytEmptyStringIsARealAnswer(t *testing.T) { c := tmpCache(t) if _, ok := c.Teryt("52.5200,13.4000"); ok { t.Fatal("empty cache reported a hit") } if err := c.PutTeryt("52.5200,13.4000", ""); err != nil { t.Fatal(err) } code, ok := c.Teryt("52.5200,13.4000") if !ok { t.Fatal("a cached empty code must report as present") } if code != "" { t.Fatalf("code = %q, want empty", code) } } func TestCorruptFileIsTreatedAsEmpty(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "cache.json") if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { t.Fatal(err) } c := New(path) if _, ok := c.Geo("anything"); ok { t.Fatal("corrupt cache must read as empty, not error") } if err := c.PutGeo("x", Geo{Lat: 1}); err != nil { t.Fatalf("must be able to overwrite a corrupt cache: %v", err) } } func TestSaveLeavesNoTempFiles(t *testing.T) { dir := t.TempDir() c := New(filepath.Join(dir, "cache.json")) if err := c.PutGeo("a", Geo{Lat: 1}); err != nil { t.Fatal(err) } entries, _ := filepath.Glob(filepath.Join(dir, "*.tmp")) if len(entries) != 0 { t.Fatalf("temp files left behind: %v", entries) } } // The whole point of the atomic write: concurrent writers must never leave a // file that fails to parse. func TestConcurrentWritesKeepValidJSON(t *testing.T) { path := filepath.Join(t.TempDir(), "cache.json") var wg sync.WaitGroup for i := 0; i < 16; i++ { wg.Add(1) go func(i int) { defer wg.Done() New(path).PutGeo("place", Geo{Lat: float64(i)}) }(i) } wg.Wait() data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } var s store if err := json.Unmarshal(data, &s); err != nil { t.Fatalf("cache is not valid JSON after concurrent writes: %v\n%s", err, data) } } // The Python implementation shares this file and stores geo entries as // [lat, lon, label, country]. If Go writes an object instead, Python crashes on // its own cache -- which is exactly what happened once. func TestGeoIsStoredAsAnArrayForPythonInterop(t *testing.T) { path := filepath.Join(t.TempDir(), "cache.json") c := New(path) if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil { t.Fatal(err) } data, _ := os.ReadFile(path) var probe struct { Geo map[string][]any `json:"geo"` } if err := json.Unmarshal(data, &probe); err != nil { t.Fatalf("geo must decode as arrays: %v\n%s", err, data) } entry := probe.Geo["Krakow"] if len(entry) != 4 { t.Fatalf("geo entry = %v, want 4 elements", entry) } if entry[2] != "Krakow, PL" { t.Errorf("third element must be the label, got %v", entry[2]) } } // A cache written in the Python shape must load here unchanged. func TestReadsPythonWrittenCache(t *testing.T) { path := filepath.Join(t.TempDir(), "cache.json") body := `{"geo":{"Krakow":[50.06174,19.93732,"Krakow, PL","PL"]},"teryt":{"50.0617,19.9373":"1261"}}` if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } c := New(path) g, ok := c.Geo("Krakow") if !ok || g.Label != "Krakow, PL" || g.Country != "PL" { t.Fatalf("got %+v (%v)", g, ok) } if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1261" { t.Fatalf("teryt = %q (%v)", code, ok) } } // writeCache puts raw bytes where the cache expects its file. func writeCache(t *testing.T, c *Cache, body string) { t.Helper() if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(c.path, []byte(body), 0o644); err != nil { t.Fatal(err) } } // One unreadable entry used to cost the whole file: load treated the parse // error as "empty cache", and the next save wrote that emptiness over // everything that was still fine. func TestOneMalformedEntryDoesNotDestroyTheOthers(t *testing.T) { c := tmpCache(t) writeCache(t, c, `{"geo":{"Gdansk":[54.35227,18.64912,"Gdansk, PL","PL"],"Broken":[1]},"teryt":{"50.0617,19.9373":"1261"}}`) if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373, Label: "Krakow, PL", Country: "PL"}); err != nil { t.Fatal(err) } if _, ok := c.Geo("Gdansk"); !ok { t.Error("a good entry was destroyed by an unrelated malformed one") } if _, ok := c.Geo("Krakow"); !ok { t.Error("the new entry was not stored") } if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1261" { t.Errorf("teryt section lost too: got %q, %v", code, ok) } if _, ok := c.Geo("Broken"); ok { t.Error("the malformed entry should be dropped, not resurrected") } } // A hand-edit that breaks the whole document must not cost the file. The cache // is disposable, but whatever was typed into it is not, so it moves aside // rather than being overwritten -- and the run still gets a working cache. func TestAnUnparseableFileIsMovedAsideNotDestroyed(t *testing.T) { c := tmpCache(t) const broken = `{"geo":{"Gdansk":[54.35227,` writeCache(t, c, broken) if err := c.PutGeo("Krakow", Geo{Lat: 50.0617, Lon: 19.9373}); err != nil { t.Fatalf("the tool must keep working: %v", err) } kept, err := os.ReadFile(c.path + ".bad") if err != nil { t.Fatalf("the unparseable file was not preserved: %v", err) } if string(kept) != broken { t.Errorf("preserved copy differs:\n got %s\nwant %s", kept, broken) } if _, ok := c.Geo("Krakow"); !ok { t.Error("the fresh cache did not take the new entry") } } func TestPutTerytAlsoMovesAnUnparseableFileAside(t *testing.T) { c := tmpCache(t) const broken = `{oops` writeCache(t, c, broken) if err := c.PutTeryt("50.0617,19.9373", "1261"); err != nil { t.Fatalf("the tool must keep working: %v", err) } kept, err := os.ReadFile(c.path + ".bad") if err != nil || string(kept) != broken { t.Errorf("unparseable file not preserved: %q, %v", kept, err) } if code, ok := c.Teryt("50.0617,19.9373"); !ok || code != "1261" { t.Errorf("fresh cache did not take the entry: %q, %v", code, ok) } } // The file is read by a human at least as often as by the program. func TestSaveWritesOneEntryPerLine(t *testing.T) { c := tmpCache(t) if err := c.PutGeo("Krakow", Geo{Lat: 50.06174, Lon: 19.93732, Label: "Krakow, PL", Country: "PL"}); err != nil { t.Fatal(err) } if err := c.PutGeo("Chiang Mai", Geo{Lat: 18.79038, Lon: 98.98468, Label: "Chiang Mai, TH", Country: "TH"}); err != nil { t.Fatal(err) } if err := c.PutTeryt("50.0617,19.9373", "1261"); err != nil { t.Fatal(err) } body, err := os.ReadFile(c.path) if err != nil { t.Fatal(err) } want := `{ "geo": { "Chiang Mai": [18.79038, 98.98468, "Chiang Mai, TH", "TH"], "Krakow": [50.06174, 19.93732, "Krakow, PL", "PL"] }, "teryt": { "50.0617,19.9373": "1261" } } ` if string(body) != want { t.Errorf("got:\n%s\nwant:\n%s", body, want) } } func TestSavedFileIsStillValidJSONForTheOtherImplementation(t *testing.T) { c := tmpCache(t) want := Geo{Lat: 50.06174, Lon: 19.93732, Label: "Krakow, PL", Country: "PL"} if err := c.PutGeo("Krakow", want); err != nil { t.Fatal(err) } body, err := os.ReadFile(c.path) if err != nil { t.Fatal(err) } var got struct { Geo map[string][]any `json:"geo"` } if err := json.Unmarshal(body, &got); err != nil { t.Fatalf("a stock JSON parser could not read it: %v", err) } row := got.Geo["Krakow"] if len(row) != 4 { t.Fatalf("got %d fields, want the 4-element shape Python reads: %v", len(row), row) } if row[2] != "Krakow, PL" { t.Errorf("label field is %v, want the third element", row[2]) } } func TestEmptyCacheSavesReadableJSON(t *testing.T) { c := tmpCache(t) if err := c.PutTeryt("52.5200,13.4000", ""); err != nil { t.Fatal(err) } body, _ := os.ReadFile(c.path) if !json.Valid(body) { t.Fatalf("invalid JSON with an empty geo section:\n%s", body) } }