Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions IPCamLapse.Tests/CamerasPageTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using IPCamLapse.Models;
using IPCamLapse.Services;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;

namespace IPCamLapse.Tests;

public sealed class CamerasPageTests
{
[Fact]
public async Task SavedPasswordNeverAppearsAndTestActionPostsOnlyProfileId()
{
const string sentinel = "SENTINEL-camera-password";
var root = Path.Combine(Path.GetTempPath(), $"ipcamlapse-cameras-page-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);

try
{
await using var factory = new IPCamLapseFactory(root);
using var client = factory.CreateClient();
var profiles = factory.Services.GetRequiredService<ICameraProfileService>();
await profiles.SaveAsync(new CameraProfile
{
Id = "a1b2c3d4",
Name = "Studio camera",
Url = "http://192.168.1.25/snapshot",
Username = "operator"
}, sentinel);

var response = await client.GetAsync("/Cameras");
response.EnsureSuccessStatusCode();
var html = await response.Content.ReadAsStringAsync();

Assert.DoesNotContain(sentinel, html, StringComparison.Ordinal);
Assert.Contains("data-profile-id=\"a1b2c3d4\"", html, StringComparison.Ordinal);
Assert.Contains("data-profile-id=\"demo\"", html, StringComparison.Ordinal);
Assert.Contains("role=\"status\"", html, StringComparison.Ordinal);
Assert.Contains("aria-live=\"polite\"", html, StringComparison.Ordinal);
Assert.Contains("body: JSON.stringify({ profileId })", html, StringComparison.Ordinal);
Assert.Contains("button.disabled = true", html, StringComparison.Ordinal);
Assert.Contains("button.disabled = false", html, StringComparison.Ordinal);
Assert.Contains("finally", html, StringComparison.Ordinal);
Assert.Contains("durationMs", html, StringComparison.Ordinal);
}
finally
{
if (Directory.Exists(root))
Directory.Delete(root, recursive: true);
}
}

private sealed class IPCamLapseFactory : WebApplicationFactory<Program>
{
private readonly string _root;

public IPCamLapseFactory(string root) => _root = root;

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("Storage:DataPath", Path.Combine(_root, "data"));
builder.UseSetting("DataProtection:KeysPath", "data-protection-keys");
}
}
}
48 changes: 47 additions & 1 deletion IPCamLapse/Pages/Cameras/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@
<p class="small mb-0"><i class="bi bi-person me-1"></i>@(profile.Username ?? "No username")</p>
}
</div>
<div class="card-footer bg-transparent d-flex gap-2">
<div class="card-footer bg-transparent d-flex flex-wrap gap-2">
<a asp-page="/Sessions/Create" class="btn btn-sm btn-outline-primary flex-fill">Use camera <i class="bi bi-arrow-right ms-1" aria-hidden="true"></i></a>
<button type="button"
class="btn btn-sm btn-outline-secondary camera-test-button"
data-profile-id="@profile.Id">
<i class="bi bi-wifi me-1" aria-hidden="true"></i>Test connection
</button>
@if (!profile.IsDemo)
{
<a asp-page="/Cameras/Edit" asp-route-id="@profile.Id" class="btn btn-sm btn-outline-secondary">Edit</a>
Expand All @@ -58,8 +63,49 @@
</button>
</form>
}
<div class="camera-test-status small w-100"
role="status"
aria-live="polite"></div>
</div>
</article>
</div>
}
</div>

@section Scripts {
<script>
document.querySelectorAll('.camera-test-button').forEach(button => {
button.addEventListener('click', async () => {
const card = button.closest('.camera-card');
const status = card.querySelector('.camera-test-status');
const profileId = button.dataset.profileId;
button.disabled = true;
card.setAttribute('aria-busy', 'true');
status.className = 'camera-test-status small w-100 text-muted';
status.textContent = 'Testing camera connection…';

try {
const response = await window.ipCamLapseFetch('/api/camera/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId })
});
const data = await response.json();
const duration = Number.isFinite(data.durationMs)
? ` · ${Math.round(data.durationMs)} ms`
: '';
if (!response.ok)
throw new Error(`${data.message || 'Connection failed'}${duration}`);
status.className = 'camera-test-status small w-100 text-success';
status.textContent = `Connected${duration}`;
} catch (error) {
status.className = 'camera-test-status small w-100 text-danger';
status.textContent = error.message || 'Connection failed';
} finally {
button.disabled = false;
card.removeAttribute('aria-busy');
}
});
});
</script>
}