makeDirectory('/images/generated');
});
test('device can fetch display data with valid credentials', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'current_screen_image' => 'test-image',
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'status' => '0',
'filename' => 'test-image.bmp',
'refresh_rate' => 900,
'reset_firmware' => false,
'update_firmware' => false,
'firmware_url' => null,
'special_function' => 'sleep',
]);
expect($device->fresh())
->last_rssi_level->toBe(-70)
->last_battery_voltage->toBe(3.8)
->last_firmware_version->toBe('1.0.0');
});
test('display endpoint includes image_url_timeout when configured', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
]);
config(['services.trmnl.image_url_timeout' => 300]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'image_url_timeout' => 300,
]);
});
test('display endpoint omits image_url_timeout when not configured', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
]);
config(['services.trmnl.image_url_timeout' => null]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJsonMissing(['image_url_timeout']);
});
test('new device is auto-assigned to user with auto-assign enabled', function (): void {
$user = User::factory()->create(['assign_new_devices' => true]);
$response = $this->withHeaders([
'id' => '00:11:22:33:44:55',
'access-token' => 'new-device-key',
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
$device = Device::where('mac_address', '00:11:22:33:44:55')->first();
expect($device)
->not->toBeNull()
->user_id->toBe($user->id)
->api_key->toBe('new-device-key');
});
test('new device is auto-assigned and mirrors specified device', function (): void {
// Create a source device that will be mirrored
$sourceDevice = Device::factory()->create([
'mac_address' => 'AA:BB:CC:DD:EE:FF',
'api_key' => 'source-api-key',
'current_screen_image' => 'source-image',
]);
// Create user with auto-assign enabled and mirror device set
$user = User::factory()->create([
'assign_new_devices' => true,
'assign_new_device_id' => $sourceDevice->id,
]);
// Make request from new device
$response = $this->withHeaders([
'id' => '00:11:22:33:44:55',
'access-token' => 'new-device-key',
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
// Verify the new device was created and mirrors the source device
$newDevice = Device::where('mac_address', '00:11:22:33:44:55')->first();
expect($newDevice)
->not->toBeNull()
->user_id->toBe($user->id)
->api_key->toBe('new-device-key')
->mirror_device_id->toBe($sourceDevice->id);
// Verify the response contains the source device's image
$response->assertJson([
'filename' => 'source-image.bmp',
]);
});
test('device setup endpoint returns correct data', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'friendly_id' => 'test-device',
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
])->get('/api/setup');
$response->assertOk()
->assertJson([
'api_key' => 'test-api-key',
'friendly_id' => 'test-device',
'message' => 'Welcome to TRMNL BYOS',
]);
});
test('device can submit logs', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
]);
$logData = [
'log' => [
'logs_array' => [
['log_message' => 'Test log message', 'level' => 'info'],
],
],
];
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
])->postJson('/api/log', $logData);
$response->assertOk()
->assertJson(['status' => '200']);
expect($device->fresh()->last_log_request)
->toBe($logData);
expect($device->logs()->count())->toBe(1);
});
test('device can submit logs in revised format', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
]);
$logData = [
'logs' => [
['message' => 'Test log message', 'level' => 'info'],
],
];
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
])->postJson('/api/log', $logData);
$response->assertOk()
->assertJson(['status' => '200']);
expect($device->fresh()->last_log_request)
->toBe($logData);
expect($device->logs()->count())->toBe(1);
});
// test('authenticated user can update device display', function () {
// $user = User::factory()->create();
// $device = Device::factory()->create(['user_id' => $user->id]);
//
// Sanctum::actingAs($user, ['update-screen']);
//
// $response = $this->postJson('/api/display/update', [
// 'device_id' => $device->id,
// 'markup' => '
Test markup
'
// ]);
//
// $response->assertOk();
// });
test('user cannot update display for devices they do not own', function (): void {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$device = Device::factory()->create(['user_id' => $otherUser->id]);
Sanctum::actingAs($user, ['update-screen']);
$response = $this->postJson('/api/display/update', [
'device_id' => $device->id,
'markup' => 'Test markup
',
]);
$response->assertForbidden();
});
test('invalid device credentials return error', function (): void {
$response = $this->withHeaders([
'id' => 'invalid-mac',
'access-token' => 'invalid-token',
])->get('/api/display');
$response->assertNotFound()
->assertJson(['message' => 'MAC Address not registered or invalid access token']);
});
test('log endpoint requires valid device credentials', function (): void {
$response = $this->withHeaders([
'id' => 'invalid-mac',
'access-token' => 'invalid-token',
])->postJson('/api/log', ['log' => []]);
$response->assertNotFound()
->assertJson(['message' => 'Device not found or invalid access token']);
});
test('update_firmware flag is only returned once', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'proxy_cloud_response' => [
'update_firmware' => true,
'firmware_url' => 'https://example.com/firmware.bin',
],
]);
// First request should return update_firmware as true
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'update_firmware' => true,
'firmware_url' => 'https://example.com/firmware.bin',
]);
// Second request should return update_firmware as false
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'update_firmware' => false,
'firmware_url' => 'https://example.com/firmware.bin',
]);
// Verify the proxy_cloud_response was updated
$device->refresh();
expect($device->proxy_cloud_response['update_firmware'])->toBeFalse();
});
test('authenticated user can fetch device status', function (): void {
$user = User::factory()->create();
$device = Device::factory()->create([
'user_id' => $user->id,
'mac_address' => '00:11:22:33:44:55',
'name' => 'Test Device',
'friendly_id' => 'test-device',
'last_rssi_level' => -70,
'last_battery_voltage' => 3.8,
'last_firmware_version' => '1.0.0',
'current_screen_image' => 'test-image',
'default_refresh_interval' => 900,
]);
Sanctum::actingAs($user);
$response = $this->getJson('/api/display/status?device_id='.$device->id);
$response->assertOk()
->assertJson([
'id' => $device->id,
'mac_address' => '00:11:22:33:44:55',
'name' => 'Test Device',
'friendly_id' => 'test-device',
'last_rssi_level' => -70,
'last_battery_voltage' => 3.8,
'last_firmware_version' => '1.0.0',
'battery_percent' => 67,
'wifi_strength' => 2,
'current_screen_image' => 'test-image',
'default_refresh_interval' => 900,
]);
});
test('user cannot fetch status for devices they do not own', function (): void {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$device = Device::factory()->create(['user_id' => $otherUser->id]);
Sanctum::actingAs($user);
$response = $this->getJson('/api/display/status?device_id='.$device->id);
$response->assertForbidden();
});
test('display status endpoint requires device_id parameter', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/display/status');
$response->assertStatus(422)
->assertJsonValidationErrors(['device_id']);
});
test('display status endpoint requires valid device_id', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/display/status?device_id=999');
$response->assertStatus(422)
->assertJsonValidationErrors(['device_id']);
});
test('device can mirror another device', function (): void {
// Create source device with a playlist and image
$sourceDevice = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'source-api-key',
'current_screen_image' => 'source-image',
]);
// Create mirroring device
$mirrorDevice = Device::factory()->create([
'mac_address' => 'AA:BB:CC:DD:EE:FF',
'api_key' => 'mirror-api-key',
'mirror_device_id' => $sourceDevice->id,
]);
// Make request from mirror device
$response = $this->withHeaders([
'id' => $mirrorDevice->mac_address,
'access-token' => $mirrorDevice->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'status' => '0',
'filename' => 'source-image.bmp',
'refresh_rate' => 900,
'reset_firmware' => false,
'update_firmware' => false,
'firmware_url' => null,
'special_function' => 'sleep',
]);
// Verify mirror device stats were updated
expect($mirrorDevice->fresh())
->last_rssi_level->toBe(-70)
->last_battery_voltage->toBe(3.8)
->last_firmware_version->toBe('1.0.0');
});
test('device can fetch current screen data', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'current_screen_image' => 'test-image',
]);
$response = $this->withHeaders([
'access-token' => $device->api_key,
])->get('/api/current_screen');
$response->assertOk()
->assertJson([
'status' => 200,
'filename' => 'test-image.bmp',
'refresh_rate' => 900,
'reset_firmware' => false,
'update_firmware' => false,
'firmware_url' => null,
'special_function' => 'sleep',
]);
});
test('current_screen endpoint requires valid device credentials', function (): void {
$response = $this->withHeaders([
'access-token' => 'invalid-token',
])->get('/api/current_screen');
$response->assertNotFound()
->assertJson(['message' => 'Device not found or invalid access token']);
});
test('authenticated user can fetch their devices', function (): void {
$user = User::factory()->create();
$devices = Device::factory()->count(2)->create([
'user_id' => $user->id,
'last_battery_voltage' => 3.72,
'last_rssi_level' => -63,
]);
Sanctum::actingAs($user);
$response = $this->getJson('/api/devices');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => [
'id',
'name',
'friendly_id',
'mac_address',
'battery_voltage',
'rssi',
],
],
])
->assertJsonCount(2, 'data');
// Verify the first device's data
$response->assertJson([
'data' => [
[
'id' => $devices[0]->id,
'name' => $devices[0]->name,
'friendly_id' => $devices[0]->friendly_id,
'mac_address' => $devices[0]->mac_address,
'battery_voltage' => 3.72,
'rssi' => -63,
],
],
]);
});
test('plugin caches image until data is stale', function (): void {
// Create source device with a playlist
$device = Device::factory()->create([
'mac_address' => '55:11:22:33:44:55',
'api_key' => 'source-api-key',
'proxy_cloud' => false,
]);
$plugin = Plugin::factory()->create([
'name' => 'Zen Quotes',
'polling_url' => null,
'data_stale_minutes' => 1,
'data_strategy' => 'polling',
'polling_verb' => 'get',
'render_markup_view' => 'trmnl',
'is_native' => false,
'data_payload_updated_at' => null,
]);
$playlist = Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'update_test',
'is_active' => true,
'weekdays' => null,
'active_from' => null,
'active_until' => null,
]);
PlaylistItem::factory()->create([
'playlist_id' => $playlist->id,
'plugin_id' => $plugin->id,
'order' => 1,
'is_active' => true,
'last_displayed_at' => null,
]);
// initial request, generates the image
$firstResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$firstResponse->assertOk();
expect($firstResponse['filename'])->not->toBe('setup-logo.bmp');
// second request after 15 seconds, shouldn't generate a new image
$plugin->update(['data_payload_updated_at' => now()->addSeconds(-15)]);
$secondResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
expect($secondResponse['filename'])
->toBe($firstResponse['filename']);
// third request after 75 seconds, should generate a new image
$plugin->update(['data_payload_updated_at' => now()->addSeconds(-75)]);
$thirdResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
expect($thirdResponse['filename'])
->not->toBe($firstResponse['filename']);
});
test('plugins in playlist are rendered in order', function (): void {
// Create source device with a playlist
$device = Device::factory()->create([
'mac_address' => '55:11:22:33:44:55',
'api_key' => 'source-api-key',
'proxy_cloud' => true,
]);
// Create two plugins
$firstPlugin = Plugin::factory()->create([
'name' => 'First Plugin',
'polling_url' => null,
'data_stale_minutes' => 1,
'data_strategy' => 'polling',
'polling_verb' => 'get',
'render_markup_view' => 'trmnl',
'is_native' => false,
'data_payload_updated_at' => null,
]);
$secondPlugin = Plugin::factory()->create([
'name' => 'Second Plugin',
'polling_url' => null,
'data_stale_minutes' => 1,
'data_strategy' => 'polling',
'polling_verb' => 'get',
'render_markup_view' => 'trmnl',
'is_native' => false,
'data_payload_updated_at' => null,
]);
// Create playlist
$playlist = Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'Two Plugins Test',
'is_active' => true,
'weekdays' => null,
'active_from' => null,
'active_until' => null,
]);
// Add plugins to playlist in specific order
PlaylistItem::factory()->create([
'playlist_id' => $playlist->id,
'plugin_id' => $firstPlugin->id,
'order' => 1,
'is_active' => true,
'last_displayed_at' => null,
]);
PlaylistItem::factory()->create([
'playlist_id' => $playlist->id,
'plugin_id' => $secondPlugin->id,
'order' => 2,
'is_active' => true,
'last_displayed_at' => null,
]);
// First request should show the first plugin
$firstResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
])->get('/api/display');
$firstResponse->assertOk();
$firstImageFilename = $firstResponse['filename'];
expect($firstImageFilename)->not->toBe('setup-logo.bmp');
// Get the first plugin's playlist item and verify it was marked as displayed
$firstPluginItem = PlaylistItem::where('plugin_id', $firstPlugin->id)->first();
expect($firstPluginItem->last_displayed_at)->not->toBeNull();
// Second request should show the second plugin
$secondResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$secondResponse->assertOk();
expect($secondResponse['filename'])
->not->toBe($firstImageFilename)
->not->toBe('setup-logo.bmp');
// Get the second plugin's playlist item and verify it was marked as displayed
$secondPluginItem = PlaylistItem::where('plugin_id', $secondPlugin->id)->first();
expect($secondPluginItem->last_displayed_at)->not->toBeNull();
// Third request should show the first plugin again
$thirdResponse = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$thirdResponse->assertOk();
expect($thirdResponse['filename'])
->not->toBe($secondResponse['filename']);
});
test('display endpoint updates last_refreshed_at timestamp', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
$device->refresh();
expect($device->last_refreshed_at)->not->toBeNull()
->and($device->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2);
});
test('display endpoint updates last_refreshed_at timestamp for mirrored devices', function (): void {
// Create source device
$sourceDevice = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'source-api-key',
]);
// Create mirroring device
$mirrorDevice = Device::factory()->create([
'mac_address' => 'AA:BB:CC:DD:EE:FF',
'api_key' => 'mirror-api-key',
'mirror_device_id' => $sourceDevice->id,
]);
$response = $this->withHeaders([
'id' => $mirrorDevice->mac_address,
'access-token' => $mirrorDevice->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
$mirrorDevice->refresh();
expect($mirrorDevice->last_refreshed_at)->not->toBeNull()
->and($mirrorDevice->last_refreshed_at->diffInSeconds(now()))->toBeLessThan(2);
});
test('display endpoint handles mashup playlist items correctly', function (): void {
// Create a device
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'proxy_cloud' => false,
]);
// Create a playlist
$playlist = Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'update_test',
'is_active' => true,
'weekdays' => null,
'active_from' => null,
'active_until' => null,
]);
// Create three plugins for the mashup
$plugin1 = Plugin::factory()->create([
'name' => 'Plugin 1',
'data_strategy' => 'webhook',
'polling_url' => null,
'data_stale_minutes' => 1,
'render_markup_view' => 'trmnl',
]);
$plugin2 = Plugin::factory()->create([
'name' => 'Plugin 2',
'data_strategy' => 'webhook',
'polling_url' => null,
'data_stale_minutes' => 1,
'render_markup_view' => 'trmnl',
]);
// Create a mashup playlist item with a 2Lx1R layout (2 plugins on left, 1 on right)
$playlistItem = PlaylistItem::createMashup(
$playlist,
'1Lx1R',
[$plugin1->id, $plugin2->id],
'Test Mashup',
1
);
// Make request to display endpoint
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
// Verify the playlist item was marked as displayed
$playlistItem->refresh();
expect($playlistItem->last_displayed_at)->not->toBeNull();
});
test('device in sleep mode returns sleep image and correct refresh rate', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'sleep_mode_enabled' => true,
'sleep_mode_from' => '19:00',
'sleep_mode_to' => '23:00',
'current_screen_image' => 'test-image',
]);
// Freeze time to 20:00 (within sleep window)
Carbon\Carbon::setTestNow(Carbon\Carbon::parse('2000-01-01 20:00:00'));
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
// The filename should be a UUID-based PNG file since we're generating from template
expect($response['filename'])->toMatch('/^[a-f0-9-]+\.png$/');
expect($response['refresh_rate'])->toBeGreaterThan(0);
Carbon\Carbon::setTestNow(); // Clear test time
});
test('device not in sleep mode returns normal image', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'sleep_mode_enabled' => true,
'sleep_mode_from' => '19:00',
'sleep_mode_to' => '23:00',
'current_screen_image' => 'test-image',
]);
// Freeze time to 18:00 (outside sleep window)
Carbon\Carbon::setTestNow(Carbon\Carbon::parse('2000-01-01 18:00:00'));
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'filename' => 'test-image.bmp',
]);
Carbon\Carbon::setTestNow(); // Clear test time
});
test('device returns sleep.png and correct refresh time when paused', function (): void {
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'pause_until' => now()->addMinutes(60),
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
$json = $response->json();
// The filename should be a UUID-based PNG file since we're generating from template
expect($json['filename'])->toMatch('/^[a-f0-9-]+\.png$/');
expect($json['image_url'])->toContain('images/generated/');
expect($json['refresh_rate'])->toBeLessThanOrEqual(3600); // ~60 min
});
test('screens endpoint accepts nullable file_name', function (): void {
Queue::fake();
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
])->post('/api/screens', [
'image' => [
'content' => 'Test content
',
],
]);
$response->assertOk();
Queue::assertPushed(GenerateScreenJob::class);
});
test('screens endpoint returns 404 for invalid device credentials', function (): void {
$response = $this->withHeaders([
'id' => 'invalid-mac',
'access-token' => 'invalid-key',
])->post('/api/screens', [
'image' => [
'content' => 'Test content
',
'file_name' => 'test.blade.php',
],
]);
$response->assertNotFound()
->assertJson([
'message' => 'MAC Address not registered or invalid access token',
]);
});
test('setup endpoint assigns device model when model-id header is provided', function (): void {
$user = User::factory()->create(['assign_new_devices' => true]);
$deviceModel = DeviceModel::factory()->create([
'name' => 'test-model',
'label' => 'Test Model',
]);
$response = $this->withHeaders([
'id' => '00:11:22:33:44:55',
'model-id' => 'test-model',
])->get('/api/setup');
$response->assertOk()
->assertJson([
'status' => 200,
'message' => 'Welcome to TRMNL BYOS',
]);
$device = Device::where('mac_address', '00:11:22:33:44:55')->first();
expect($device)->not->toBeNull()
->and($device->device_model_id)->toBe($deviceModel->id);
});
test('setup endpoint handles non-existent device model gracefully', function (): void {
$user = User::factory()->create(['assign_new_devices' => true]);
$response = $this->withHeaders([
'id' => '00:11:22:33:44:55',
'model-id' => 'non-existent-model',
])->get('/api/setup');
$response->assertOk()
->assertJson([
'status' => 200,
'message' => 'Welcome to TRMNL BYOS',
]);
$device = Device::where('mac_address', '00:11:22:33:44:55')->first();
expect($device)->not->toBeNull()
->and($device->device_model_id)->toBeNull();
});
test('setup endpoint matches MAC address case-insensitively', function (): void {
// Create device with lowercase MAC address
$device = Device::factory()->create([
'mac_address' => 'a1:b2:c3:d4:e5:f6',
'api_key' => 'test-api-key',
'friendly_id' => 'test-device',
]);
// Request with uppercase MAC address should still match
$response = $this->withHeaders([
'id' => 'A1:B2:C3:D4:E5:F6',
])->get('/api/setup');
$response->assertOk()
->assertJson([
'status' => 200,
'api_key' => 'test-api-key',
'friendly_id' => 'test-device',
'message' => 'Welcome to TRMNL BYOS',
]);
});
test('display endpoint matches MAC address case-insensitively', function (): void {
// Create device with lowercase MAC address
$device = Device::factory()->create([
'mac_address' => 'a1:b2:c3:d4:e5:f6',
'api_key' => 'test-api-key',
'current_screen_image' => 'test-image',
]);
// Request with uppercase MAC address should still match
$response = $this->withHeaders([
'id' => 'A1:B2:C3:D4:E5:F6',
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk()
->assertJson([
'status' => '0',
'filename' => 'test-image.bmp',
]);
});
test('screens endpoint matches MAC address case-insensitively', function (): void {
Queue::fake();
// Create device with uppercase MAC address
$device = Device::factory()->create([
'mac_address' => 'A1:B2:C3:D4:E5:F6',
'api_key' => 'test-api-key',
]);
// Request with lowercase MAC address should still match
$response = $this->withHeaders([
'id' => 'a1:b2:c3:d4:e5:f6',
'access-token' => $device->api_key,
])->post('/api/screens', [
'image' => [
'content' => 'Test content
',
],
]);
$response->assertOk();
Queue::assertPushed(GenerateScreenJob::class);
});
test('display endpoint handles plugin rendering errors gracefully', function (): void {
TrmnlPipeline::fake();
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'proxy_cloud' => false,
]);
// Create a plugin with Blade markup that will cause an exception when accessing data[0]
// when data is not an array or doesn't have index 0
$plugin = Plugin::factory()->create([
'name' => 'Broken Recipe',
'data_strategy' => 'polling',
'polling_url' => null,
'data_stale_minutes' => 1,
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
'render_markup' => '{{ $data[0]["invalid"] }}
', // This will fail if data[0] doesn't exist
'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail
'data_payload_updated_at' => now()->subMinutes(2), // Make it stale
'current_image' => null,
]);
$playlist = Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'test_playlist',
'is_active' => true,
'weekdays' => null,
'active_from' => null,
'active_until' => null,
]);
PlaylistItem::factory()->create([
'playlist_id' => $playlist->id,
'plugin_id' => $plugin->id,
'order' => 1,
'is_active' => true,
'last_displayed_at' => null,
]);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
// Verify error screen was generated and set on device
$device->refresh();
expect($device->current_screen_image)->not->toBeNull();
// Verify the error image exists
$errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png");
// The TrmnlPipeline is faked, so we just verify the UUID was set
expect($device->current_screen_image)->toBeString();
});
test('display endpoint handles mashup rendering errors gracefully', function (): void {
TrmnlPipeline::fake();
$device = Device::factory()->create([
'mac_address' => '00:11:22:33:44:55',
'api_key' => 'test-api-key',
'proxy_cloud' => false,
]);
// Create plugins for mashup, one with invalid markup
$plugin1 = Plugin::factory()->create([
'name' => 'Working Plugin',
'data_strategy' => 'polling',
'polling_url' => null,
'data_stale_minutes' => 1,
'render_markup_view' => 'trmnl',
'data_payload_updated_at' => now()->subMinutes(2),
'current_image' => null,
]);
$plugin2 = Plugin::factory()->create([
'name' => 'Broken Plugin',
'data_strategy' => 'polling',
'polling_url' => null,
'data_stale_minutes' => 1,
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
'render_markup' => '{{ $data[0]["invalid"] }}
', // This will fail
'data_payload' => ['error' => 'Failed to fetch data'],
'data_payload_updated_at' => now()->subMinutes(2),
'current_image' => null,
]);
$playlist = Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'test_playlist',
'is_active' => true,
'weekdays' => null,
'active_from' => null,
'active_until' => null,
]);
// Create mashup playlist item
$playlistItem = PlaylistItem::createMashup(
$playlist,
'1Lx1R',
[$plugin1->id, $plugin2->id],
'Test Mashup',
1
);
$response = $this->withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'rssi' => -70,
'battery_voltage' => 3.8,
'fw-version' => '1.0.0',
])->get('/api/display');
$response->assertOk();
// Verify error screen was generated and set on device
$device->refresh();
expect($device->current_screen_image)->not->toBeNull();
// Verify the error image UUID was set
expect($device->current_screen_image)->toBeString();
});
test('generateDefaultScreenImage creates error screen with plugin name', function (): void {
TrmnlPipeline::fake();
Storage::fake('public');
Storage::disk('public')->makeDirectory('/images/generated');
$device = Device::factory()->create();
$errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name');
expect($errorUuid)->not->toBeEmpty();
// Verify the error image path would be created
$errorPath = "images/generated/{$errorUuid}.png";
// Since TrmnlPipeline is faked, we just verify the UUID was generated
expect($errorUuid)->toBeString();
});
test('generateDefaultScreenImage throws exception for invalid error image type', function (): void {
$device = Device::factory()->create();
expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type'))
->toThrow(InvalidArgumentException::class);
});
test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void {
$device = new Device();
$device->deviceModel = null;
$result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error');
expect($result)->toBeNull();
});