From f3f9d1cb224355f60c2f1d4dce70d0118dfea925 Mon Sep 17 00:00:00 2001 From: masatoi Date: Mon, 31 Aug 2026 16:41:24 +0900 Subject: [PATCH] =?UTF-8?q?fix:=2012=E6=99=82=E9=96=93=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=81=AEiOS=E7=AB=AF=E6=9C=AB=E3=81=A7=E6=97=A5=E4=BB=98?= =?UTF-8?q?=E3=81=8C=E5=A3=8A=E3=82=8C=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APIJSONEncoder の DateFormatter に locale が設定されておらず、Locale.current が 使われていた。固定フォーマットの DateFormatter に en_US_POSIX を指定しないと、 端末が12時間表示のとき Foundation が "HH" を "h" + AM/PM に書き換える (QA1480)。 その結果 Flutter チャネルに渡る日付が ISO8601 でなくなり、Dart 側の DateTime.parse が FormatException を投げていた。 ja_JP: 2026-11-20T午前6:56:43.091000Z en_US: 2026-11-20T6:56:43.091000 AMZ レスポンスの日付を文字列化しているのは APIJSONEncoder のみで、生成側の AutogenMethodHandlers.swift も全 150 箇所が self.after 経由のため、この1行で 手書き・生成の両経路が直る。 回帰テストとして、ネイティブが壊れた日付を返したことを Dart 側で検知する テストを追加した (異常系の文字列は iOS 26.5 シミュレータでの実測値)。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W5pXxkWbYKDAnQZDpYu1RG --- ios/Classes/SwiftPokepaySdkPlugin.swift | 6 ++ test/responses/check_test.dart | 138 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 test/responses/check_test.dart diff --git a/ios/Classes/SwiftPokepaySdkPlugin.swift b/ios/Classes/SwiftPokepaySdkPlugin.swift index 9a1801d..5bf4ef2 100644 --- a/ios/Classes/SwiftPokepaySdkPlugin.swift +++ b/ios/Classes/SwiftPokepaySdkPlugin.swift @@ -11,6 +11,12 @@ private class APIJSONEncoder : JSONEncoder, @unchecked Sendable { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + // A fixed-format DateFormatter must always use en_US_POSIX (Apple QA1480). + // Otherwise Locale.current is used, and Foundation rewrites "HH" into + // "h" plus an AM/PM marker when the device is set to 12-hour time. The + // date handed to Flutter then stops being ISO8601 and DateTime.parse + // throws a FormatException on the Dart side. + dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateEncodingStrategy = .formatted(dateFormatter) } } diff --git a/test/responses/check_test.dart b/test/responses/check_test.dart new file mode 100644 index 0000000..c16619a --- /dev/null +++ b/test/responses/check_test.dart @@ -0,0 +1,138 @@ +import 'package:test/test.dart'; + +import 'package:pokepay_sdk/responses/check.dart'; +import 'package:pokepay_sdk/responses/token_info.dart'; + +/// Mimics the Check JSON the native side pushes over the MethodChannel +/// (iOS: APIJSONEncoder in SwiftPokepaySdkPlugin, Android: Response.toString +/// in pokepaylib). Only expires_at varies, so each test can check how a +/// particular date format is handled. +Map checkJson(String expiresAt) => { + 'id': 'b3f04a3f-0a17-42a4-85f1-1376a4f07c07', + 'amount': 1000.0, + 'money_amount': 1000.0, + 'point_amount': 0.0, + 'description': 'topup QR', + 'user': { + 'id': '4b4a2d5a-4d5c-4c1a-9c2f-2c5c9a5b1d3e', + 'name': 'test user', + 'is_merchant': false, + }, + 'private_money': { + 'id': '9f1a0c33-6b4e-4a52-8d4a-1a2b3c4d5e6f', + 'name': 'test money', + 'type': 'own', + 'unit': '円', + 'description': 'money for tests', + 'oneline_message': '', + 'account_image': null, + 'images': { + 'card': null, + '300x300': null, + '600x600': null, + }, + 'organization': { + 'code': 'test', + 'name': 'test organization', + }, + 'max_balance': null, + 'transfer_limit': null, + 'expiration_type': 'unlimited', + 'is_exclusive': false, + 'terms_url': null, + 'privacy_policy_url': null, + 'payment_act_url': null, + 'commercial_act_url': null, + 'can_use_credit_card': false, + 'can_use_c2c_transfer': false, + 'custom_domain_name': null, + 'topup_methods': null, + }, + 'is_onetime': true, + 'is_disabled': false, + 'expires_at': expiresAt, + 'point_expires_at': null, + 'point_expires_in_days': null, + 'token': 'b3f04a3f-0a17-42a4-85f1-1376a4f07c07', + }; + +void main() { + group('Check.fromJson expires_at', () { + test('parses the ISO8601 the server returns (6-digit microseconds)', () { + final check = Check.fromJson(checkJson('2026-11-20T06:56:43.091034Z')); + + expect(check.expiresAt.isUtc, isTrue); + expect( + check.expiresAt.toUtc().toIso8601String(), + '2026-11-20T06:56:43.091034Z', + ); + }); + + test('parses the ISO8601 the native side returns (millisecond precision ' + 'zero-padded to 6 digits)', () { + // The iOS APIJSONEncoder writes dates with the format + // "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'", so the last three digits are + // always 000. + final check = Check.fromJson(checkJson('2026-11-20T06:56:43.091000Z')); + + // toIso8601String() omits trailing zeros, so compare the value itself. + expect( + check.expiresAt.toUtc(), + DateTime.utc(2026, 11, 20, 6, 56, 43, 91, 0), + ); + }); + + // Regression tests: if the iOS DateFormatter is left without + // locale = en_US_POSIX, Foundation rewrites "HH" into "h" plus an AM/PM + // marker while the device is set to 12-hour time (Apple QA1480). The + // expires_at that reaches Dart then stops being ISO8601 and fails here. + // See APIJSONEncoder in ios/Classes/SwiftPokepaySdkPlugin.swift. + // + // The strings below are what the pre-fix APIJSONEncoder actually emitted + // on the iOS 26.5 simulator with Settings > General > Date & Time > + // 24-Hour Time turned off. + test('throws FormatException for the broken date a 12-hour ja_JP device ' + 'returns', () { + // Measured: {"expires_at":"2026-11-20T午前6:56:43.091000Z", ...} + expect( + () => Check.fromJson(checkJson('2026-11-20T午前6:56:43.091000Z')), + throwsA(isA()), + ); + }); + + test('throws FormatException for the broken date a 12-hour en_US device ' + 'returns', () { + // Measured: {"expires_at":"2026-11-20T6:56:43.091000 AMZ", ...} + expect( + () => Check.fromJson(checkJson('2026-11-20T6:56:43.091000 AMZ')), + throwsA(isA()), + ); + }); + }); + + group('TokenInfo.fromJson (the topup QR path of getTokenInfo)', () { + test('restores a CHECK token as a Check', () { + final info = TokenInfo.fromJson({ + 'type': 'CHECK', + 'token': checkJson('2026-11-20T06:56:43.091034Z'), + }); + + expect(info.type, TokenType.CHECK); + expect(info.token, isA()); + expect( + (info.token as Check).expiresAt.toUtc().toIso8601String(), + '2026-11-20T06:56:43.091034Z', + ); + }); + + test('throws FormatException for a CHECK token carrying a broken date', () { + expect( + () => TokenInfo.fromJson({ + 'type': 'CHECK', + 'token': checkJson('2026-11-20T午前6:56:43.091000Z'), + }), + throwsA(isA()), + ); + }); + }); +}