Skip to content

Commit b236563

Browse files
authored
Fix timestamp parsing (#34)
* test * all timestamps are microseconds * all nanos * add actual integration test * reduce token size * format * update test workflow * remove --no-daemon * remove srt print * handle string timestamps * remove java action cache * Rewrite reposetories
1 parent cccc5b8 commit b236563

10 files changed

Lines changed: 218 additions & 18 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ jobs:
1313
runs-on: ubuntu-latest
1414
steps:
1515
- name: Check out code
16-
uses: actions/checkout@v4
16+
uses: actions/checkout@v5.0.1
1717
with:
1818
fetch-depth: 0
1919

2020
# - name: Commit message lint
2121
# uses: wagoid/commitlint-github-action@v4
2222

2323
- name: Setup JDK 17
24-
uses: actions/setup-java@v4
24+
uses: actions/setup-java@v5.1.0
2525
with:
2626
distribution: 'corretto'
2727
java-version: '17'
@@ -35,5 +35,5 @@ jobs:
3535
STREAM_API_KEY: ${{ vars.STREAM_API_KEY }}
3636
STREAM_API_SECRET: ${{ secrets.STREAM_API_SECRET }}
3737
run: |
38-
./gradlew spotlessCheck --no-daemon
39-
./gradlew build --info --no-daemon
38+
./gradlew spotlessCheck
39+
./gradlew build --info

.github/workflows/scheduled_test.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,17 @@ jobs:
1111
environment: ci
1212
runs-on: ubuntu-latest
1313
steps:
14-
- uses: actions/checkout@v3
14+
- uses: actions/checkout@v5.0.1
15+
16+
- name: Setup JDK 17
17+
uses: actions/setup-java@v5.1.0
18+
with:
19+
distribution: 'corretto'
20+
java-version: '17'
21+
22+
- name: Setup Gradle
23+
uses: gradle/actions/setup-gradle@v4
24+
1525
- name: Run tests
1626
env:
1727
STREAM_BASE_URL: ${{ vars.STREAM_BASE_URL }}

build.gradle.kts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,6 @@ plugins {
1212
group = "io.getstream"
1313
description = "Stream official Java SDK"
1414

15-
repositories {
16-
mavenCentral()
17-
gradlePluginPortal()
18-
}
19-
20-
2115
java {
2216
toolchain {
2317
languageVersion = JavaLanguageVersion.of(21)

settings.gradle.kts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,18 @@
55
* For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.8/userguide/multi_project_builds.html in the Gradle documentation.
66
*/
77

8+
pluginManagement {
9+
repositories {
10+
gradlePluginPortal()
11+
mavenCentral()
12+
}
13+
}
14+
15+
dependencyResolutionManagement {
16+
repositories {
17+
mavenCentral()
18+
gradlePluginPortal()
19+
}
20+
}
21+
822
rootProject.name = "stream-sdk-java"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package io.getstream.services.framework;
2+
3+
import com.fasterxml.jackson.core.JsonParser;
4+
import com.fasterxml.jackson.core.JsonToken;
5+
import com.fasterxml.jackson.databind.DeserializationContext;
6+
import com.fasterxml.jackson.databind.JsonDeserializer;
7+
import com.fasterxml.jackson.databind.util.StdDateFormat;
8+
import java.io.IOException;
9+
import java.text.ParseException;
10+
import java.util.Date;
11+
12+
/**
13+
* Deserializer for timestamps that are represented as Unix nanoseconds. Converts nanosecond
14+
* timestamps to Java Date objects by dividing by 1,000,000 to get milliseconds.
15+
*/
16+
public class NanosecondTimestampDeserializer extends JsonDeserializer<Date> {
17+
@Override
18+
public Date deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
19+
throws IOException {
20+
21+
if (jsonParser.getCurrentToken() == JsonToken.VALUE_NUMBER_INT) {
22+
long value = jsonParser.getLongValue();
23+
// Convert nanoseconds to milliseconds by dividing by 1,000,000
24+
return new Date(value / 1_000_000L);
25+
}
26+
27+
// Fallback to parsing as RFC 3339/ISO-8601 string for non-numeric values
28+
if (jsonParser.getCurrentToken() == JsonToken.VALUE_STRING) {
29+
String dateString = jsonParser.getText();
30+
try {
31+
return StdDateFormat.instance.parse(dateString);
32+
} catch (ParseException e) {
33+
throw deserializationContext.weirdStringException(
34+
dateString, Date.class, "Unable to parse date string: " + dateString);
35+
}
36+
}
37+
38+
throw deserializationContext.wrongTokenException(
39+
jsonParser,
40+
Date.class,
41+
jsonParser.getCurrentToken(),
42+
"Expected number or string timestamp");
43+
}
44+
}

src/main/java/io/getstream/services/framework/StreamHTTPClient.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.fasterxml.jackson.databind.DeserializationFeature;
44
import com.fasterxml.jackson.databind.ObjectMapper;
55
import com.fasterxml.jackson.databind.SerializationFeature;
6+
import com.fasterxml.jackson.databind.module.SimpleModule;
67
import com.fasterxml.jackson.databind.util.StdDateFormat;
78
import io.jsonwebtoken.Jwts;
89
import io.jsonwebtoken.SignatureAlgorithm;
@@ -37,7 +38,10 @@ public class StreamHTTPClient {
3738
.withColonInTimeZone(true)
3839
.withTimeZone(TimeZone.getTimeZone("UTC")))
3940
.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE)
40-
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
41+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
42+
.registerModule(
43+
new SimpleModule()
44+
.addDeserializer(Date.class, new NanosecondTimestampDeserializer()));
4145

4246
@NotNull private String apiSecret;
4347
@NotNull private String apiKey;
@@ -79,10 +83,7 @@ public StreamHTTPClient(Properties properties) throws IllegalArgumentException {
7983
calendar.add(Calendar.SECOND, -5);
8084
return Jwts.builder()
8185
.issuedAt(new Date())
82-
.issuer("Stream Chat Java SDK")
83-
.subject("Stream Chat Java SDK")
8486
.claim("server", true)
85-
.claim("scope", "admins")
8687
.signWith(signingKey, SignatureAlgorithm.HS256)
8788
.compact();
8889
}

src/main/java/io/getstream/services/framework/TokenBuilder.java

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,6 @@ public static String createCallToken(@NotNull String apiSecret, @NotNull CallTok
6262
"call_cids", claims.getCallCIDs()))
6363
.issuedAt(Date.from(claims.getIssuedAt()))
6464
.expiration(Date.from(claims.getExpiresAt()))
65-
.issuer("Stream Java SDK")
66-
.subject("Stream Java SDK")
6765
.signWith(key, Jwts.SIG.HS256)
6866
.compact();
6967
}

src/test/java/io/getstream/CallTest.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,6 @@ void testGenerateSRTToken() {
305305
() -> testCall.createSRTCredentials(testUser.getId()).getAddress());
306306
Assertions.assertNotNull(srtToken);
307307
Assertions.assertNotEquals("", srtToken);
308-
System.out.println("SRT Token: " + srtToken);
309308
}
310309

