| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- 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)
- }
- }
|