provider-schedule.test.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { describe, expect, it } from "vitest";
  2. import { isProviderActiveNow } from "@/lib/utils/provider-schedule";
  3. describe("isProviderActiveNow", () => {
  4. // Helper: create a Date at a specific time in a given timezone
  5. function makeDate(hh: number, mm: number, timezone: string): Date {
  6. // Build a date string in the target timezone, then convert back to UTC
  7. const now = new Date();
  8. const year = now.getFullYear();
  9. const month = now.getMonth();
  10. const day = now.getDate();
  11. // Create a date formatter for the target timezone
  12. const formatter = new Intl.DateTimeFormat("en-US", {
  13. timeZone: timezone,
  14. year: "numeric",
  15. month: "2-digit",
  16. day: "2-digit",
  17. hour: "2-digit",
  18. minute: "2-digit",
  19. second: "2-digit",
  20. hour12: false,
  21. });
  22. // Get what the current time is in that timezone
  23. const parts = formatter.formatToParts(now);
  24. const getPart = (type: string) => parts.find((p) => p.type === type)?.value ?? "0";
  25. const currentHourInTz = parseInt(getPart("hour"), 10);
  26. const currentMinuteInTz = parseInt(getPart("minute"), 10);
  27. // Compute the offset in ms we need to shift
  28. const targetMinutes = hh * 60 + mm;
  29. const currentMinutes = currentHourInTz * 60 + currentMinuteInTz;
  30. const diffMs = (targetMinutes - currentMinutes) * 60 * 1000;
  31. return new Date(now.getTime() + diffMs);
  32. }
  33. describe("null/undefined inputs (always active)", () => {
  34. it("returns true when both start and end are null", () => {
  35. expect(isProviderActiveNow(null, null, "UTC")).toBe(true);
  36. });
  37. it("returns true when start is null and end is non-null", () => {
  38. expect(isProviderActiveNow(null, "18:00", "UTC")).toBe(true);
  39. });
  40. it("returns true when start is non-null and end is null", () => {
  41. expect(isProviderActiveNow("09:00", null, "UTC")).toBe(true);
  42. });
  43. });
  44. describe("same-day schedule (start < end)", () => {
  45. const cases = [
  46. { start: "09:00", end: "17:00", hour: 9, min: 0, expected: true, desc: "at start boundary" },
  47. { start: "09:00", end: "17:00", hour: 12, min: 30, expected: true, desc: "middle of window" },
  48. { start: "09:00", end: "17:00", hour: 16, min: 59, expected: true, desc: "just before end" },
  49. {
  50. start: "09:00",
  51. end: "17:00",
  52. hour: 17,
  53. min: 0,
  54. expected: false,
  55. desc: "at end boundary (exclusive)",
  56. },
  57. {
  58. start: "09:00",
  59. end: "17:00",
  60. hour: 8,
  61. min: 59,
  62. expected: false,
  63. desc: "just before start",
  64. },
  65. { start: "09:00", end: "17:00", hour: 23, min: 0, expected: false, desc: "well after end" },
  66. { start: "09:00", end: "17:00", hour: 0, min: 0, expected: false, desc: "midnight" },
  67. { start: "00:00", end: "23:59", hour: 12, min: 0, expected: true, desc: "nearly full day" },
  68. ];
  69. for (const { start, end, hour, min, expected, desc } of cases) {
  70. it(`${desc}: ${start}-${end} at ${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")} -> ${expected}`, () => {
  71. const now = makeDate(hour, min, "UTC");
  72. expect(isProviderActiveNow(start, end, "UTC", now)).toBe(expected);
  73. });
  74. }
  75. });
  76. describe("cross-day schedule (start > end)", () => {
  77. const cases = [
  78. { start: "22:00", end: "08:00", hour: 22, min: 0, expected: true, desc: "at start boundary" },
  79. { start: "22:00", end: "08:00", hour: 23, min: 30, expected: true, desc: "late night" },
  80. { start: "22:00", end: "08:00", hour: 0, min: 0, expected: true, desc: "midnight" },
  81. { start: "22:00", end: "08:00", hour: 3, min: 0, expected: true, desc: "early morning" },
  82. { start: "22:00", end: "08:00", hour: 7, min: 59, expected: true, desc: "just before end" },
  83. {
  84. start: "22:00",
  85. end: "08:00",
  86. hour: 8,
  87. min: 0,
  88. expected: false,
  89. desc: "at end boundary (exclusive)",
  90. },
  91. { start: "22:00", end: "08:00", hour: 12, min: 0, expected: false, desc: "midday" },
  92. {
  93. start: "22:00",
  94. end: "08:00",
  95. hour: 21,
  96. min: 59,
  97. expected: false,
  98. desc: "just before start",
  99. },
  100. ];
  101. for (const { start, end, hour, min, expected, desc } of cases) {
  102. it(`${desc}: ${start}-${end} at ${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")} -> ${expected}`, () => {
  103. const now = makeDate(hour, min, "UTC");
  104. expect(isProviderActiveNow(start, end, "UTC", now)).toBe(expected);
  105. });
  106. }
  107. });
  108. describe("edge cases", () => {
  109. it("start === end returns false (zero-width window)", () => {
  110. const now = makeDate(22, 0, "UTC");
  111. expect(isProviderActiveNow("22:00", "22:00", "UTC", now)).toBe(false);
  112. });
  113. it("start === end returns false even at different time", () => {
  114. const now = makeDate(10, 0, "UTC");
  115. expect(isProviderActiveNow("22:00", "22:00", "UTC", now)).toBe(false);
  116. });
  117. });
  118. describe("timezone support", () => {
  119. it("same UTC time yields different results in different timezones", () => {
  120. // At UTC 06:00, in Asia/Shanghai (UTC+8) it's 14:00
  121. // Schedule 09:00-17:00 should be active in Shanghai but not in UTC
  122. const utcDate = makeDate(6, 0, "UTC");
  123. // In UTC at 06:00 with schedule 09:00-17:00 -> inactive
  124. expect(isProviderActiveNow("09:00", "17:00", "UTC", utcDate)).toBe(false);
  125. // In Asia/Shanghai at 14:00 with schedule 09:00-17:00 -> active
  126. expect(isProviderActiveNow("09:00", "17:00", "Asia/Shanghai", utcDate)).toBe(true);
  127. });
  128. });
  129. describe("malformed input defense", () => {
  130. it("returns true (fail-open) for malformed start time", () => {
  131. const now = makeDate(12, 0, "UTC");
  132. expect(isProviderActiveNow("garbage", "17:00", "UTC", now)).toBe(true);
  133. });
  134. it("returns true (fail-open) for malformed end time", () => {
  135. const now = makeDate(12, 0, "UTC");
  136. expect(isProviderActiveNow("09:00", "not-a-time", "UTC", now)).toBe(true);
  137. });
  138. it("returns true (fail-open) for both times malformed", () => {
  139. const now = makeDate(12, 0, "UTC");
  140. expect(isProviderActiveNow("bad", "worse", "UTC", now)).toBe(true);
  141. });
  142. it("returns true (fail-open) for out-of-range hour (24:00)", () => {
  143. const now = makeDate(12, 0, "UTC");
  144. expect(isProviderActiveNow("24:00", "17:00", "UTC", now)).toBe(true);
  145. });
  146. it("returns true (fail-open) for single-digit hour (9:00)", () => {
  147. const now = makeDate(12, 0, "UTC");
  148. expect(isProviderActiveNow("9:00", "17:00", "UTC", now)).toBe(true);
  149. });
  150. it("returns true (fail-open) for out-of-range minutes (99:99)", () => {
  151. const now = makeDate(12, 0, "UTC");
  152. expect(isProviderActiveNow("99:99", "17:00", "UTC", now)).toBe(true);
  153. });
  154. });
  155. });