311310
@Test

src/test/java/io/getstream/CommonTest.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,41 @@ void whenCheckingBadSns_thenError() {
129129
.getData());
130130
Assertions.assertEquals("error", response.getStatus());
131131
}
132+
133+
@Test
134+
void testUserTimestamps() throws Exception {
135+
// Use a fixed user ID
136+
String userId = "test-user-timestamps";
137+
UserRequest userRequest =
138+
UserRequest.builder()
139+
.id(userId)
140+
.name("Test User for Timestamps" + System.currentTimeMillis())
141+
.build();
142+
143+
UpdateUsersRequest updateUsersRequest =
144+
UpdateUsersRequest.builder().users(java.util.Map.of(userId, userRequest)).build();
145+
146+
// Upsert the user
147+
var response = client.updateUsers(updateUsersRequest).execute();
148+
FullUserResponse user = response.getData().getUsers().get(userId);
149+
150+
// Verify the user was created
151+
Assertions.assertNotNull(user, "User should not be null");
152+
Assertions.assertEquals(userId, user.getId(), "User ID should match");
153+
154+
// Verify updatedAt timestamp is not null
155+
Assertions.assertNotNull(user.getUpdatedAt(), "updatedAt timestamp should not be null");
156+
157+
// Verify updatedAt is reasonable (not in the future, not too old)
158+
long now = System.currentTimeMillis();
159+
long fiveMinutesAgo = now - (5 * 60 * 1000);
160+
long oneMinuteInFuture = now + (60 * 1000);
161+
162+
Assertions.assertTrue(
163+
user.getUpdatedAt().getTime() >= fiveMinutesAgo,
164+
"updatedAt should not be more than 5 minutes in the past");
165+
Assertions.assertTrue(
166+
user.getUpdatedAt().getTime() <= oneMinuteInFuture,
167+
"updatedAt should not be in the future");
168+
}
132169
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package io.getstream;
2+
3+
import static org.junit.jupiter.api.Assertions.*;
4+
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import io.getstream.models.MessageResponse;
7+
import io.getstream.services.framework.StreamHTTPClient;
8+
import java.util.Date;
9+
import org.junit.jupiter.api.BeforeAll;
10+
import org.junit.jupiter.api.Test;
11+
12+
public class StreamHTTPClientTest {
13+
private static StreamHTTPClient client;
14+
private static ObjectMapper objectMapper;
15+
16+
@BeforeAll
17+
static void setup() {
18+
// Initialize with test credentials (secret must be at least 32 characters for HS256)
19+
client = new StreamHTTPClient();
20+
objectMapper = client.getObjectMapper();
21+
}
22+
23+
@Test
24+
void testUnixNanosecondTimestampParsing() throws Exception {
25+
long timestampInNanos = 1704542400000000000L;
26+
long timestampInMillis = timestampInNanos / 1_000_000;
27+
28+
// Create a JSON response with unix microsecond timestamp
29+
String json =
30+
String.format(
31+
"""
32+
{
33+
"id": "test-message-id",
34+
"text": "Test message",
35+
"type": "regular",
36+
"created_at": %d,
37+
"updated_at": %d
38+
}
39+
""",
40+
timestampInNanos, timestampInNanos);
41+
42+
// Parse the JSON
43+
MessageResponse message = objectMapper.readValue(json, MessageResponse.class);
44+
45+
// Expected date: 2024-01-06 12:00:00 UTC
46+
Date expectedDate = new Date(timestampInMillis);
47+
48+
// Assert that the parsed date matches the expected date
49+
assertEquals(
50+
expectedDate.getTime(),
51+
message.getCreatedAt().getTime(),
52+
1000, // Allow 1 second tolerance
53+
String.format(
54+
"Expected timestamp %d (2024-01-06) but got %d (%s)",
55+
expectedDate.getTime(), message.getCreatedAt().getTime(), message.getCreatedAt()));
56+
57+
assertEquals(
58+
expectedDate.getTime(),
59+
message.getUpdatedAt().getTime(),
60+
1000, // Allow 1 second tolerance
61+
String.format(
62+
"Expected timestamp %d (2024-01-06) but got %d (%s)",
63+
expectedDate.getTime(), message.getUpdatedAt().getTime(), message.getUpdatedAt()));
64+
}
65+
66+
@Test
67+
void testRFC3339TimestampParsing() throws Exception {
68+
// Create a JSON response with RFC 3339 formatted timestamp
69+
String json =
70+
"""
71+
{
72+
"id": "test-message-id",
73+
"text": "Test message",
74+
"type": "regular",
75+
"created_at": "2024-01-06T12:00:00.000Z",
76+
"updated_at": "2024-01-06T12:00:00Z"
77+
}
78+
""";
79+
80+
// Parse the JSON
81+
MessageResponse message = objectMapper.readValue(json, MessageResponse.class);
82+
83+
// Expected date: 2024-01-06 12:00:00 UTC
84+
Date expectedDate = new Date(1704542400000L);
85+
86+
// Assert that the parsed date matches the expected date
87+
assertEquals(
88+
expectedDate.getTime(),
89+
message.getCreatedAt().getTime(),
90+
1000, // Allow 1 second tolerance
91+
String.format(
92+
"Expected timestamp %d (2024-01-06T12:00:00Z) but got %d (%s)",
93+
expectedDate.getTime(), message.getCreatedAt().getTime(), message.getCreatedAt()));
94+
95+
assertEquals(
96+
expectedDate.getTime(),
97+
message.getUpdatedAt().getTime(),
98+
1000, // Allow 1 second tolerance
99+
String.format(
100+
"Expected timestamp %d (2024-01-06T12:00:00Z) but got %d (%s)",
101+
expectedDate.getTime(), message.getUpdatedAt().getTime(), message.getUpdatedAt()));
102+
}
103+
}

0 commit comments

Comments
 (0)