From 528f63908484d9d0df2a21b74f198dd1d192dd11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Mon, 13 Jul 2026 12:47:07 +0900 Subject: [PATCH 1/4] fix: encode RFC 3339 UTC offset as "Z" instead of "+0000" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC3339Strategy used the single "Z" date-format pattern, which emits an RFC 822 style offset ("+0000") for UTC. RFC 3339 requires the offset to be "Z" or "+00:00", so strict parsers such as Go's time.RFC3339 (behind gRPC-gateway) reject the value with HTTP 400. Switch the pattern to "ZZZZZ", which emits "Z" for UTC and "±HH:MM" for other offsets. Decoding is unaffected: "ZZZZZ" parses "-08:00", "-0800", "+0000", and "Z" identically to the previous pattern, so there is no backward-compatibility loss. Add an encoding regression test. Ref: IOS-4838 --- .../DateValue/Strategy/RFC3339Strategy.swift | 2 +- .../RFC3339StrategyEncodingTests.swift | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyEncodingTests.swift diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/Strategy/RFC3339Strategy.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/Strategy/RFC3339Strategy.swift index bc7fcd1..fe6c3bf 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DateValue/Strategy/RFC3339Strategy.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/Strategy/RFC3339Strategy.swift @@ -20,7 +20,7 @@ public struct RFC3339Strategy: DateValueCodableStrategy { let dateFormatter = DateFormatter() dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" return dateFormatter }() diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyEncodingTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyEncodingTests.swift new file mode 100644 index 0000000..874a2b6 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyEncodingTests.swift @@ -0,0 +1,24 @@ +// +// RFC3339StrategyEncodingTests.swift +// KarrotCodableKit +// +// Created by Elon on 7/13/26. +// + +import XCTest + +import KarrotCodableKit + +final class RFC3339StrategyEncodingTests: XCTestCase { + func testEncodingUTCDateUsesStandardCompliantOffset() throws { + // given + let date = Date(timeIntervalSince1970: 1715082540) // 2024-05-07T11:49:00 UTC + + // when + let result = RFC3339Strategy.encode(date) + + // then + // RFC 3339 requires the UTC offset to be "Z" (or "+00:00"), not the colon-less "+0000". + XCTAssertEqual(result, "2024-05-07T11:49:00Z") + } +} From e2c10d00a8516b726b637a16718c11a0644ff7d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Mon, 13 Jul 2026 15:46:13 +0900 Subject: [PATCH 2/4] test: cover RFC 3339 decode of legacy +0000 / +00:00 / Z offsets The "ZZZZZ" pattern only changes encoding output; DateFormatter still parses every offset form the previous "Z" pattern accepted, including the colon-less RFC 822 style "+0000" (verified with isLenient at both false and true). Add explicit decode coverage to guard backward compatibility, and rename the file to RFC3339StrategyTests since it now covers both encode and decode. Addresses CodeRabbit review on PR #27. --- .../RFC3339StrategyEncodingTests.swift | 24 -------- .../DateValue/RFC3339StrategyTests.swift | 58 +++++++++++++++++++ 2 files changed, 58 insertions(+), 24 deletions(-) delete mode 100644 Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyEncodingTests.swift create mode 100644 Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyEncodingTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyEncodingTests.swift deleted file mode 100644 index 874a2b6..0000000 --- a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyEncodingTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// RFC3339StrategyEncodingTests.swift -// KarrotCodableKit -// -// Created by Elon on 7/13/26. -// - -import XCTest - -import KarrotCodableKit - -final class RFC3339StrategyEncodingTests: XCTestCase { - func testEncodingUTCDateUsesStandardCompliantOffset() throws { - // given - let date = Date(timeIntervalSince1970: 1715082540) // 2024-05-07T11:49:00 UTC - - // when - let result = RFC3339Strategy.encode(date) - - // then - // RFC 3339 requires the UTC offset to be "Z" (or "+00:00"), not the colon-less "+0000". - XCTAssertEqual(result, "2024-05-07T11:49:00Z") - } -} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift new file mode 100644 index 0000000..4b22b1b --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift @@ -0,0 +1,58 @@ +// +// RFC3339StrategyTests.swift +// KarrotCodableKit +// +// Created by Elon on 7/13/26. +// + +import XCTest + +import KarrotCodableKit + +final class RFC3339StrategyTests: XCTestCase { + func testEncodingUTCDateUsesStandardCompliantOffset() throws { + // given + let date = Date(timeIntervalSince1970: 1715082540) // 2024-05-07T11:49:00 UTC + + // when + let result = RFC3339Strategy.encode(date) + + // then + // RFC 3339 requires the UTC offset to be "Z" (or "+00:00"), not the colon-less "+0000". + XCTAssertEqual(result, "2024-05-07T11:49:00Z") + } + + func testDecodingAcceptsAllUTCOffsetForms() throws { + // The "ZZZZZ" pattern must still decode every UTC offset form the previous "Z" + // pattern accepted, including the colon-less RFC 822 style "+0000". + let inputs = [ + "2024-05-07T11:49:00Z", + "2024-05-07T11:49:00+00:00", + "2024-05-07T11:49:00+0000" + ] + + for input in inputs { + // when + let date = try RFC3339Strategy.decode(input) + + // then + XCTAssertEqual(date, Date(timeIntervalSince1970: 1715082540), "failed to decode \(input)") + } + } + + func testDecodingAcceptsAllNonUTCOffsetForms() throws { + // Both the extended "-08:00" and the colon-less "-0800" forms must decode. + let inputs = [ + "1996-12-19T16:39:57-08:00", + "1996-12-19T16:39:57-0800" + ] + + for input in inputs { + // when + let date = try RFC3339Strategy.decode(input) + + // then + XCTAssertEqual(date, Date(timeIntervalSince1970: 851042397), "failed to decode \(input)") + } + } +} From 5727b7fc98d41108fbef5efc2bc036bde1bd4251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Mon, 13 Jul 2026 16:45:35 +0900 Subject: [PATCH 3/4] test: migrate RFC3339StrategyTests to Swift Testing Convert the XCTest class to a Swift Testing struct: plain @Test with #expect assertions and parameterized decode cases via @Test(arguments:). Behavior is unchanged; all encode/decode cases still pass. --- .../DateValue/RFC3339StrategyTests.swift | 61 +++++++++---------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift index 4b22b1b..620c9af 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift @@ -5,12 +5,15 @@ // Created by Elon on 7/13/26. // -import XCTest +import Foundation +import Testing import KarrotCodableKit -final class RFC3339StrategyTests: XCTestCase { - func testEncodingUTCDateUsesStandardCompliantOffset() throws { +struct RFC3339StrategyTests { + + @Test + func encodesUTCDateWithStandardCompliantOffset() { // given let date = Date(timeIntervalSince1970: 1715082540) // 2024-05-07T11:49:00 UTC @@ -19,40 +22,32 @@ final class RFC3339StrategyTests: XCTestCase { // then // RFC 3339 requires the UTC offset to be "Z" (or "+00:00"), not the colon-less "+0000". - XCTAssertEqual(result, "2024-05-07T11:49:00Z") + #expect(result == "2024-05-07T11:49:00Z") } - func testDecodingAcceptsAllUTCOffsetForms() throws { - // The "ZZZZZ" pattern must still decode every UTC offset form the previous "Z" - // pattern accepted, including the colon-less RFC 822 style "+0000". - let inputs = [ - "2024-05-07T11:49:00Z", - "2024-05-07T11:49:00+00:00", - "2024-05-07T11:49:00+0000" - ] - - for input in inputs { - // when - let date = try RFC3339Strategy.decode(input) - - // then - XCTAssertEqual(date, Date(timeIntervalSince1970: 1715082540), "failed to decode \(input)") - } - } + @Test(arguments: [ + "2024-05-07T11:49:00Z", + "2024-05-07T11:49:00+00:00", + "2024-05-07T11:49:00+0000" + ]) + func decodesAllUTCOffsetForms(input: String) throws { + // when + let date = try RFC3339Strategy.decode(input) - func testDecodingAcceptsAllNonUTCOffsetForms() throws { - // Both the extended "-08:00" and the colon-less "-0800" forms must decode. - let inputs = [ - "1996-12-19T16:39:57-08:00", - "1996-12-19T16:39:57-0800" - ] + // then + // "ZZZZZ" still parses every offset form the old "Z" pattern accepted, including "+0000". + #expect(date == Date(timeIntervalSince1970: 1715082540)) + } - for input in inputs { - // when - let date = try RFC3339Strategy.decode(input) + @Test(arguments: [ + "1996-12-19T16:39:57-08:00", + "1996-12-19T16:39:57-0800" + ]) + func decodesNonUTCOffsetsRegardlessOfColon(input: String) throws { + // when + let date = try RFC3339Strategy.decode(input) - // then - XCTAssertEqual(date, Date(timeIntervalSince1970: 851042397), "failed to decode \(input)") - } + // then + #expect(date == Date(timeIntervalSince1970: 851042397)) } } From 18808b41f4978f3692613f9730b56f46edee75fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elon=20Park=20=28=E1=84=8B=E1=85=A6=E1=86=AF=E1=84=85?= =?UTF-8?q?=E1=85=A9=E1=86=AB=29?= Date: Mon, 13 Jul 2026 19:50:42 +0900 Subject: [PATCH 4/4] test: expand RFC3339Strategy coverage as BDD suites Restructure RFC3339StrategyTests into @Suite-grouped behaviors (encoding, decoding, decoding failures) with given/when/then bodies. Add real-world coverage that serializes @DateValue via JSONEncoder and asserts a "Z" offset (not "+0000"), parameterized decode of every offset form, and a malformed-string case asserting DecodingError.dataCorrupted. --- .../DateValue/RFC3339StrategyTests.swift | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift index 620c9af..147591a 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/RFC3339StrategyTests.swift @@ -10,10 +10,20 @@ import Testing import KarrotCodableKit +@Suite("RFC3339Strategy") struct RFC3339StrategyTests { + private struct Fixture: Codable { + @DateValue var date: Date + } +} + +// MARK: - Encoding a Date + +extension RFC3339StrategyTests { + @Test - func encodesUTCDateWithStandardCompliantOffset() { + func encodesUTCDateWithZOffsetInsteadOfRFC822Offset() { // given let date = Date(timeIntervalSince1970: 1715082540) // 2024-05-07T11:49:00 UTC @@ -21,21 +31,41 @@ struct RFC3339StrategyTests { let result = RFC3339Strategy.encode(date) // then - // RFC 3339 requires the UTC offset to be "Z" (or "+00:00"), not the colon-less "+0000". + // RFC 3339 allows only "Z" or "+00:00" for UTC, never the colon-less "+0000". #expect(result == "2024-05-07T11:49:00Z") } + @Test + func serializesUTCDateWithZOffsetViaJSONEncoder() throws { + // given + let date = Date(timeIntervalSince1970: 1715082540) // 2024-05-07T11:49:00 UTC + let fixture = Fixture(date: date) + + // when + let data = try JSONEncoder().encode(fixture) + let json = try #require(String(bytes: data, encoding: .utf8)) + + // then + #expect(json.contains("\"2024-05-07T11:49:00Z\"")) + #expect(!json.contains("+0000")) + } +} + +// MARK: - Decoding an RFC 3339 string + +extension RFC3339StrategyTests { + @Test(arguments: [ "2024-05-07T11:49:00Z", "2024-05-07T11:49:00+00:00", "2024-05-07T11:49:00+0000" ]) - func decodesAllUTCOffsetForms(input: String) throws { + func decodesEveryUTCOffsetFormToSameInstant(input: String) throws { // when let date = try RFC3339Strategy.decode(input) // then - // "ZZZZZ" still parses every offset form the old "Z" pattern accepted, including "+0000". + // "ZZZZZ" still parses every offset form the old "Z" pattern accepted. #expect(date == Date(timeIntervalSince1970: 1715082540)) } @@ -43,7 +73,7 @@ struct RFC3339StrategyTests { "1996-12-19T16:39:57-08:00", "1996-12-19T16:39:57-0800" ]) - func decodesNonUTCOffsetsRegardlessOfColon(input: String) throws { + func decodesNonUTCOffsetsWithOrWithoutColon(input: String) throws { // when let date = try RFC3339Strategy.decode(input) @@ -51,3 +81,19 @@ struct RFC3339StrategyTests { #expect(date == Date(timeIntervalSince1970: 851042397)) } } + +// MARK: - Decoding a malformed string + +extension RFC3339StrategyTests { + + @Test + func throwsDataCorruptedErrorForMalformedString() { + // when / then + #expect { + try RFC3339Strategy.decode("not-a-valid-date") + } throws: { error in + guard case DecodingError.dataCorrupted = error else { return false } + return true + } + } +}