dataDownloadService_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. package services
  2. import (
  3. "encoding/json"
  4. "strings"
  5. "testing"
  6. "time"
  7. "fwh-toolbox-server/domain"
  8. "fwh-toolbox-server/dto"
  9. "fwh-toolbox-server/repositories"
  10. "go.mongodb.org/mongo-driver/bson/primitive"
  11. )
  12. func TestDiscoverFaultDevicesUsesCurrentScope(t *testing.T) {
  13. original := repositories.QuerySiteBaseInfoList
  14. t.Cleanup(func() { repositories.QuerySiteBaseInfoList = original })
  15. repositories.QuerySiteBaseInfoList = func(_ dto.SiteBaseInfoPara) (*domain.FwhResponse[[]dto.SiteBaseInfo], error) {
  16. devices := []dto.SiteBaseInfo{{Id: "GATE-1", FhpSn: "POWER-1,POWER-2"}}
  17. return &domain.FwhResponse[[]dto.SiteBaseInfo]{Code: 200, Result: &devices}, nil
  18. }
  19. devices, err := discoverDataDownloadFaultDevices("GATE-1")
  20. if err != nil {
  21. t.Fatal(err)
  22. }
  23. if len(devices) != 3 || devices[0].Type != 0 || devices[1].Type != 0 || devices[2].Type != 1 {
  24. t.Fatalf("unexpected fault devices: %+v", devices)
  25. }
  26. }
  27. func TestDataDownloadLocationFixedOffset(t *testing.T) {
  28. loc := dataDownloadLocation("UTC-07.50")
  29. _, offset := time.Date(2026, 1, 1, 0, 0, 0, 0, loc).Zone()
  30. if offset != -7*3600-30*60 {
  31. t.Fatalf("offset = %d", offset)
  32. }
  33. }
  34. func TestDataDownloadEventAlarmTable(t *testing.T) {
  35. 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":"{}"}`)})
  36. if len(headers) != 8 || headers[7] != "Data" {
  37. t.Fatalf("unexpected headers: %#v", headers)
  38. }
  39. if rows[0][0] != "1" || rows[0][6] != "null" {
  40. t.Fatalf("unexpected event row: %#v", rows[0])
  41. }
  42. }
  43. func TestSupportedRenderFormats(t *testing.T) {
  44. if !supportedRenderFormat(domain.DataDownloadPowerFlow, "html") {
  45. t.Fatal("power flow HTML should be supported")
  46. }
  47. if supportedRenderFormat(domain.DataDownloadPowerFlow, "xlsx") {
  48. t.Fatal("power flow XLSX should not be supported")
  49. }
  50. if !supportedRenderFormat(domain.DataDownloadMqttLog, "csv") {
  51. t.Fatal("MQTT CSV should be supported")
  52. }
  53. if supportedRenderFormat(domain.DataDownloadUpdateLog, "csv") {
  54. t.Fatal("update CSV should not be supported")
  55. }
  56. }
  57. func TestCleanZipPath(t *testing.T) {
  58. if got := cleanZipPath("../../outside"); got != "outside" {
  59. t.Fatalf("unsafe path normalized to %q", got)
  60. }
  61. }
  62. func TestBuildDataDownloadRequestKeyByKind(t *testing.T) {
  63. tests := []struct {
  64. name string
  65. item domain.DataDownloadItem
  66. want string
  67. }{
  68. {
  69. name: "power flow includes timezone",
  70. item: domain.DataDownloadItem{Kind: domain.DataDownloadPowerFlow, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"},
  71. want: "dd-request-v1|kind=power_flow|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles",
  72. },
  73. {
  74. name: "mqtt includes subtype and timezone",
  75. item: domain.DataDownloadItem{Kind: domain.DataDownloadMqttLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "201", Timezone: "America/Los_Angeles"},
  76. want: "dd-request-v1|kind=mqtt_log|content_version=v1|site_sn=SN001|date=2026-09-10|subtype=201|timezone=America%2FLos_Angeles",
  77. },
  78. {
  79. name: "event alarm includes timezone",
  80. item: domain.DataDownloadItem{Kind: domain.DataDownloadEventAlarm, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"},
  81. want: "dd-request-v1|kind=event_alarm|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles",
  82. },
  83. {
  84. name: "alarm snapshot includes timezone",
  85. item: domain.DataDownloadItem{Kind: domain.DataDownloadAlarmSnapshot, SiteSN: "SN001", Date: "2026-09-10", Timezone: "America/Los_Angeles"},
  86. want: "dd-request-v1|kind=alarm_snapshot|content_version=v1|site_sn=SN001|date=2026-09-10|timezone=America%2FLos_Angeles",
  87. },
  88. {
  89. name: "EMS ignores timezone",
  90. item: domain.DataDownloadItem{Kind: domain.DataDownloadEmsLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "2", Timezone: "America/Los_Angeles"},
  91. want: "dd-request-v1|kind=ems_log|content_version=v1|site_sn=SN001|date=2026-09-10|subtype=2",
  92. },
  93. {
  94. name: "device scoped fault record",
  95. item: domain.DataDownloadItem{Kind: domain.DataDownloadFaultRecord, SiteSN: "SN001", DeviceSN: "DEV001", DeviceType: "aPower", Date: "2026-09-10", Timezone: "UTC-07.00"},
  96. want: "dd-request-v1|kind=fault_record|content_version=v2|device_sn=DEV001|device_type=aPower|date=2026-09-10|timezone=UTC-07.00",
  97. },
  98. {
  99. name: "update log ignores date subtype and timezone",
  100. item: domain.DataDownloadItem{Kind: domain.DataDownloadUpdateLog, SiteSN: "SN001", Date: "2026-09-10", Subtype: "unused", Timezone: "America/Los_Angeles"},
  101. want: "dd-request-v1|kind=update_log|content_version=v1|site_sn=SN001",
  102. },
  103. }
  104. for _, tt := range tests {
  105. t.Run(tt.name, func(t *testing.T) {
  106. got, err := buildDataDownloadRequestKey(&tt.item)
  107. if err != nil {
  108. t.Fatal(err)
  109. }
  110. if got != tt.want {
  111. t.Fatalf("request key = %q, want %q", got, tt.want)
  112. }
  113. })
  114. }
  115. }
  116. func TestBuildDataDownloadRequestKeyIdentityStability(t *testing.T) {
  117. base := domain.DataDownloadItem{
  118. TaskID: primitive.NewObjectID(),
  119. ItemKey: "task-item-a",
  120. Kind: domain.DataDownloadMqttLog,
  121. SiteSN: "SN|001",
  122. Date: "2026-09-10",
  123. Subtype: "cmd=201",
  124. Timezone: "America/Los_Angeles",
  125. }
  126. want, err := buildDataDownloadRequestKey(&base)
  127. if err != nil {
  128. t.Fatal(err)
  129. }
  130. changedTaskFields := base
  131. changedTaskFields.TaskID = primitive.NewObjectID()
  132. changedTaskFields.ItemKey = "task-item-b"
  133. got, err := buildDataDownloadRequestKey(&changedTaskFields)
  134. if err != nil {
  135. t.Fatal(err)
  136. }
  137. if got != want {
  138. t.Fatalf("task-only fields changed request identity: %q != %q", got, want)
  139. }
  140. changedContent := base
  141. changedContent.Timezone = "Asia/Shanghai"
  142. got, err = buildDataDownloadRequestKey(&changedContent)
  143. if err != nil {
  144. t.Fatal(err)
  145. }
  146. if got == want {
  147. t.Fatal("content-affecting timezone did not change request identity")
  148. }
  149. if !strings.Contains(want, "site_sn=SN%7C001") || !strings.Contains(want, "subtype=cmd%3D201") {
  150. t.Fatalf("request key fields were not safely escaped: %q", want)
  151. }
  152. }
  153. func TestBuildDataDownloadRequestKeyRejectsInvalidIdentity(t *testing.T) {
  154. tests := []domain.DataDownloadItem{
  155. {Kind: "unknown", SiteSN: "SN001"},
  156. {Kind: domain.DataDownloadMqttLog, SiteSN: "SN001", Date: "2026-09-10", Timezone: "UTC"},
  157. {Kind: domain.DataDownloadPowerFlow, SiteSN: "SN001", Date: "not-a-date", Timezone: "UTC"},
  158. {Kind: domain.DataDownloadFaultRecord, SiteSN: "SN001", DeviceSN: "DEV001", Date: "2026-09-10", Timezone: "UTC"},
  159. }
  160. for i := range tests {
  161. if _, err := buildDataDownloadRequestKey(&tests[i]); err == nil {
  162. t.Fatalf("case %d: expected invalid identity error", i)
  163. }
  164. }
  165. }
  166. func TestDataDownloadFaultRecordDeviceSN(t *testing.T) {
  167. aPower, mppt, aHub := "AP-1", "MPPT-1", "HUB-1"
  168. tests := []struct {
  169. record domain.FaultRecordInfo
  170. want string
  171. }{
  172. {record: domain.FaultRecordInfo{DeviceType: 0, ApowerId: &aPower}, want: aPower},
  173. {record: domain.FaultRecordInfo{DeviceType: 1, GatewayId: "GATE-1"}, want: "GATE-1"},
  174. {record: domain.FaultRecordInfo{DeviceType: 2, ApowerId: &mppt}, want: mppt},
  175. {record: domain.FaultRecordInfo{DeviceType: 3, PowerlinkId: &aHub}, want: aHub},
  176. }
  177. for _, tt := range tests {
  178. got, err := dataDownloadFaultRecordDeviceSN(tt.record)
  179. if err != nil {
  180. t.Fatal(err)
  181. }
  182. if got != tt.want {
  183. t.Fatalf("device SN = %q, want %q", got, tt.want)
  184. }
  185. }
  186. if _, err := dataDownloadFaultRecordDeviceSN(domain.FaultRecordInfo{DeviceType: 4}); err == nil {
  187. t.Fatal("future device types must require an explicit identity rule")
  188. }
  189. }
  190. func TestFaultRecordDocumentStateDPlusTwo(t *testing.T) {
  191. loc := time.FixedZone("UTC-7", -7*60*60)
  192. day := time.Date(2026, 9, 10, 0, 0, 0, 0, loc)
  193. before := time.Date(2026, 9, 11, 23, 59, 59, 0, loc)
  194. atBoundary := time.Date(2026, 9, 12, 0, 0, 0, 0, loc)
  195. if state, frozenAt := faultRecordDocumentState(before, loc, day, 0); state != domain.DataDownloadMutable || frozenAt != nil {
  196. t.Fatalf("before D+2 = %s, %v", state, frozenAt)
  197. }
  198. if state, frozenAt := faultRecordDocumentState(atBoundary, loc, day, 0); state != domain.DataDownloadFrozen || frozenAt == nil {
  199. t.Fatalf("at D+2 = %s, %v", state, frozenAt)
  200. }
  201. if state, frozenAt := faultRecordDocumentState(atBoundary, loc, day, 1); state != domain.DataDownloadMutable || frozenAt != nil {
  202. t.Fatalf("failed D+2 request = %s, %v", state, frozenAt)
  203. }
  204. }
  205. // TestStripTimeLocation 锁定 OOM 根因修复:把带时区的时间剥离为同字面值的 UTC。
  206. func TestStripTimeLocation(t *testing.T) {
  207. chicago, err := time.LoadLocation("America/Chicago")
  208. if err != nil {
  209. t.Skipf("时区数据不可用: %v", err)
  210. }
  211. // 复现 DataDownload 的入参:本地时区解析出的当日起止。
  212. start := time.Date(2026, 9, 19, 0, 0, 0, 0, chicago)
  213. end := start.AddDate(0, 0, 1).Add(-time.Nanosecond)
  214. startQuery := stripTimeLocation(start)
  215. endQuery := stripTimeLocation(end)
  216. // 剥离后必须为 UTC,且字面 年/月/日/时/分/秒 与原值一致。
  217. if startQuery.Location() != time.UTC || endQuery.Location() != time.UTC {
  218. t.Fatalf("剥离后应为 UTC: start=%v end=%v", startQuery.Location(), endQuery.Location())
  219. }
  220. if startQuery.Year() != 2026 || startQuery.Month() != 9 || startQuery.Day() != 19 ||
  221. startQuery.Hour() != 0 || startQuery.Minute() != 0 || startQuery.Second() != 0 {
  222. t.Fatalf("start 字面值被改变: %v", startQuery)
  223. }
  224. // 关键回归点:用剥离后的 UTC 起点走上游同款两步游标推进(Truncate 按 UTC 对齐 + time.Date 归零),
  225. // 下一天游标必须严格前进到 09-20,而不是回退到当天导致死循环。
  226. dayEnd := time.Date(startQuery.Year(), startQuery.Month(), startQuery.Day(), 23, 59, 59, 0, startQuery.Location())
  227. truncated := dayEnd.Add(time.Second).Truncate(24 * time.Hour)
  228. next := time.Date(truncated.Year(), truncated.Month(), truncated.Day(), 0, 0, 0, 0, truncated.Location())
  229. if !next.After(startQuery) {
  230. t.Fatalf("游标未前进(会死循环): start=%v next=%v", startQuery, next)
  231. }
  232. if next.Year() != 2026 || next.Month() != 9 || next.Day() != 20 {
  233. t.Fatalf("游标应前进到 09-20,实际=%v", next)
  234. }
  235. // 反证:若不剥离时区(直接用 Chicago 时间),上游完整两步推进会把游标拉回当天 00:00 → 死循环。
  236. // 第一步 Truncate(24h) 按 UTC 对齐后落在当天 19:00(-0500),第二步 time.Date(..,0,0,0,loc)
  237. // 归零到当天 00:00,游标回退、未越过当天,currTime.Before(endTime) 恒成立。
  238. badDayEnd := time.Date(start.Year(), start.Month(), start.Day(), 23, 59, 59, 0, start.Location())
  239. badTruncated := badDayEnd.Add(time.Second).Truncate(24 * time.Hour)
  240. badNext := time.Date(badTruncated.Year(), badTruncated.Month(), badTruncated.Day(), 0, 0, 0, 0, badTruncated.Location())
  241. if badNext.After(start) {
  242. t.Fatalf("预期未剥离时区会回退到当天起点,但游标前进了: start=%v badNext=%v", start, badNext)
  243. }
  244. // 零值需原样返回。
  245. if got := stripTimeLocation(time.Time{}); !got.IsZero() {
  246. t.Fatalf("零值应原样返回: %v", got)
  247. }
  248. }