// Copyright 2026 tamakyi. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package report import ( "context" "testing" "time" "github.com/stretchr/testify/require" "github.com/tamakyi/TamaBox/internal/db" "github.com/tamakyi/TamaBox/internal/security/geoip" ) // fakeGeoStore 内存版缓存,避免测试依赖数据库。 type fakeGeoStore struct { rows map[string]*db.IPGeoRecord upserts int } func newFakeGeoStore() *fakeGeoStore { return &fakeGeoStore{rows: map[string]*db.IPGeoRecord{}} } func (f *fakeGeoStore) GetMany(_ context.Context, keys []string) (map[string]*db.IPGeoRecord, error) { out := map[string]*db.IPGeoRecord{} for _, k := range keys { if r, ok := f.rows[k]; ok { out[k] = r } } return out, nil } func (f *fakeGeoStore) UpsertMany(_ context.Context, records []*db.IPGeoRecord) error { f.upserts += len(records) for _, r := range records { f.rows[r.IPKey] = r } return nil } func (f *fakeGeoStore) PurgeResolvedBefore(context.Context, time.Time, bool) (int64, error) { return 0, nil } func (f *fakeGeoStore) Count(context.Context) (int64, error) { return int64(len(f.rows)), nil } // fakeResolver 记录被查询的键,按预设返回。 // // 三态要能分别构造:result 里的是查到了,unresolved 里的是服务明确说没有, // 两者都没有的算请求失败(failures)。 type fakeResolver struct { enabled bool result map[string]geoip.Geo unresolved map[string]bool failures int queried []string } func (f *fakeResolver) Lookup(_ context.Context, keys []string) geoip.Result { f.queried = append(f.queried, keys...) r := geoip.Result{ Geo: map[string]geoip.Geo{}, Unresolved: map[string]bool{}, Requests: 1, Failures: f.failures, } for _, k := range keys { if g, ok := f.result[k]; ok { r.Geo[k] = g continue } if f.unresolved[k] { r.Unresolved[k] = true } } return r } func (f *fakeResolver) Enabled() bool { return f.enabled } // TestResolveWithCache 覆盖核心缓存语义:命中不查、未命中才查、明确无数据也回写。 func TestResolveWithCache(t *testing.T) { ctx := context.Background() store := newFakeGeoStore() now := time.Now() // 已缓存的旧记录(30 天前,仍在 90 天 TTL 内) store.rows["1.1.1.1"] = &db.IPGeoRecord{ IPKey: "1.1.1.1", Country: "澳大利亚", CountryCode: "AU", City: "Sydney", HasCoords: true, Lat: -33.8, Lon: 151.2, Source: "ipapi", ResolvedAt: now.AddDate(0, 0, -30), } // 已缓存的失败记录(1 天前,仍在 3 天 TTL 内)→ 不重试 store.rows["2.2.2.2"] = &db.IPGeoRecord{ IPKey: "2.2.2.2", Failed: true, ResolvedAt: now.AddDate(0, 0, -1), } resolver := &fakeResolver{ enabled: true, result: map[string]geoip.Geo{ "3.3.3.3": {Country: "德国", CountryCode: "DE", City: "Berlin", Lat: 52.5, Lon: 13.4, HasCoords: true, Source: "ipapi"}, }, // 4.4.4.4:服务明确回答查不到 unresolved: map[string]bool{"4.4.4.4": true}, } got := ResolveWithCache(ctx, store, resolver, []string{"1.1.1.1", "2.2.2.2", "3.3.3.3", "4.4.4.4"}) // 缓存命中的值要能取回来 require.Equal(t, "澳大利亚", got["1.1.1.1"].Country) // 失败记录不进结果,也不重新查询 require.NotContains(t, got, "2.2.2.2") // 未命中的查到了 require.Equal(t, "德国", got["3.3.3.3"].Country) // 查不到的也不进结果 require.NotContains(t, got, "4.4.4.4") // 只应查询两个真正需要查的键 require.ElementsMatch(t, []string{"3.3.3.3", "4.4.4.4"}, resolver.queried) // 两条未命中的都要写回缓存(成功的 + 明确无数据的) require.Equal(t, 2, store.upserts) require.True(t, store.rows["4.4.4.4"].Failed, "服务明确说没数据时必须落 Failed 标记,否则下一份报告会反复重试") require.False(t, store.rows["3.3.3.3"].Failed) } // TestResolveWithCacheNetworkFailure 是「缓存毒化」的回归测试。 // // 网络失败(超时 / 限流 / 网关 5xx)绝不能被记成「这个 IP 没有位置」: // 一旦写进缓存就是 3 天,此后每份报告的定位覆盖率都会莫名其妙变成 0。 func TestResolveWithCacheNetworkFailure(t *testing.T) { ctx := context.Background() store := newFakeGeoStore() // 三个键全都没查成:result 与 unresolved 都是空的 resolver := &fakeResolver{enabled: true, failures: 1} got := ResolveWithCache(ctx, store, resolver, []string{"8.8.8.8", "9.9.9.9"}) require.Empty(t, got) require.Len(t, resolver.queried, 2, "失败也要算查过了,否则同一轮里会重复发请求") require.Equal(t, 0, store.upserts, "请求失败绝不能写缓存") require.Empty(t, store.rows) } // TestResolveWithCacheTTL 覆盖过期语义:成功记录 90 天后、失败记录 3 天后都要重查。 func TestResolveWithCacheTTL(t *testing.T) { ctx := context.Background() now := time.Now() store := newFakeGeoStore() store.rows["5.5.5.5"] = &db.IPGeoRecord{ IPKey: "5.5.5.5", Country: "旧数据", HasCoords: true, ResolvedAt: now.AddDate(0, 0, -91), // 超过 90 天 } store.rows["6.6.6.6"] = &db.IPGeoRecord{ IPKey: "6.6.6.6", Failed: true, ResolvedAt: now.AddDate(0, 0, -4), // 超过 3 天 } resolver := &fakeResolver{ enabled: true, result: map[string]geoip.Geo{ "5.5.5.5": {Country: "新数据", CountryCode: "US", Source: "ipapi", HasCoords: true}, "6.6.6.6": {Country: "韩国", CountryCode: "KR", Source: "ipapi", HasCoords: true}, }, } got := ResolveWithCache(ctx, store, resolver, []string{"5.5.5.5", "6.6.6.6"}) require.Equal(t, "新数据", got["5.5.5.5"].Country) require.Equal(t, "韩国", got["6.6.6.6"].Country, "过期的失败记录必须重试") require.Len(t, resolver.queried, 2) } // TestResolveWithCacheDisabled 覆盖 ip_geo_provider = none:一个字节都不外发。 func TestResolveWithCacheDisabled(t *testing.T) { ctx := context.Background() store := newFakeGeoStore() resolver := &fakeResolver{enabled: false} got := ResolveWithCache(ctx, store, resolver, []string{"1.2.3.4"}) require.Empty(t, got) require.Empty(t, resolver.queried) require.Equal(t, 0, store.upserts) } // TestResolveWithCacheDedup 覆盖重复键只查一次。 func TestResolveWithCacheDedup(t *testing.T) { ctx := context.Background() store := newFakeGeoStore() resolver := &fakeResolver{ enabled: true, result: map[string]geoip.Geo{"7.7.7.7": {Country: "法国", Source: "ipapi"}}, } ResolveWithCache(ctx, store, resolver, []string{"7.7.7.7", "7.7.7.7", "7.7.7.7"}) require.Equal(t, []string{"7.7.7.7"}, resolver.queried) require.Equal(t, 1, store.upserts) }