From 5c7fc8494290e5b391e944de7ed2d1e48e703180 Mon Sep 17 00:00:00 2001
From: Jordan Sim-Smith <jordansimsmith@canva.com>
Date: Mon, 26 Aug 2024 17:43:11 +1200
Subject: [PATCH] Refactor dependency injection to support test implementations

---
 immersion_tracker_api/BUILD.bazel             | 15 ++++++---
 .../immersiontracker/GetProgressHandler.java  |  7 ++--
 .../ImmersionTrackerFactory.java              | 32 ++++++++----------
 .../ImmersionTrackerModule.java               | 27 ---------------
 .../GetProgressHandlerIntegrationTest.java    | 31 ++++++++++-------
 .../ImmersionTrackerTestFactory.java          | 33 +++++++++++++++++++
 lib/dynamodb/BUILD.bazel                      | 16 +++++++++
 .../dynamodb/DynamoDbModule.java              | 22 +++++++++++++
 .../dynamodb/DynamoDbTestModule.java          | 24 ++++++++++++++
 lib/json/BUILD.bazel                          | 15 +++++++++
 .../json/ObjectMapperModule.java              | 13 ++++++++
 lib/time/BUILD.bazel                          | 14 ++++++++
 .../java/com/jordansimsmith/time/Clock.java   |  7 ++++
 .../com/jordansimsmith/time/ClockModule.java  | 12 +++++++
 .../jordansimsmith/time/ClockTestModule.java  | 17 ++++++++++
 .../com/jordansimsmith/time/FakeClock.java    | 22 +++++++++++++
 .../com/jordansimsmith/time/SystemClock.java  | 10 ++++++
 17 files changed, 254 insertions(+), 63 deletions(-)
 create mode 100644 immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/ImmersionTrackerTestFactory.java
 create mode 100644 lib/dynamodb/BUILD.bazel
 create mode 100644 lib/dynamodb/src/main/java/com/jordansimsmith/dynamodb/DynamoDbModule.java
 create mode 100644 lib/dynamodb/src/main/java/com/jordansimsmith/dynamodb/DynamoDbTestModule.java
 create mode 100644 lib/json/BUILD.bazel
 create mode 100644 lib/json/src/main/java/com/jordansimsmith/json/ObjectMapperModule.java
 create mode 100644 lib/time/BUILD.bazel
 create mode 100644 lib/time/src/main/java/com/jordansimsmith/time/Clock.java
 create mode 100644 lib/time/src/main/java/com/jordansimsmith/time/ClockModule.java
 create mode 100644 lib/time/src/main/java/com/jordansimsmith/time/ClockTestModule.java
 create mode 100644 lib/time/src/main/java/com/jordansimsmith/time/FakeClock.java
 create mode 100644 lib/time/src/main/java/com/jordansimsmith/time/SystemClock.java

diff --git a/immersion_tracker_api/BUILD.bazel b/immersion_tracker_api/BUILD.bazel
index 77647cf..e2cc55e 100644
--- a/immersion_tracker_api/BUILD.bazel
+++ b/immersion_tracker_api/BUILD.bazel
@@ -13,11 +13,12 @@ java_library(
     ),
     deps = [
         ":dagger",
-        "@maven//:com_google_code_findbugs_jsr305",
-        "@maven//:com_google_guava_guava",
+        "//lib/dynamodb:lib",
+        "//lib/json:lib",
+        "//lib/time:lib",
+        "@maven//:com_fasterxml_jackson_core_jackson_databind",
         "@maven//:software_amazon_awssdk_dynamodb",
         "@maven//:software_amazon_awssdk_dynamodb_enhanced",
-        "@maven//:com_fasterxml_jackson_core_jackson_databind",
     ],
 )
 
@@ -30,6 +31,7 @@ java_binary(
     ]),
     deps = [
         ":lib",
+        "//lib/time:lib",
         "@maven//:ch_qos_logback_logback_classic",
         "@maven//:ch_qos_logback_logback_core",
         "@maven//:com_amazonaws_aws_lambda_java_core",
@@ -38,7 +40,6 @@ java_binary(
         "@maven//:com_fasterxml_jackson_core_jackson_databind",
         "@maven//:com_google_code_findbugs_jsr305",
         "@maven//:com_google_guava_guava",
-        "@maven//:software_amazon_awssdk_dynamodb",
         "@maven//:software_amazon_awssdk_dynamodb_enhanced",
     ],
 )
