RepairSkuDurationTest.java 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import java.time.LocalDateTime;
  2. import java.util.Arrays;
  3. import java.util.Collections;
  4. import java.util.List;
  5. import java.util.regex.Pattern;
  6. /** Standalone regression checks; does not connect to a database. */
  7. public final class RepairSkuDurationTest {
  8. private static int checks;
  9. public static void main(String[] args) {
  10. exactYearIsCandidateForBothSkus();
  11. calendarMonthArithmeticHandlesLeapDaysAndMonthEnds();
  12. timestampDifferencesRequireReview();
  13. missingDatesAndOtherDurationsAreSkipped();
  14. unrelatedSkusAreSkipped();
  15. renewalAndMultiplePurchasesRequireReview();
  16. quantityMeansDifferentThingsForLegacyAndSlotOrders();
  17. missingOrMismatchedPurchaseRequiresReview();
  18. adjustedAndSuspendedTicketsRequireReview();
  19. specialAcquisitionRequiresReview();
  20. generatedSqlMatchesTheOldSnapshot();
  21. nullableUpdatedSnapshotIsPreserved();
  22. System.out.println("RepairSkuDurationTest: " + checks + " checks passed");
  23. }
  24. private static void exactYearIsCandidateForBothSkus() {
  25. for (long skuId : new long[]{772L, 774L}) {
  26. RepairSkuDuration.Ticket ticket = ticket();
  27. ticket.skuId = skuId;
  28. RepairSkuDuration.Decision decision = classify(ticket);
  29. kind("candidate", decision, "exact year, SKU " + skuId);
  30. equal(ticket.expiry.minusMonths(6), decision.newExpiry,
  31. "candidate subtracts six calendar months from expiry");
  32. equal(0.0, decision.deltaSeconds, "exact year has no timestamp difference");
  33. }
  34. RepairSkuDuration.Ticket outside = ticket();
  35. outside.status = "outside";
  36. kind("candidate", classify(outside), "occupied extra seat is supported");
  37. }
  38. private static void calendarMonthArithmeticHandlesLeapDaysAndMonthEnds() {
  39. RepairSkuDuration.Ticket leap = ticket();
  40. leap.start = time("2024-02-29T10:20:30");
  41. leap.expiry = time("2025-02-28T10:20:30");
  42. RepairSkuDuration.Decision leapDecision = classify(leap);
  43. kind("candidate", leapDecision, "leap-day year uses calendar arithmetic");
  44. equal(time("2024-08-28T10:20:30"), leapDecision.newExpiry,
  45. "subtract from actual expiry; do not calculate start plus six months");
  46. RepairSkuDuration.Ticket monthEnd = ticket();
  47. monthEnd.start = time("2025-08-31T10:20:30");
  48. monthEnd.expiry = time("2026-08-31T10:20:30");
  49. RepairSkuDuration.Decision monthEndDecision = classify(monthEnd);
  50. kind("candidate", monthEndDecision, "month-end year is recognized");
  51. equal(time("2026-02-28T10:20:30"), monthEndDecision.newExpiry,
  52. "six-month subtraction clamps to the last day of February");
  53. RepairSkuDuration.Ticket leapFebruary = ticket();
  54. leapFebruary.start = time("2023-08-31T10:20:30");
  55. leapFebruary.expiry = time("2024-08-31T10:20:30");
  56. equal(time("2024-02-29T10:20:30"), classify(leapFebruary).newExpiry,
  57. "six-month subtraction preserves leap February's final day");
  58. }
  59. private static void timestampDifferencesRequireReview() {
  60. for (long seconds : new long[]{-1, 1, -30, 30}) {
  61. RepairSkuDuration.Ticket ticket = ticket();
  62. ticket.expiry = ticket.expiry.plusSeconds(seconds);
  63. RepairSkuDuration.Decision decision = classify(ticket);
  64. kind("review", decision, "timestamp difference " + seconds + " seconds");
  65. equal(Math.abs((double) seconds), Math.abs(decision.deltaSeconds),
  66. "report the timestamp difference accurately");
  67. }
  68. RepairSkuDuration.Ticket manualTime = ticket();
  69. manualTime.expiry = manualTime.expiry.toLocalDate().atStartOfDay();
  70. kind("review", classify(manualTime), "same anniversary date with a different time");
  71. }
  72. private static void missingDatesAndOtherDurationsAreSkipped() {
  73. RepairSkuDuration.Ticket noStart = ticket();
  74. noStart.start = null;
  75. kind("skipped", classify(noStart), "missing start time");
  76. RepairSkuDuration.Ticket noExpiry = ticket();
  77. noExpiry.expiry = null;
  78. kind("skipped", classify(noExpiry), "missing expiry time");
  79. RepairSkuDuration.Ticket noDates = ticket();
  80. noDates.start = null;
  81. noDates.expiry = null;
  82. kind("skipped", classify(noDates), "ticket has never been activated");
  83. for (int months : new int[]{1, 6, 18, 24}) {
  84. RepairSkuDuration.Ticket ticket = ticket();
  85. ticket.expiry = ticket.start.plusMonths(months);
  86. kind("skipped", classify(ticket), "nonannual duration " + months + " months");
  87. }
  88. }
  89. private static void unrelatedSkusAreSkipped() {
  90. for (long skuId : new long[]{773L, 775L, 1L}) {
  91. RepairSkuDuration.Ticket ticket = ticket();
  92. ticket.skuId = skuId;
  93. kind("skipped", classify(ticket), "unrelated SKU " + skuId);
  94. }
  95. }
  96. private static void renewalAndMultiplePurchasesRequireReview() {
  97. RepairSkuDuration.Ticket ticket = ticket();
  98. RepairSkuDuration.Purchase renewal = purchase(ticket);
  99. renewal.orderType = 2;
  100. kind("review", classify(ticket, Collections.singletonList(renewal), 0),
  101. "a renewal must not be mistaken for an incorrectly configured first purchase");
  102. RepairSkuDuration.Purchase first = purchase(ticket);
  103. RepairSkuDuration.Purchase second = purchase(ticket);
  104. second.id++;
  105. kind("review", classify(ticket, Arrays.asList(first, second), 0),
  106. "two purchases may legitimately add up to a year");
  107. second.orderType = 2;
  108. kind("review", classify(ticket, Arrays.asList(first, second), 0),
  109. "original purchase plus renewal requires review");
  110. }
  111. private static void quantityMeansDifferentThingsForLegacyAndSlotOrders() {
  112. RepairSkuDuration.Ticket ticket = ticket();
  113. RepairSkuDuration.Purchase purchase = purchase(ticket);
  114. purchase.quantity = 2;
  115. kind("review", classify(ticket, Collections.singletonList(purchase), 0),
  116. "legacy purchase quantity can extend one ticket's duration");
  117. purchase.slot = true;
  118. RepairSkuDuration.Decision decision = classify(ticket, Collections.singletonList(purchase), 0);
  119. kind("candidate", decision, "a successful delivery slot represents one ticket");
  120. equal(ticket.expiry.minusMonths(6), decision.newExpiry,
  121. "slot delivery subtracts six months once, regardless of order quantity");
  122. }
  123. private static void missingOrMismatchedPurchaseRequiresReview() {
  124. RepairSkuDuration.Ticket ticket = ticket();
  125. kind("review", classify(ticket, Collections.emptyList(), 0),
  126. "missing purchase does not prove that the annual duration is wrong");
  127. RepairSkuDuration.Purchase purchase = purchase(ticket);
  128. purchase.skuId = 775L;
  129. kind("review", classify(ticket, Collections.singletonList(purchase), 0),
  130. "current ticket SKU differs from the purchase SKU");
  131. }
  132. private static void adjustedAndSuspendedTicketsRequireReview() {
  133. RepairSkuDuration.Ticket ticket = ticket();
  134. kind("review", classify(ticket, Collections.singletonList(purchase(ticket)), 1),
  135. "manual adjustment history requires review even if the dates total one year");
  136. RepairSkuDuration.Ticket compensated = ticket();
  137. compensated.compensationDays = 3;
  138. kind("review", classify(compensated), "compensated ticket");
  139. RepairSkuDuration.Ticket frozen = ticket();
  140. frozen.frozen = true;
  141. kind("review", classify(frozen), "frozen ticket");
  142. RepairSkuDuration.Ticket recovering = ticket();
  143. recovering.recover = true;
  144. kind("review", classify(recovering), "ticket being restored");
  145. RepairSkuDuration.Ticket reserved = ticket();
  146. reserved.reservedDays = 15;
  147. kind("review", classify(reserved), "ticket with reserved duration");
  148. }
  149. private static void specialAcquisitionRequiresReview() {
  150. RepairSkuDuration.Ticket vip = ticket();
  151. vip.vip = true;
  152. kind("review", classify(vip), "VIP ticket");
  153. RepairSkuDuration.Ticket trial = ticket();
  154. trial.aqType = 2;
  155. kind("review", classify(trial), "trial acquisition");
  156. }
  157. private static void generatedSqlMatchesTheOldSnapshot() {
  158. RepairSkuDuration.Ticket ticket = ticket();
  159. String sql = normalizeSql(RepairSkuDuration.updateSql(ticket));
  160. containsPattern(sql, "\\bupdate\\s+groups_relation\\b", "SQL updates the ticket table");
  161. snapshotNumber(sql, "id", ticket.id);
  162. snapshotNumber(sql, "groups_id", ticket.groupsId);
  163. snapshotNumber(sql, "user_id", ticket.userId);
  164. snapshotNumber(sql, "sku_id", ticket.skuId);
  165. snapshotDate(sql, "start_time", "2026-01-15 10:20:30");
  166. snapshotDate(sql, "expiry_time", "2027-01-15 10:20:30");
  167. snapshotDate(sql, "update_time", "2026-01-15 10:21:40");
  168. containsPattern(sql, "\\bwhere\\b", "SQL contains a guarded WHERE clause");
  169. }
  170. private static void nullableUpdatedSnapshotIsPreserved() {
  171. RepairSkuDuration.Ticket ticket = ticket();
  172. ticket.updated = null;
  173. String sql = normalizeSql(RepairSkuDuration.updateSql(ticket));
  174. containsPattern(sql, "\\bupdate_time\\s*(?:is\\s+null|<=>\\s*null)\\b",
  175. "null update timestamp is compared as null");
  176. }
  177. private static RepairSkuDuration.Ticket ticket() {
  178. RepairSkuDuration.Ticket ticket = new RepairSkuDuration.Ticket();
  179. ticket.id = 880011L;
  180. ticket.groupsId = 660022L;
  181. ticket.skuId = 772L;
  182. ticket.userId = 440033L;
  183. ticket.status = "validity";
  184. ticket.start = time("2026-01-15T10:20:30");
  185. ticket.expiry = time("2027-01-15T10:20:30");
  186. ticket.updated = time("2026-01-15T10:21:40");
  187. ticket.aqType = 1;
  188. return ticket;
  189. }
  190. private static RepairSkuDuration.Purchase purchase(RepairSkuDuration.Ticket ticket) {
  191. RepairSkuDuration.Purchase purchase = new RepairSkuDuration.Purchase();
  192. purchase.id = 330044L;
  193. purchase.skuId = ticket.skuId;
  194. purchase.orderType = 1;
  195. purchase.quantity = 1;
  196. return purchase;
  197. }
  198. private static RepairSkuDuration.Decision classify(RepairSkuDuration.Ticket ticket) {
  199. return classify(ticket, Collections.singletonList(purchase(ticket)), 0);
  200. }
  201. private static RepairSkuDuration.Decision classify(RepairSkuDuration.Ticket ticket,
  202. List<RepairSkuDuration.Purchase> purchases,
  203. int adjustmentCount) {
  204. return RepairSkuDuration.classify(ticket, purchases, adjustmentCount);
  205. }
  206. private static LocalDateTime time(String value) {
  207. return LocalDateTime.parse(value);
  208. }
  209. private static void kind(String expected, RepairSkuDuration.Decision decision, String description) {
  210. equal(expected, decision.kind, description + "; reason=" + decision.reason);
  211. }
  212. private static void equal(Object expected, Object actual, String description) {
  213. checks++;
  214. if (expected == null ? actual != null : !expected.equals(actual)) {
  215. throw new AssertionError(description + ": expected=" + expected + ", actual=" + actual);
  216. }
  217. }
  218. private static String normalizeSql(String sql) {
  219. return sql.replace("`", "").replaceAll("\\s+", " ");
  220. }
  221. private static void snapshotNumber(String sql, String column, long value) {
  222. containsPattern(sql, "\\b" + column + "\\s*(?:=|<=>)\\s*" + value + "\\b",
  223. "SQL guards the original " + column);
  224. }
  225. private static void snapshotDate(String sql, String column, String value) {
  226. containsPattern(sql, "\\b" + column + "\\s*(?:=|<=>)\\s*'" + Pattern.quote(value)
  227. + "(?:\\.0+)?'", "SQL guards the original " + column);
  228. }
  229. private static void containsPattern(String value, String regex, String description) {
  230. checks++;
  231. if (!Pattern.compile(regex, Pattern.CASE_INSENSITIVE).matcher(value).find()) {
  232. throw new AssertionError(description + ": expected pattern=" + regex + ", SQL=" + value);
  233. }
  234. }
  235. }