diff --git a/.gitignore b/.gitignore index 524f096..620caa4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* + +target/ +.idea/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9bd07c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# SwitchBotAPI-java Project + +## Project Overview + +A Java wrapper library for the SwitchBot API v1.1, providing easy integration with SwitchBot devices. + +## Key Information + +- **Language**: Java 17 +- **Build Tool**: Maven +- **Version**: 1.2.5 +- **Main Package**: `com.bigboxer23.switch_bot` + +## Project Structure + +``` +src/ +├── main/java/com/bigboxer23/switch_bot/ +│ ├── SwitchBotApi.java # Main API client +│ ├── SwitchBotDeviceApi.java # Device-specific API operations +│ ├── IDeviceTypes.java # Device type constants +│ ├── IDeviceCommands.java # Device command constants +│ └── data/ # Data models and response classes +└── test/java/com/bigboxer23/switch_bot/ # Unit and integration tests +``` + +## Build Commands + +- **Test**: `mvn test` +- **Integration Tests**: `mvn failsafe:integration-test -Dintegration=true` +- **Build**: `mvn compile` +- **Package**: `mvn package` +- **Format Code**: `mvn spotless:apply` +- **Coverage Report**: `mvn jacoco:report` + +## Code Style + +- Uses Spotless for code formatting with Google Java Format (AOSP style) +- Tab indentation (4 spaces per tab) +- Lombok for reducing boilerplate code + +## Testing + +- JUnit 5 for unit tests +- Mockito for mocking +- Integration tests with SwitchBot API +- Code coverage with JaCoCo + +## Dependencies + +- **utils**: Custom utility library (bigboxer23) +- **moshi**: JSON serialization +- **lombok**: Code generation +- **logback**: Logging (test scope) + +## GitHub Actions + +- Unit tests on push/PR +- CodeQL security analysis +- Code coverage reporting +- Automatic package publishing +- Auto-merge for dependency updates + +## API Features + +- Device listing and status retrieval +- Device control commands +- Battery status monitoring +- Support for curtains, plugs, and other SwitchBot devices +- HMAC-SHA256 authentication with SwitchBot API + +## Example Usage + +```java +SwitchBotApi instance = SwitchBotApi.getInstance(token, secret); +List devices = instance.getDeviceApi().getDevices(); +Device status = instance.getDeviceApi().getDeviceStatus(devices.getFirst().getDeviceId()); +``` + diff --git a/pom.xml b/pom.xml index 2ddbc7b..ddcb4fc 100644 --- a/pom.xml +++ b/pom.xml @@ -19,6 +19,7 @@ UTF-8 17 + 1.18.40 @@ -48,7 +49,7 @@ org.projectlombok lombok - 1.18.40 + ${lombok.version} com.squareup.moshi @@ -82,6 +83,13 @@ 3.14.0 17 + + + org.projectlombok + lombok + ${lombok.version} + + diff --git a/src/test/java/com/bigboxer23/switch_bot/IDeviceTypesTest.java b/src/test/java/com/bigboxer23/switch_bot/IDeviceTypesTest.java new file mode 100644 index 0000000..c3a8901 --- /dev/null +++ b/src/test/java/com/bigboxer23/switch_bot/IDeviceTypesTest.java @@ -0,0 +1,55 @@ +package com.bigboxer23.switch_bot; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +public class IDeviceTypesTest { + + @Test + public void testDeviceTypeConstants() { + assertEquals("Curtain", IDeviceTypes.CURTAIN); + assertEquals("Hub 2", IDeviceTypes.HUB2); + assertEquals("Meter", IDeviceTypes.METER); + assertEquals("WoIOSensor", IDeviceTypes.WOIOSENSOR); + assertEquals("Plug Mini (US)", IDeviceTypes.PLUG_MINI); + assertEquals("Water Detector", IDeviceTypes.WATER_DETECTOR); + assertEquals("MeterPro(CO2)", IDeviceTypes.METER_PRO_CO2); + assertEquals("Roller Shade", IDeviceTypes.ROLLER_SHADE); + } + + @Test + public void testDeviceTypeConstantsAreNotNull() { + assertNotNull(IDeviceTypes.CURTAIN); + assertNotNull(IDeviceTypes.HUB2); + assertNotNull(IDeviceTypes.METER); + assertNotNull(IDeviceTypes.WOIOSENSOR); + assertNotNull(IDeviceTypes.PLUG_MINI); + assertNotNull(IDeviceTypes.WATER_DETECTOR); + assertNotNull(IDeviceTypes.METER_PRO_CO2); + assertNotNull(IDeviceTypes.ROLLER_SHADE); + } + + @Test + public void testDeviceTypeUniqueness() { + String[] deviceTypes = { + IDeviceTypes.CURTAIN, + IDeviceTypes.HUB2, + IDeviceTypes.METER, + IDeviceTypes.WOIOSENSOR, + IDeviceTypes.PLUG_MINI, + IDeviceTypes.WATER_DETECTOR, + IDeviceTypes.METER_PRO_CO2, + IDeviceTypes.ROLLER_SHADE + }; + + for (int i = 0; i < deviceTypes.length; i++) { + for (int j = i + 1; j < deviceTypes.length; j++) { + assertNotEquals( + deviceTypes[i], + deviceTypes[j], + "Device types should be unique: " + deviceTypes[i] + " vs " + deviceTypes[j]); + } + } + } +} diff --git a/src/test/java/com/bigboxer23/switch_bot/SwitchBotApiUnitTest.java b/src/test/java/com/bigboxer23/switch_bot/SwitchBotApiUnitTest.java index 506e3b2..5a5ac6d 100644 --- a/src/test/java/com/bigboxer23/switch_bot/SwitchBotApiUnitTest.java +++ b/src/test/java/com/bigboxer23/switch_bot/SwitchBotApiUnitTest.java @@ -30,17 +30,15 @@ public void testSingletonBehavior() { @Test public void testGetInstanceWithNullToken() { - RuntimeException exception = assertThrows(RuntimeException.class, () -> { - SwitchBotApi.getInstance(null, "secret"); - }); + RuntimeException exception = + assertThrows(RuntimeException.class, () -> SwitchBotApi.getInstance(null, "secret")); assertEquals("need to define token and secret values.", exception.getMessage()); } @Test public void testGetInstanceWithNullSecret() { - RuntimeException exception = assertThrows(RuntimeException.class, () -> { - SwitchBotApi.getInstance("token", null); - }); + RuntimeException exception = + assertThrows(RuntimeException.class, () -> SwitchBotApi.getInstance("token", null)); assertEquals("need to define token and secret values.", exception.getMessage()); } @@ -130,4 +128,49 @@ public void testDeviceNameCacheRefreshBehavior() throws IOException { deviceApi.getDeviceNameFromId("12345"); verify(deviceApi, times(0)).getDevices(); } + + @Test + public void testAddAuthCreatesValidHeaders() { + SwitchBotApi api = SwitchBotApi.getInstance("testToken", "testSecret"); + com.bigboxer23.utils.http.RequestBuilderCallback callback = api.addAuth(); + + assertNotNull(callback); + } + + @Test + public void testAddAuthWithRequestBuilder() { + SwitchBotApi api = SwitchBotApi.getInstance("testToken", "testSecret"); + com.bigboxer23.utils.http.RequestBuilderCallback callback = api.addAuth(); + + okhttp3.Request.Builder mockBuilder = mock(okhttp3.Request.Builder.class); + when(mockBuilder.addHeader(anyString(), anyString())).thenReturn(mockBuilder); + + okhttp3.Request.Builder result = callback.modifyBuilder(mockBuilder); + + assertNotNull(result); + verify(mockBuilder, times(5)).addHeader(anyString(), anyString()); + } + + @Test + public void testGetMoshiReturnsInstance() { + SwitchBotApi api = SwitchBotApi.getInstance("testToken", "testSecret"); + com.squareup.moshi.Moshi moshi = api.getMoshi(); + + assertNotNull(moshi); + assertSame(moshi, api.getMoshi(), "Should return the same Moshi instance"); + } + + @Test + public void testGetDeviceApiReturnsInstance() { + SwitchBotApi api = SwitchBotApi.getInstance("testToken", "testSecret"); + SwitchBotDeviceApi deviceApi = api.getDeviceApi(); + + assertNotNull(deviceApi); + assertSame(deviceApi, api.getDeviceApi(), "Should return the same DeviceApi instance"); + } + + @Test + public void testBaseUrlConstant() { + assertEquals("https://api.switch-bot.com/", SwitchBotApi.baseUrl); + } } diff --git a/src/test/java/com/bigboxer23/switch_bot/SwitchBotDeviceApiTest.java b/src/test/java/com/bigboxer23/switch_bot/SwitchBotDeviceApiTest.java index 492d335..3831967 100644 --- a/src/test/java/com/bigboxer23/switch_bot/SwitchBotDeviceApiTest.java +++ b/src/test/java/com/bigboxer23/switch_bot/SwitchBotDeviceApiTest.java @@ -8,6 +8,7 @@ import java.io.IOException; import java.util.Arrays; import java.util.Collections; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; @@ -53,7 +54,7 @@ public void testGetDeviceNameFromIdWithUnknownDevice() throws IOException { device.setDeviceName("Known Device"); SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); - doReturn(Arrays.asList(device)).when(spyDeviceApi).getDevices(); + doReturn(List.of(device)).when(spyDeviceApi).getDevices(); String result = spyDeviceApi.getDeviceNameFromId("unknown-device"); assertEquals("unknown-device", result); @@ -68,7 +69,7 @@ public void testGetDeviceNameFromIdCacheExpiry() throws IOException { SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); spyDeviceApi.deviceIdToNamesCacheTime = System.currentTimeMillis() - (ITimeConstants.HOUR * 2); - doReturn(Arrays.asList(device)).when(spyDeviceApi).getDevices(); + doReturn(List.of(device)).when(spyDeviceApi).getDevices(); String result = spyDeviceApi.getDeviceNameFromId("device1"); assertEquals("Test Device", result); @@ -85,7 +86,7 @@ public void testGetDeviceNameFromIdWithValidCache() throws IOException { SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); spyDeviceApi.deviceIdToNamesCacheTime = System.currentTimeMillis(); - doReturn(Arrays.asList(device)).when(spyDeviceApi).getDevices(); + doReturn(List.of(device)).when(spyDeviceApi).getDevices(); spyDeviceApi.getDeviceNameFromId("device1"); reset(spyDeviceApi); @@ -112,9 +113,7 @@ public void testGetDeviceStatusWithNullDeviceId() throws IOException { @Test public void testGetDeviceStatusInputValidation() { - assertDoesNotThrow(() -> { - assertNotNull(deviceApi); - }); + assertDoesNotThrow(() -> assertNotNull(deviceApi)); } @Test @@ -145,18 +144,14 @@ public void testConcurrentCacheRefresh() throws InterruptedException { device.setDeviceId("concurrent-device"); device.setDeviceName("Concurrent Test"); try { - doReturn(Arrays.asList(device)).when(spyDeviceApi).getDevices(); + doReturn(List.of(device)).when(spyDeviceApi).getDevices(); } catch (IOException e) { fail("Setup failed: " + e.getMessage()); } - Thread thread1 = new Thread(() -> { - spyDeviceApi.getDeviceNameFromId("concurrent-device"); - }); + Thread thread1 = new Thread(() -> spyDeviceApi.getDeviceNameFromId("concurrent-device")); - Thread thread2 = new Thread(() -> { - spyDeviceApi.getDeviceNameFromId("concurrent-device"); - }); + Thread thread2 = new Thread(() -> spyDeviceApi.getDeviceNameFromId("concurrent-device")); thread1.start(); thread2.start(); @@ -192,4 +187,83 @@ public void testGetDeviceStatusReturnsNullForNullInput() throws IOException { public void testDeviceApiNotNull() { assertNotNull(deviceApi); } + + @Test + public void testParseResponseWithIOException() { + SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); + when(mockSwitchBotApi.getMoshi()).thenReturn(new com.squareup.moshi.Moshi.Builder().build()); + + DeviceCommand command = new DeviceCommand("turnOn", "default"); + assertDoesNotThrow(() -> { + String json = + mockSwitchBotApi.getMoshi().adapter(DeviceCommand.class).toJson(command); + assertNotNull(json); + }); + } + + @Test + public void testSendDeviceControlCommandsWithValidInput() { + DeviceCommand command = new DeviceCommand("turnOn", "default"); + String deviceId = "valid-device-id"; + + when(mockSwitchBotApi.getMoshi()).thenReturn(new com.squareup.moshi.Moshi.Builder().build()); + + assertNotNull(command.getCommand()); + assertNotNull(command.getParameter()); + assertNotNull(deviceId); + assertEquals("turnOn", command.getCommand()); + assertEquals("default", command.getParameter()); + } + + @Test + public void testDeviceStatusValidation() throws IOException { + assertNull(deviceApi.getDeviceStatus(null)); + } + + @Test + public void testGetDeviceNameFromIdWithNullDeviceNameMap() throws IOException { + SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); + spyDeviceApi.deviceIdToNamesCacheTime = -1; + + doThrow(new IOException("Network error")).when(spyDeviceApi).getDevices(); + + String result = spyDeviceApi.getDeviceNameFromId("test-device"); + assertEquals("test-device", result); + } + + @Test + public void testCacheTimeValidation() { + SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); + + spyDeviceApi.deviceIdToNamesCacheTime = System.currentTimeMillis() - (ITimeConstants.HOUR * 2); + assertTrue(spyDeviceApi.deviceIdToNamesCacheTime < System.currentTimeMillis() - ITimeConstants.HOUR); + + spyDeviceApi.deviceIdToNamesCacheTime = System.currentTimeMillis(); + assertTrue(spyDeviceApi.deviceIdToNamesCacheTime > System.currentTimeMillis() - ITimeConstants.HOUR); + } + + @Test + public void testDeviceApiConstructor() { + SwitchBotDeviceApi newDeviceApi = new SwitchBotDeviceApi(mockSwitchBotApi); + assertNotNull(newDeviceApi); + assertEquals(-1, newDeviceApi.deviceIdToNamesCacheTime); + } + + @Test + public void testGetDeviceStatusWithEmptyDeviceId() { + assertDoesNotThrow(() -> assertNotNull(deviceApi)); + } + + @Test + public void testGetDeviceNameFromIdWithCacheRefreshFailure() throws IOException { + SwitchBotDeviceApi spyDeviceApi = spy(deviceApi); + + spyDeviceApi.deviceIdToNamesCacheTime = System.currentTimeMillis() - (ITimeConstants.HOUR * 2); + + doThrow(new IOException("Network failure")).when(spyDeviceApi).getDevices(); + + String result = spyDeviceApi.getDeviceNameFromId("test-device"); + assertEquals("test-device", result); + assertEquals(-1, spyDeviceApi.deviceIdToNamesCacheTime); + } } diff --git a/src/test/java/com/bigboxer23/switch_bot/data/ApiResponseBodyTest.java b/src/test/java/com/bigboxer23/switch_bot/data/ApiResponseBodyTest.java new file mode 100644 index 0000000..5b01b1a --- /dev/null +++ b/src/test/java/com/bigboxer23/switch_bot/data/ApiResponseBodyTest.java @@ -0,0 +1,103 @@ +package com.bigboxer23.switch_bot.data; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class ApiResponseBodyTest { + + @Test + public void testDefaultConstructor() { + ApiResponseBody body = new ApiResponseBody(); + assertNotNull(body); + assertNull(body.getDeviceList()); + } + + @Test + public void testSetAndGetDeviceList() { + ApiResponseBody body = new ApiResponseBody(); + + Device device1 = new Device(); + device1.setDeviceId("device1"); + device1.setDeviceName("Test Device 1"); + + Device device2 = new Device(); + device2.setDeviceId("device2"); + device2.setDeviceName("Test Device 2"); + + List devices = Arrays.asList(device1, device2); + body.setDeviceList(devices); + + assertEquals(devices, body.getDeviceList()); + assertEquals(2, body.getDeviceList().size()); + assertEquals("device1", body.getDeviceList().get(0).getDeviceId()); + assertEquals("Test Device 1", body.getDeviceList().get(0).getDeviceName()); + } + + @Test + public void testSetDeviceListWithEmptyList() { + ApiResponseBody body = new ApiResponseBody(); + List emptyList = Collections.emptyList(); + + body.setDeviceList(emptyList); + + assertEquals(emptyList, body.getDeviceList()); + assertTrue(body.getDeviceList().isEmpty()); + } + + @Test + public void testSetDeviceListWithNull() { + ApiResponseBody body = new ApiResponseBody(); + body.setDeviceList(null); + + assertNull(body.getDeviceList()); + } + + @Test + public void testEqualsAndHashCode() { + ApiResponseBody body1 = new ApiResponseBody(); + ApiResponseBody body2 = new ApiResponseBody(); + + assertEquals(body1, body2); + assertEquals(body1.hashCode(), body2.hashCode()); + + Device device = new Device(); + device.setDeviceId("test"); + List devices = List.of(device); + + body1.setDeviceList(devices); + body2.setDeviceList(devices); + + assertEquals(body1, body2); + assertEquals(body1.hashCode(), body2.hashCode()); + } + + @Test + public void testToString() { + ApiResponseBody body = new ApiResponseBody(); + String toString = body.toString(); + + assertNotNull(toString); + assertTrue(toString.contains("ApiResponseBody")); + assertTrue(toString.contains("deviceList")); + } + + @Test + public void testSetDeviceListModification() { + ApiResponseBody body = new ApiResponseBody(); + + Device device = new Device(); + device.setDeviceId("original"); + List devices = List.of(device); + + body.setDeviceList(devices); + assertEquals(1, body.getDeviceList().size()); + assertEquals("original", body.getDeviceList().get(0).getDeviceId()); + + device.setDeviceId("modified"); + assertEquals("modified", body.getDeviceList().get(0).getDeviceId()); + } +}