@@ -57,12 +58,16 @@ java_test_suite(
     test_suffixes = ["IntegrationTest.java"],
     runtime_deps = JUNIT5_DEPS,
     deps = [
+        ":dagger",
         ":get-progress-handler",
         ":lib",
+        "//lib/dynamodb:lib",
+        "//lib/json:lib",
         "//lib/testcontainers:lib",
+        "//lib/time:lib",
         "@maven//:com_amazonaws_aws_lambda_java_core",
-        "@maven//:com_fasterxml_jackson_core_jackson_databind",
         "@maven//:com_amazonaws_aws_lambda_java_events",
+        "@maven//:com_fasterxml_jackson_core_jackson_databind",
         "@maven//:org_assertj_assertj_core",
         "@maven//:org_junit_jupiter_junit_jupiter_api",
         "@maven//:org_testcontainers_junit_jupiter",
diff --git a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetProgressHandler.java b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetProgressHandler.java
index 2868d52..073204c 100644
--- a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetProgressHandler.java
+++ b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/GetProgressHandler.java
@@ -7,6 +7,7 @@
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.annotations.VisibleForTesting;
+import com.jordansimsmith.time.Clock;
 import java.time.Instant;
 import java.time.ZoneId;
 import java.time.temporal.ChronoUnit;
@@ -23,8 +24,9 @@
 public class GetProgressHandler
     implements RequestHandler<APIGatewayV2HTTPEvent, APIGatewayV2HTTPResponse> {
   private static final int MINUTES_PER_EPISODE = 20;
-  private static final ZoneId AUCKLAND_ZONE_ID = ZoneId.of("Pacific/Auckland");
+  @VisibleForTesting static final ZoneId ZONE_ID = ZoneId.of("Pacific/Auckland");
 
+  private final Clock clock;
   private final ObjectMapper objectMapper;
   private final DynamoDbTable<ImmersionTrackerItem> immersionTrackerTable;
 
@@ -48,6 +50,7 @@ public GetProgressHandler() {
 
   @VisibleForTesting
   GetProgressHandler(ImmersionTrackerFactory factory) {
+    this.clock = factory.clock();
     this.objectMapper = factory.objectMapper();
     this.immersionTrackerTable = factory.immersionTrackerTable();
   }
@@ -85,7 +88,7 @@ private APIGatewayV2HTTPResponse doHandleRequest(APIGatewayV2HTTPEvent event, Co
 
     var totalEpisodesWatched = episodes.size();
     var totalHoursWatched = totalEpisodesWatched * MINUTES_PER_EPISODE / 60;
-    var today = Instant.now().atZone(AUCKLAND_ZONE_ID).truncatedTo(ChronoUnit.DAYS).toInstant();
+    var today = clock.now().atZone(ZONE_ID).truncatedTo(ChronoUnit.DAYS).toInstant();
     var episodesWatchedToday =
         episodes.stream()
             .filter(e -> Instant.ofEpochSecond(e.getTimestamp()).isAfter(today))
diff --git a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerFactory.java b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerFactory.java
index a820dc9..e6111c2 100644
--- a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerFactory.java
+++ b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerFactory.java
@@ -1,19 +1,27 @@
 package com.jordansimsmith.immersiontracker;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
-import dagger.BindsInstance;
+import com.jordansimsmith.dynamodb.DynamoDbModule;
+import com.jordansimsmith.json.ObjectMapperModule;
+import com.jordansimsmith.time.Clock;
+import com.jordansimsmith.time.ClockModule;
 import dagger.Component;
-import java.net.URI;
-import javax.annotation.Nullable;
-import javax.inject.Named;
 import javax.inject.Singleton;
 import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
 import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
 import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
 
 @Singleton
-@Component(modules = {ImmersionTrackerModule.class})
+@Component(
+    modules = {
+      ClockModule.class,
+      ObjectMapperModule.class,
+      DynamoDbModule.class,
+      ImmersionTrackerModule.class
+    })
 public interface ImmersionTrackerFactory {
+  Clock clock();
+
   ObjectMapper objectMapper();
 
   DynamoDbClient dynamoDbClient();
@@ -22,19 +30,7 @@ public interface ImmersionTrackerFactory {
 
   DynamoDbTable<ImmersionTrackerItem> immersionTrackerTable();
 
-  @Component.Builder
-  interface Builder {
-    @BindsInstance
-    Builder dynamoDbEndpoint(@Named("dynamoDbEndpoint") @Nullable URI dynamoDbEndpoint);
-
-    ImmersionTrackerFactory build();
-  }
-
   static ImmersionTrackerFactory create() {
-    return DaggerImmersionTrackerFactory.builder().build();
-  }
-
-  static ImmersionTrackerFactory.Builder builder() {
-    return DaggerImmersionTrackerFactory.builder();
+    return DaggerImmersionTrackerFactory.create();
   }
 }
diff --git a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerModule.java b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerModule.java
index 631c34a..179df47 100644
--- a/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerModule.java
+++ b/immersion_tracker_api/src/main/java/com/jordansimsmith/immersiontracker/ImmersionTrackerModule.java
@@ -1,41 +1,14 @@
 package com.jordansimsmith.immersiontracker;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
 import dagger.Module;
 import dagger.Provides;
-import java.net.URI;
-import javax.annotation.Nullable;
-import javax.inject.Named;
 import javax.inject.Singleton;
 import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
 import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
 import software.amazon.awssdk.enhanced.dynamodb.TableSchema;
-import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
 
 @Module
 public class ImmersionTrackerModule {
-  @Provides
-  public ObjectMapper objectMapper() {
-    return new ObjectMapper();
-  }
-
-  @Provides
-  @Singleton
-  public DynamoDbClient dynamoDbClient(@Named("dynamoDbEndpoint") @Nullable URI dynamoDbEndpoint) {
-    var builder = DynamoDbClient.builder();
-    if (dynamoDbEndpoint != null) {
-      builder.endpointOverride(dynamoDbEndpoint);
-    }
-
-    return builder.build();
-  }
-
-  @Provides
-  @Singleton
-  public DynamoDbEnhancedClient dynamoDbEnhancedClient(DynamoDbClient dynamoDbClient) {
-    return DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build();
-  }
-
   @Provides
   @Singleton
   public DynamoDbTable<ImmersionTrackerItem> immersionTrackerTable(
diff --git a/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetProgressHandlerIntegrationTest.java b/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetProgressHandlerIntegrationTest.java
index 5559a10..105c0b7 100644
--- a/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetProgressHandlerIntegrationTest.java
+++ b/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/GetProgressHandlerIntegrationTest.java
@@ -1,8 +1,11 @@
 package com.jordansimsmith.immersiontracker;
 
+import static org.assertj.core.api.Assertions.assertThat;
+
 import com.amazonaws.services.lambda.runtime.events.APIGatewayV2HTTPEvent;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.jordansimsmith.testcontainers.DynamoDbContainer;
+import com.jordansimsmith.time.FakeClock;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.testcontainers.junit.jupiter.Container;
@@ -10,29 +13,31 @@
 import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable;
 import software.amazon.awssdk.services.dynamodb.waiters.DynamoDbWaiter;
 
-import static org.assertj.core.api.Assertions.assertThat;
-
 @Testcontainers
 public class GetProgressHandlerIntegrationTest {
+  private FakeClock fakeClock;
   private ObjectMapper objectMapper;
   private DynamoDbTable<ImmersionTrackerItem> immersionTrackerTable;
 
   private GetProgressHandler getProgressHandler;
 
-  @Container
-  DynamoDbContainer dynamoDbContainer = new DynamoDbContainer();
+  @Container DynamoDbContainer dynamoDbContainer = new DynamoDbContainer();
 
   @BeforeEach
   void setUp() {
-    var factory = ImmersionTrackerFactory.builder().dynamoDbEndpoint(dynamoDbContainer.getEndpoint()).build();
+    var factory = ImmersionTrackerTestFactory.create(dynamoDbContainer.getEndpoint());
 
+    fakeClock = factory.fakeClock();
     objectMapper = factory.objectMapper();
 
     var dynamoDbClient = factory.dynamoDbClient();
     immersionTrackerTable = factory.immersionTrackerTable();
     immersionTrackerTable.createTable();
     try (var waiter = DynamoDbWaiter.builder().client(dynamoDbClient).build()) {
-      var res = waiter.waitUntilTableExists(b -> b.tableName(immersionTrackerTable.tableName()).build()).matched();
+      var res =
+          waiter
+              .waitUntilTableExists(b -> b.tableName(immersionTrackerTable.tableName()).build())
+              .matched();
       res.response().orElseThrow();
     }
 
@@ -40,11 +45,15 @@ void setUp() {
   }
 
   @Test
-  void handleRequestShouldQueryItems() throws Exception {
+  void handleRequestShouldCalculateProgress() throws Exception {
     // arrange
-    var episode1 = ImmersionTrackerItem.createEpisode("jordansimsmith", "show1", "episode1", 123);
-    var episode2 = ImmersionTrackerItem.createEpisode("jordansimsmith", "show1", "episode2", 456);
-    var episode3 = ImmersionTrackerItem.createEpisode("jordansimsmith", "show2", "episode1", 789);
+    var now = (int) fakeClock.now().atZone(GetProgressHandler.ZONE_ID).toInstant().getEpochSecond();
+    var episode1 =
+        ImmersionTrackerItem.createEpisode("jordansimsmith", "show1", "episode1", now - 100);
+    var episode2 =
+        ImmersionTrackerItem.createEpisode("jordansimsmith", "show1", "episode2", now + 100);
+    var episode3 =
+        ImmersionTrackerItem.createEpisode("jordansimsmith", "show2", "episode1", now - 100);
     var show = ImmersionTrackerItem.createShow("jordansimsmith", "show1");
     show.setTvdbId(1);
     show.setTvdbName("my show");
@@ -65,7 +74,7 @@ void handleRequestShouldQueryItems() throws Exception {
     assertThat(progress).isNotNull();
     assertThat(progress.totalEpisodesWatched()).isEqualTo(3);
     assertThat(progress.totalHoursWatched()).isEqualTo(1);
-    assertThat(progress.episodesWatchedToday()).isEqualTo(0);
+    assertThat(progress.episodesWatchedToday()).isEqualTo(1);
 
     var shows = progress.shows();
     assertThat(shows).hasSize(2);
diff --git a/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/ImmersionTrackerTestFactory.java b/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/ImmersionTrackerTestFactory.java
new file mode 100644
index 0000000..a3ce79d
--- /dev/null
+++ b/immersion_tracker_api/src/test/java/com/jordansimsmith/immersiontracker/ImmersionTrackerTestFactory.java
@@ -0,0 +1,33 @@
+package com.jordansimsmith.immersiontracker;
+
+import com.jordansimsmith.dynamodb.DynamoDbTestModule;
+import com.jordansimsmith.json.ObjectMapperModule;
+import com.jordansimsmith.time.ClockTestModule;
+import com.jordansimsmith.time.FakeClock;
+import dagger.BindsInstance;
+import dagger.Component;
+import java.net.URI;
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+@Singleton
+@Component(
+    modules = {
+      ClockTestModule.class,
+      ObjectMapperModule.class,
+      DynamoDbTestModule.class,
+      ImmersionTrackerModule.class
+    })
+public interface ImmersionTrackerTestFactory extends ImmersionTrackerFactory {
+  FakeClock fakeClock();
+
+  @Component.Factory
+  interface Factory {
+    ImmersionTrackerTestFactory create(
+        @BindsInstance @Named("dynamoDbEndpoint") URI dynamoDbEndpoint);
+  }
+
+  static ImmersionTrackerTestFactory create(URI dynamoDbEndpoint) {
+    return DaggerImmersionTrackerTestFactory.factory().create(dynamoDbEndpoint);
+  }
+}
diff --git a/lib/dynamodb/BUILD.bazel b/lib/dynamodb/BUILD.bazel
new file mode 100644
index 0000000..c23be34
--- /dev/null
+++ b/lib/dynamodb/BUILD.bazel
@@ -0,0 +1,16 @@
+load("@dagger//:workspace_defs.bzl", "dagger_rules")
+
+dagger_rules()
+
+java_library(
+    name = "lib",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = [
+        "//visibility:public",
+    ],
+    deps = [
+        ":dagger",
+        "@maven//:software_amazon_awssdk_dynamodb",
+        "@maven//:software_amazon_awssdk_dynamodb_enhanced",
+    ],
+)
diff --git a/lib/dynamodb/src/main/java/com/jordansimsmith/dynamodb/DynamoDbModule.java b/lib/dynamodb/src/main/java/com/jordansimsmith/dynamodb/DynamoDbModule.java
new file mode 100644
index 0000000..622e06a
--- /dev/null
+++ b/lib/dynamodb/src/main/java/com/jordansimsmith/dynamodb/DynamoDbModule.java
@@ -0,0 +1,22 @@
+package com.jordansimsmith.dynamodb;
+
+import dagger.Module;
+import dagger.Provides;
+import javax.inject.Singleton;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+
+@Module
+public class DynamoDbModule {
+  @Provides
+  @Singleton
+  public DynamoDbClient dynamoDbClient() {
+    return DynamoDbClient.builder().build();
+  }
+
+  @Provides
+  @Singleton
+  public DynamoDbEnhancedClient dynamoDbEnhancedClient(DynamoDbClient dynamoDbClient) {
+    return DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build();
+  }
+}
diff --git a/lib/dynamodb/src/main/java/com/jordansimsmith/dynamodb/DynamoDbTestModule.java b/lib/dynamodb/src/main/java/com/jordansimsmith/dynamodb/DynamoDbTestModule.java
new file mode 100644
index 0000000..642227b
--- /dev/null
+++ b/lib/dynamodb/src/main/java/com/jordansimsmith/dynamodb/DynamoDbTestModule.java
@@ -0,0 +1,24 @@
+package com.jordansimsmith.dynamodb;
+
+import dagger.Module;
+import dagger.Provides;
+import java.net.URI;
+import javax.inject.Named;
+import javax.inject.Singleton;
+import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient;
+import software.amazon.awssdk.services.dynamodb.DynamoDbClient;
+
+@Module
+public class DynamoDbTestModule {
+  @Provides
+  @Singleton
+  public DynamoDbClient dynamoDbClient(@Named("dynamoDbEndpoint") URI dynamoDbEndpoint) {
+    return DynamoDbClient.builder().endpointOverride(dynamoDbEndpoint).build();
+  }
+
+  @Provides
+  @Singleton
+  public DynamoDbEnhancedClient dynamoDbEnhancedClient(DynamoDbClient dynamoDbClient) {
+    return DynamoDbEnhancedClient.builder().dynamoDbClient(dynamoDbClient).build();
+  }
+}
diff --git a/lib/json/BUILD.bazel b/lib/json/BUILD.bazel
new file mode 100644
index 0000000..c4f3b44
--- /dev/null
+++ b/lib/json/BUILD.bazel
@@ -0,0 +1,15 @@
+load("@dagger//:workspace_defs.bzl", "dagger_rules")
+
+dagger_rules()
+
+java_library(
+    name = "lib",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = [
+        "//visibility:public",
+    ],
+    deps = [
+        ":dagger",
+        "@maven//:com_fasterxml_jackson_core_jackson_databind",
+    ],
+)
diff --git a/lib/json/src/main/java/com/jordansimsmith/json/ObjectMapperModule.java b/lib/json/src/main/java/com/jordansimsmith/json/ObjectMapperModule.java
new file mode 100644
index 0000000..5d6f2c0
--- /dev/null
+++ b/lib/json/src/main/java/com/jordansimsmith/json/ObjectMapperModule.java
@@ -0,0 +1,13 @@
+package com.jordansimsmith.json;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import dagger.Module;
+import dagger.Provides;
+
+@Module
+public class ObjectMapperModule {
+  @Provides
+  public ObjectMapper objectMapper() {
+    return new ObjectMapper();
+  }
+}
diff --git a/lib/time/BUILD.bazel b/lib/time/BUILD.bazel
new file mode 100644
index 0000000..7ecf491
--- /dev/null
+++ b/lib/time/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@dagger//:workspace_defs.bzl", "dagger_rules")
+
+dagger_rules()
+
+java_library(
+    name = "lib",
+    srcs = glob(["src/main/java/**/*.java"]),
+    visibility = [
+        "//visibility:public",
+    ],
+    deps = [
+        ":dagger",
+    ],
+)
diff --git a/lib/time/src/main/java/com/jordansimsmith/time/Clock.java b/lib/time/src/main/java/com/jordansimsmith/time/Clock.java
new file mode 100644
index 0000000..250f933
--- /dev/null
+++ b/lib/time/src/main/java/com/jordansimsmith/time/Clock.java
@@ -0,0 +1,7 @@
+package com.jordansimsmith.time;
+
+import java.time.Instant;
+
+public interface Clock {
+  Instant now();
+}
diff --git a/lib/time/src/main/java/com/jordansimsmith/time/ClockModule.java b/lib/time/src/main/java/com/jordansimsmith/time/ClockModule.java
new file mode 100644
index 0000000..5028cbf
--- /dev/null
+++ b/lib/time/src/main/java/com/jordansimsmith/time/ClockModule.java
@@ -0,0 +1,12 @@
+package com.jordansimsmith.time;
+
+import dagger.Module;
+import dagger.Provides;
+
+@Module
+public class ClockModule {
+  @Provides
+  public Clock clock() {
+    return new SystemClock();
+  }
+}
diff --git a/lib/time/src/main/java/com/jordansimsmith/time/ClockTestModule.java b/lib/time/src/main/java/com/jordansimsmith/time/ClockTestModule.java
new file mode 100644
index 0000000..f7981ca
--- /dev/null
+++ b/lib/time/src/main/java/com/jordansimsmith/time/ClockTestModule.java
@@ -0,0 +1,17 @@
+package com.jordansimsmith.time;
+
+import dagger.Module;
+import dagger.Provides;
+
+@Module
+public class ClockTestModule {
+  @Provides
+  public FakeClock fakeClock() {
+    return new FakeClock();
+  }
+
+  @Provides
+  public Clock clock(FakeClock fakeClock) {
+    return fakeClock;
+  }
+}
diff --git a/lib/time/src/main/java/com/jordansimsmith/time/FakeClock.java b/lib/time/src/main/java/com/jordansimsmith/time/FakeClock.java
new file mode 100644
index 0000000..70c3183
--- /dev/null
+++ b/lib/time/src/main/java/com/jordansimsmith/time/FakeClock.java
@@ -0,0 +1,22 @@
+package com.jordansimsmith.time;
+
+import java.time.Instant;
+
+public class FakeClock implements Clock {
+  private static final long DEFAULT_EPOCH_MILLI = 946638000000L; // 2000-01-01
+
+  private long currentEpochMilli = DEFAULT_EPOCH_MILLI;
+
+  @Override
+  public Instant now() {
+    return Instant.ofEpochMilli(currentEpochMilli);
+  }
+
+  public void setTime(long epochMillis) {
+    this.currentEpochMilli = epochMillis;
+  }
+
+  public void reset() {
+    currentEpochMilli = DEFAULT_EPOCH_MILLI;
+  }
+}
diff --git a/lib/time/src/main/java/com/jordansimsmith/time/SystemClock.java b/lib/time/src/main/java/com/jordansimsmith/time/SystemClock.java
new file mode 100644
index 0000000..03f63c4
--- /dev/null
+++ b/lib/time/src/main/java/com/jordansimsmith/time/SystemClock.java
@@ -0,0 +1,10 @@
+package com.jordansimsmith.time;
+
+import java.time.Instant;
+
+public class SystemClock implements Clock {
+  @Override
+  public Instant now() {
+    return Instant.now();
+  }
+}