package services import ( "encoding/json" "strings" "testing" "time" "fwh-toolbox-server/domain" "fwh-toolbox-server/dto" "fwh-toolbox-server/repositories" "go.mongodb.org/mongo-driver/bson/primitive" ) func TestDiscoverFaultDevicesUsesCurrentScope(t *testing.T) { original := repositories.QuerySiteBaseInfoList t.Cleanup(func() { repositories.QuerySiteBaseInfoList = original }) repositories.QuerySiteBaseInfoList = func(_ dto.SiteBaseInfoPara) (*domain.FwhResponse[[]dto.SiteBaseInfo], error) { devices := []dto.SiteBaseInfo{{Id: "GATE-1", FhpSn: "POWER-1,POWER-2"}} return &domain.FwhResponse[[]dto.SiteBaseInfo]{Code: 200, Result: &devices}, nil } devices, err := discoverDataDownloadFaultDevices("GATE-1") if err != nil { t.Fatal(err) } if len(devices) != 3 || devices[0].Type != 0 || devices[1].Type != 0 || devices[2].Type != 1 { t.Fatalf("unexpected fault devices: %+v", devices) } } func TestDataDownloadLocationFixedOffset(t *testing.T) { loc := dataDownloadLocation("UTC-07.50") _, offset := time.Date(2026, 1, 1, 0, 0, 0, 0, loc).Zone() if offset != -7*3600-30*60 { t.Fatalf("offset = %d", offset) } } func TestDataDownloadEventAlarmTable(t *testing.T) { headers, rows := dataDownloadTable(domain.DataDownloadEventAlarm, []json.RawMessage{json.RawMessage(`{"id":null,"logTime":"2026-09-19 10:00:00","logType":"1","alarmCode":"A1","logName":"Alarm","startTime":"2026-09-19 09:59:00","endTime":null,"logData":"{}"}`)}) if len(headers) != 8 || headers[7] != "Data" { t.Fatalf("unexpected headers: %#v", headers) } if rows[0][0] != "1" || rows[0][6] != "null" { t.Fatalf("unexpected event row: %#v", rows[0]) } } func TestSupportedRenderFormats(t *testing.T) { if !supportedRenderFormat(domain.DataDownloadPowerFlow, "html") { t.Fatal("power flow HTML should be supported") } if supportedRenderFormat(domain.DataDownloadPowerFlow, "xlsx") { t.Fatal("power flow XLSX should not be supported") } if !supportedRenderFormat(domain.DataDownloadMqttLog, "csv") { t.Fatal("MQTT CSV should be supported") } if supportedRenderFormat(domain.DataDownloadUpdateLog, "csv") { t.Fatal("update CSV should not be supported") } } func TestCleanZipPath(t *testing.T) { if got := cleanZipPath("../../outside"); got != "outside" { t.Fatalf("unsafe path normalized to %q", got) } } func TestBuildDataDownloadRequestKeyByKind(t *testing.T) { tests := []struct { name string item domain.DataDownloadItem want string }{ { name: "power flow includes timezone", item: domain.DataDownloadItem{Kind: domain.DataDownloadPowerFlow, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"}, want: "dd-request-v1|kind=power_flow|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles", }, { name: "mqtt includes subtype and timezone", item: domain.DataDownloadItem{Kind: domain.DataDownloadMqttLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "201", Timezone: "America/Los_Angeles"}, want: "dd-request-v1|kind=mqtt_log|content_version=v1|site_sn=SN001|date=2026-09-10|subtype=201|timezone=America%2FLos_Angeles", }, { name: "event alarm includes timezone", item: domain.DataDownloadItem{Kind: domain.DataDownloadEventAlarm, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"}, want: "dd-request-v1|kind=event_alarm|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles", }, { name: "alarm snapshot includes timezone", item: domain.DataDownloadItem{Kind: domain.DataDownloadAlarmSnapshot, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"}, want: "dd-request-v1|kind=alarm_snapshot|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles", }, { name: "EMS ignores timezone", item: domain.DataDownloadItem{Kind: domain.DataDownloadEmsLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "2", Timezone: "America/Los_Angeles"}, want: "dd-request-v1|kind=ems_log|content_version=v1|site_sn=SN001|date=2026-09-10|subtype=2", }, { name: "device scoped fault record", item: domain.DataDownloadItem{Kind: domain.DataDownloadFaultRecord, SiteSN: "SN001", DeviceSN: "DEV001", DeviceType: "aPower", Date: "2026-09-10", Timezone: "UTC-07.00"}, want: "dd-request-v1|kind=fault_record|content_version=v2|device_sn=DEV001|device_type=aPower|date=2026-09-10|timezone=UTC-07.00", }, { name: "update log ignores date subtype and timezone", item: domain.DataDownloadItem{Kind: domain.DataDownloadUpdateLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "unused", Timezone: "America/Los_Angeles"}, want: "dd-request-v1|kind=update_log|content_version=v1|site_sn=SN001", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := buildDataDownloadRequestKey(&tt.item) if err != nil { t.Fatal(err) } if got != tt.want { t.Fatalf("request key = %q, want %q", got, tt.want) } }) } } func TestBuildDataDownloadRequestKeyIdentityStability(t *testing.T) { base := domain.DataDownloadItem{ TaskID: primitive.NewObjectID(), ItemKey: "task-item-a", Kind: domain.DataDownloadMqttLog, SiteSN: "SN|001", Date: "2026-09-10", Subtype: "cmd=201", Timezone: "America/Los_Angeles", } want, err := buildDataDownloadRequestKey(&base) if err != nil { t.Fatal(err) } changedTaskFields := base changedTaskFields.TaskID = primitive.NewObjectID() changedTaskFields.ItemKey = "task-item-b" got, err := buildDataDownloadRequestKey(&changedTaskFields) if err != nil { t.Fatal(err) } if got != want { t.Fatalf("task-only fields changed request identity: %q != %q", got, want) } changedContent := base changedContent.Timezone = "Asia/Shanghai" got, err = buildDataDownloadRequestKey(&changedContent) if err != nil { t.Fatal(err) } if got == want { t.Fatal("content-affecting timezone did not change request identity") } if !strings.Contains(want, "site_sn=SN%7C001") || !strings.Contains(want, "subtype=cmd%3D201") { t.Fatalf("request key fields were not safely escaped: %q", want) } } func TestBuildDataDownloadRequestKeyRejectsInvalidIdentity(t *testing.T) { tests := []domain.DataDownloadItem{ {Kind: "unknown", SiteSN: "SN001"}, {Kind: domain.DataDownloadMqttLog, SiteSN: "SN001", Date: "2026-09-10", Timezone: "UTC"}, {Kind: domain.DataDownloadPowerFlow, SiteSN: "SN001", Date: "not-a-date", Timezone: "UTC"}, {Kind: domain.DataDownloadFaultRecord, SiteSN: "SN001", DeviceSN: "DEV001", Date: "2026-09-10", Timezone: "UTC"}, } for i := range tests { if _, err := buildDataDownloadRequestKey(&tests[i]); err == nil { t.Fatalf("case %d: expected invalid identity error", i) } } } func TestDataDownloadFaultRecordDeviceSN(t *testing.T) { aPower, mppt, aHub := "AP-1", "MPPT-1", "HUB-1" tests := []struct { record domain.FaultRecordInfo want string }{ {record: domain.FaultRecordInfo{DeviceType: 0, ApowerId: &aPower}, want: aPower}, {record: domain.FaultRecordInfo{DeviceType: 1, GatewayId: "GATE-1"}, want: "GATE-1"}, {record: domain.FaultRecordInfo{DeviceType: 2, ApowerId: &mppt}, want: mppt}, {record: domain.FaultRecordInfo{DeviceType: 3, PowerlinkId: &aHub}, want: aHub}, } for _, tt := range tests { got, err := dataDownloadFaultRecordDeviceSN(tt.record) if err != nil { t.Fatal(err) } if got != tt.want { t.Fatalf("device SN = %q, want %q", got, tt.want) } } if _, err := dataDownloadFaultRecordDeviceSN(domain.FaultRecordInfo{DeviceType: 4}); err == nil { t.Fatal("future device types must require an explicit identity rule") } } func TestFaultRecordDocumentStateDPlusTwo(t *testing.T) { loc := time.FixedZone("UTC-7", -7*60*60) day := time.Date(2026, 9, 10, 0, 0, 0, 0, loc) before := time.Date(2026, 9, 11, 23, 59, 59, 0, loc) atBoundary := time.Date(2026, 9, 12, 0, 0, 0, 0, loc) if state, frozenAt := faultRecordDocumentState(before, loc, day, 0); state != domain.DataDownloadMutable || frozenAt != nil { t.Fatalf("before D+2 = %s, %v", state, frozenAt) } if state, frozenAt := faultRecordDocumentState(atBoundary, loc, day, 0); state != domain.DataDownloadFrozen || frozenAt == nil { t.Fatalf("at D+2 = %s, %v", state, frozenAt) } if state, frozenAt := faultRecordDocumentState(atBoundary, loc, day, 1); state != domain.DataDownloadMutable || frozenAt != nil { t.Fatalf("failed D+2 request = %s, %v", state, frozenAt) } } // TestStripTimeLocation 锁定 OOM 根因修复:把带时区的时间剥离为同字面值的 UTC。 func TestStripTimeLocation(t *testing.T) { chicago, err := time.LoadLocation("America/Chicago") if err != nil { t.Skipf("时区数据不可用: %v", err) } // 复现 DataDownload 的入参:本地时区解析出的当日起止。 start := time.Date(2026, 9, 19, 0, 0, 0, 0, chicago) end := start.AddDate(0, 0, 1).Add(-time.Nanosecond) startQuery := stripTimeLocation(start) endQuery := stripTimeLocation(end) // 剥离后必须为 UTC,且字面 年/月/日/时/分/秒 与原值一致。 if startQuery.Location() != time.UTC || endQuery.Location() != time.UTC { t.Fatalf("剥离后应为 UTC: start=%v end=%v", startQuery.Location(), endQuery.Location()) } if startQuery.Year() != 2026 || startQuery.Month() != 9 || startQuery.Day() != 19 || startQuery.Hour() != 0 || startQuery.Minute() != 0 || startQuery.Second() != 0 { t.Fatalf("start 字面值被改变: %v", startQuery) } // 关键回归点:用剥离后的 UTC 起点走上游同款两步游标推进(Truncate 按 UTC 对齐 + time.Date 归零), // 下一天游标必须严格前进到 09-20,而不是回退到当天导致死循环。 dayEnd := time.Date(startQuery.Year(), startQuery.Month(), startQuery.Day(), 23, 59, 59, 0, startQuery.Location()) truncated := dayEnd.Add(time.Second).Truncate(24 * time.Hour) next := time.Date(truncated.Year(), truncated.Month(), truncated.Day(), 0, 0, 0, 0, truncated.Location()) if !next.After(startQuery) { t.Fatalf("游标未前进(会死循环): start=%v next=%v", startQuery, next) } if next.Year() != 2026 || next.Month() != 9 || next.Day() != 20 { t.Fatalf("游标应前进到 09-20,实际=%v", next) } // 反证:若不剥离时区(直接用 Chicago 时间),上游完整两步推进会把游标拉回当天 00:00 → 死循环。 // 第一步 Truncate(24h) 按 UTC 对齐后落在当天 19:00(-0500),第二步 time.Date(..,0,0,0,loc) // 归零到当天 00:00,游标回退、未越过当天,currTime.Before(endTime) 恒成立。 badDayEnd := time.Date(start.Year(), start.Month(), start.Day(), 23, 59, 59, 0, start.Location()) badTruncated := badDayEnd.Add(time.Second).Truncate(24 * time.Hour) badNext := time.Date(badTruncated.Year(), badTruncated.Month(), badTruncated.Day(), 0, 0, 0, 0, badTruncated.Location()) if badNext.After(start) { t.Fatalf("预期未剥离时区会回退到当天起点,但游标前进了: start=%v badNext=%v", start, badNext) } // 零值需原样返回。 if got := stripTimeLocation(time.Time{}); !got.IsZero() { t.Fatalf("零值应原样返回: %v", got) } }