T3-11/14/15 + M2 잔여: 권한 정책 UI(PermissionGate/PermissionRequested 다이얼로그+SavesInProfile), 추적 방지(TrackerBlocklist/WebResourceRequested 차단+실드 배지·팝업), 주소창 진행 바+파라미터 제거 복사(UrlCleaner/NavProgressTracker), U5 완료 알림(Mobile.Core 감지기+Plugin.LocalNotification), 재생중 ongoing 미디어 알림, 썸네일/해상도 API(Thumbnailer 사이드카 캐시+/thumb)+라이브러리 그리드, 외부망 안내(ConnectionTroubleshoot). 147 tests GREEN

This commit is contained in:
Yun Chan 2026-08-15 18:11:49 +09:00
parent 27c91c04dd
commit 46c6c321f2
26 changed files with 1058 additions and 30 deletions

View file

@ -0,0 +1,46 @@
using VideoDownloader.Browser;
namespace VideoDownloader.Tests.Browser;
/// <summary>T3-11: 사이트별 권한 정책 — 기억된 상태 우선, 미기억은 Ask(프롬프트).</summary>
public class PermissionGateTests
{
[Fact]
public void 미기억_권한은_Ask()
=> Assert.Equal(PermissionVerdict.Ask, new PermissionGate().Decide("notifications", "https://x.com"));
[Theory]
[InlineData("allow")]
[InlineData("deny")]
public void 기억된_판정은_그대로_반환(string v)
{
var g = new PermissionGate();
g.Remember("notifications", "https://x.com", v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny);
Assert.Equal(v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny,
g.Decide("notifications", "https://x.com"));
}
[Fact]
public void 판정은_출처_단위_격리()
{
var g = new PermissionGate();
g.Remember("geolocation", "https://a.com", PermissionVerdict.Allow);
Assert.Equal(PermissionVerdict.Ask, g.Decide("geolocation", "https://b.com"));
}
[Fact]
public void 판정은_종류_단위_격리()
{
var g = new PermissionGate();
g.Remember("notifications", "https://a.com", PermissionVerdict.Allow);
Assert.Equal(PermissionVerdict.Ask, g.Decide("camera", "https://a.com"));
}
[Fact]
public void OtherSensors_등_민감_종류는_자동_거절()
=> Assert.Equal(PermissionVerdict.Deny, new PermissionGate().Decide("OtherSensors", "https://x.com"));
[Fact]
public void MultipleAutomaticDownloads_파일_다운로드는_자동_허용()
=> Assert.Equal(PermissionVerdict.Allow, new PermissionGate().Decide("MultipleAutomaticDownloads", "https://x.com"));
}

View file

@ -0,0 +1,40 @@
using VideoDownloader.Browser;
namespace VideoDownloader.Tests.Browser;
/// <summary>T3-14: 추적 방지 — 블랙리스트 도메인 매칭(서브도메인 포함) + 차단 카운터.</summary>
public class TrackerBlockTests
{
[Theory]
[InlineData("https://google-analytics.com/analytics.js", true)] // 정확히 도메인
[InlineData("https://www.google-analytics.com/g/collect", true)] // 서브도메인
[InlineData("https://x.com/notgoogle-analytics.com/a", false)] // 접미사 함정 제외
[InlineData("https://doubleclick.net/instream/ad_status.js", true)]
[InlineData("https://safe.example.com/lib.js", false)] // 무관 도메인
public void Matches_블랙리스트_도메인_판정(string url, bool expected)
=> Assert.Equal(expected, new TrackerBlocklist().Matches(new Uri(url)));
[Fact]
public void 커스텀_도메인_주입_가능()
{
var bl = new TrackerBlocklist(new[] { "blocked.test" });
Assert.True(bl.Matches(new Uri("https://t.blocked.test/x.js")));
Assert.False(bl.Matches(new Uri("https://ok.com/x.js")));
}
[Fact]
public void Counter_차단_누적_도메인별_집계_변경_통지()
{
var c = new TrackerCounter();
var changed = 0;
c.Changed += () => changed++;
Assert.Equal(0, c.Total);
c.Record("https://www.google-analytics.com/a.js");
c.Record("https://google-analytics.com/b.js");
c.Record("https://scorecardresearch.com/c.js");
Assert.Equal(3, c.Total);
Assert.Equal(2, c.ByDomain["google-analytics.com"]);
Assert.Equal(1, c.ByDomain["scorecardresearch.com"]);
Assert.Equal(3, changed);
}
}

View file

@ -0,0 +1,80 @@
using VideoDownloader.Browser;
namespace VideoDownloader.Tests.Browser;
/// <summary>T3-15: 파라미터 제거 복사 + 주소창 진행 상태.</summary>
public class UrlCleanerTests
{
[Theory]
[InlineData("https://x.com/a?utm_source=1&b=2", "https://x.com/a")] // 쿼리 제거
[InlineData("https://x.com/a#frag", "https://x.com/a")] // 프래그먼트 제거
[InlineData("https://x.com/a?b=1#f", "https://x.com/a")] // 둘 다
[InlineData("https://x.com/a/", "https://x.com/a/")] // 없으면 무변경
[InlineData("https://x.com/a?q=한글", "https://x.com/a")] // 유니코드 쿼리
[InlineData("x.com/a?b=1", "x.com/a")] // 스킴 없어도 동작
public void StripQuery_파라미터와_프래그먼트를_제거(string input, string expected)
=> Assert.Equal(expected, UrlCleaner.StripQuery(input));
[Fact]
public void StripQuery_빈값_null_안전()
{
Assert.Equal("", UrlCleaner.StripQuery(""));
Assert.Null(UrlCleaner.StripQuery(null));
}
}
/// <summary>T3-15: 탭 전체 네비게이션 진행 집계 — 주소창 진행 바 표시 상태.</summary>
public class NavProgressTrackerTests
{
[Fact]
public void 초기값은_비진행()
=> Assert.False(new NavProgressTracker().IsNavigating);
[Fact]
public void 시작하면_진행()
{
var t = new NavProgressTracker();
t.Started();
Assert.True(t.IsNavigating);
}
[Fact]
public void 완료되면_비진행()
{
var t = new NavProgressTracker();
t.Started();
t.Completed();
Assert.False(t.IsNavigating);
}
[Fact]
public void 동시_내비게이션은_마지막_완료까지_진행() // 프레임 리다이렉트·중복 시작
{
var t = new NavProgressTracker();
t.Started(); t.Started();
t.Completed();
Assert.True(t.IsNavigating);
t.Completed();
Assert.False(t.IsNavigating);
}
[Fact]
public void 과잉_완료로_음수_되지_않음()
{
var t = new NavProgressTracker();
t.Completed();
Assert.False(t.IsNavigating);
}
[Fact]
public void IsNavigating_변경_시_INPC_발화()
{
var t = new NavProgressTracker();
var fired = new List<string?>();
t.PropertyChanged += (_, e) => fired.Add(e.PropertyName);
t.Started();
t.Completed();
t.Completed(); // false→false → 이벤트 없음
Assert.Equal(new[] { nameof(NavProgressTracker.IsNavigating), nameof(NavProgressTracker.IsNavigating) }, fired);
}
}

View file

@ -0,0 +1,109 @@
using System.Windows;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.Browser;
using VideoDownloader.Tests.Infrastructure;
namespace VideoDownloader.Tests.E2E;
/// <summary>
/// E2E (T3-11/T3-14): 실제 WebView2 에서 권한 정책 응답과 추적 요청 차단·집계를 검증.
/// TabViewService 와 동일한 배선(PermissionGate/TrackerBlocklist/TrackerCounter)을 사용한다.
/// </summary>
[Collection("E2E")]
public class PermissionTrackerE2ETests
{
[Fact]
public async Task 권한_요청이_정책을_거쳐_허용된다_T3_11()
{
using var sta = StaDispatcher.Start();
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
var result = await sta.InvokeAsync(async () =>
{
var window = new Window();
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
window.Content = browser;
window.Show();
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
await browser.EnsureCoreWebView2Async(env);
var cv = browser.CoreWebView2;
// TabViewService 배선과 동일: 기억된 판정은 즉시 응답(출처는 이벤트에서 관측된 것으로 기억)
var gate = new PermissionGate();
cv.PermissionRequested += (_, e) =>
{
var o = VideoDownloader.App.Services.TabViewService.OriginOf(e.Uri);
gate.Remember("Notifications", o, PermissionVerdict.Allow); // E2E: 알림은 허용 정책
var verdict = gate.Decide(e.PermissionKind.ToString(), o);
e.State = verdict == PermissionVerdict.Allow ? CoreWebView2PermissionState.Allow : CoreWebView2PermissionState.Deny;
e.Handled = true;
};
var html = "<html><body><script>Notification.requestPermission().then(p => { document.title = 'PERM=' + p; });</script></body></html>";
cv.NavigateToString(html);
await WaitUntilNavCompleted(cv);
var title = await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""),
t => t.StartsWith("PERM=", StringComparison.Ordinal), 10000);
window.Close();
return title;
});
try { Directory.Delete(udf, true); } catch { }
Assert.Equal("PERM=granted", result);
}
[Fact]
public async Task 블랙리스트_스크립트가_차단되고_카운터에_집계된다_T3_14()
{
using var sta = StaDispatcher.Start();
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
var result = await sta.InvokeAsync(async () =>
{
var window = new Window();
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
window.Content = browser;
window.Show();
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
await browser.EnsureCoreWebView2Async(env);
var cv = browser.CoreWebView2;
var bl = new TrackerBlocklist(new[] { "blocked.test" });
var counter = new TrackerCounter();
cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
cv.WebResourceRequested += (_, e) =>
{
if (!bl.Matches(new Uri(e.Request.Uri))) return;
e.Response = env.CreateWebResourceResponse(null, 204, "Blocked", "");
counter.Record(e.Request.Uri);
};
var html = "<html><body><p>tracker-e2e</p>" +
"<script src=\"https://www.blocked.test/a.js\"></script>" +
"<script src=\"https://www.blocked.test/b.js\"></script>" +
"<script src=\"https://ok.example.com/c.js\"></script>" +
"</body></html>";
cv.NavigateToString(html);
await WaitUntilNavCompleted(cv);
await Wait.Until(() => Task.FromResult(counter.Total), t => t >= 2, 10000);
window.Close();
return counter.Total;
});
try { Directory.Delete(udf, true); } catch { }
Assert.Equal(2, result); // 정상 도메인(ok.example.com)은 집계되지 않음
}
private static async Task WaitUntilNavCompleted(CoreWebView2 cv)
{
var tcs = new TaskCompletionSource();
EventHandler<CoreWebView2NavigationCompletedEventArgs> h = (_, _) => tcs.TrySetResult();
cv.NavigationCompleted += h;
if (tcs.Task.IsCompleted) return;
await Task.WhenAny(tcs.Task, Task.Delay(15000));
cv.NavigationCompleted -= h;
}
}

View file

@ -0,0 +1,106 @@
using VideoDownloader.Mobile;
namespace VideoDownloader.Tests.Mobile;
/// <summary>Mobile.Core 순수 로직 — U5 완료 감지, 외부망 안내, U4 저장소(파일 시스템).</summary>
public class MobileCoreTests
{
// ── U5 완료 알림 감지 ──
[Fact]
public void 완료_전환_항목_ID_반환()
{
var d = new QueueCompletionDetector();
var prev = new[] { ("a", "다운로드 중"), ("b", "대기") };
var next = new[] { ("a", "완료"), ("b", "다운로드 중") };
Assert.Equal(new[] { "a" }, d.Detect(prev, next));
}
[Fact]
public void 이미_완료였던_항목은_재알림_없음()
{
var d = new QueueCompletionDetector();
var prev = new[] { ("a", "완료") };
var next = new[] { ("a", "완료") };
Assert.Empty(d.Detect(prev, next));
}
[Fact]
public void 첫_폴링에서_이미_완료된_항목은_무시() // 앱 시작 시 과거 완료 스팸 방지
{
var d = new QueueCompletionDetector();
Assert.Empty(d.Detect(Array.Empty<(string, string)>(), new[] { ("a", "완료") }));
}
[Fact]
public void 목록에서_사라진_항목은_알림_없음()
{
var d = new QueueCompletionDetector();
var prev = new[] { ("a", "다운로드 중") };
Assert.Empty(d.Detect(prev, Array.Empty<(string, string)>()));
}
// ── 외부망 안내(MOBILE_PLAN §7) ──
[Fact]
public void 미연결_안내()
{
var m = ConnectionTroubleshoot.Message(isConfigured: false, lastError: null);
Assert.Contains("연결", m);
}
[Fact]
public void 연결됐지만_실패_시_같은네트워크_VPN_안내()
{
var m = ConnectionTroubleshoot.Message(isConfigured: true, lastError: "No such host is known.");
Assert.Contains("같은 네트워크", m);
Assert.Contains("Tailscale", m); // 계획 §3: 자체 릴레이 대신 Tailscale 문서화
}
[Fact]
public void 정상_시_빈_문자열()
=> Assert.Equal("", ConnectionTroubleshoot.Message(isConfigured: true, lastError: null));
// ── U4 저장소(임시 디렉터리로 파일 시스템 검증) ──
[Fact]
public void OfflineStore_기록_목록_삭제_라운드트립()
{
var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
var store = new OfflineStore(baseDir);
Assert.Empty(store.List());
var p = store.PathFor("id1");
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
File.WriteAllBytes(p, new byte[123]);
store.Record("id1", "video.mp4");
Assert.True(store.Exists("id1"));
var list = store.List();
Assert.Single(list);
Assert.Equal(("id1", "video.mp4", 123L), (list[0].Id, list[0].FileName, list[0].SizeBytes));
store.Delete("id1");
Assert.False(store.Exists("id1"));
Assert.Empty(store.List());
Directory.Delete(baseDir, true);
}
[Fact]
public void OfflineStore_재시작_시_매니페스트_유지()
{
var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
var store = new OfflineStore(baseDir);
var p = store.PathFor("id2");
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
File.WriteAllBytes(p, new byte[10]);
store.Record("id2", "keep.mp4");
var reopened = new OfflineStore(baseDir); // 새 인스턴스 = 앱 재시작
Assert.True(reopened.Exists("id2"));
Assert.Equal("keep.mp4", reopened.List().Single().FileName);
Directory.Delete(baseDir, true);
}
}

View file

@ -155,6 +155,44 @@ public class RemoteServerE2ETests : IDisposable
Assert.Equal(93.5, persisted!.PositionSec);
}
[Fact]
public async Task 썸네일_API_E2E_생성_캐시_404()
{
Directory.CreateDirectory(_libDir);
var mediaPath = Path.Combine(_libDir, "clip.mp4");
File.WriteAllBytes(mediaPath, new byte[2048]);
// 가짜 ffmpeg: 출력 경로에 JPEG 생성
var fake = new Tests.Platform.FakeProcessRunner
{
Behavior = (spec, _, _) =>
{
File.WriteAllBytes(spec.Args[^1], new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x01 });
return Task.FromResult(new VideoDownloader.Core.Platform.ProcessResult(0, "", ""));
},
};
await using var server = await ServerHost.StartAsync(new ServerOptions
{
Port = 0, Token = "thumb-token", LibraryFolder = _libDir,
Queue = new FakeQueue(),
Thumbnailer = new Thumbnailer(fake),
});
using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") };
// 1) 익명 401
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await http.GetAsync("/api/library/xyz/thumb")).StatusCode);
// 2) 없는 항목 404
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "thumb-token");
Assert.Equal(System.Net.HttpStatusCode.NotFound, (await http.GetAsync("/api/library/xyz/thumb")).StatusCode);
// 3) 실제 항목 → 200 + JPEG 매직
var lib = (await http.GetFromJsonAsync<List<LibraryItemDto>>("/api/library"))!;
var thumb = await http.GetByteArrayAsync($"/api/library/{lib[0].Id}/thumb");
Assert.True(thumb.Length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8, "JPEG 매직 바이트");
}
public void Dispose()
{
try { Directory.Delete(_libDir, true); } catch { }

View file

@ -0,0 +1,84 @@
using VideoDownloader.Core.Platform;
using VideoDownloader.Server;
using VideoDownloader.Tests.Platform;
namespace VideoDownloader.Tests.Server;
/// <summary>M2 라이브러리 썸네일/해상도 — ffmpeg 서브프로세스를 Fake 러너로 검증(사이드카 캐시).</summary>
public class ThumbnailerTests
{
private string _dir = "";
private string NewMedia()
{
_dir = Path.Combine(Path.GetTempPath(), $"vd_thumb_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
var p = Path.Combine(_dir, "clip.mp4");
File.WriteAllBytes(p, new byte[100]);
return p;
}
private static Task<ProcessResult> FakeFfmpegCreatesJpg(ProcessSpec spec, Action<string, string>? _, CancellationToken __)
{
// 출력 경로(마지막 인자)에 가짜 JPEG 생성
var outPath = spec.Args[^1];
File.WriteAllBytes(outPath, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }); // JPEG SOI/APP0
return Task.FromResult(new ProcessResult(0, "", ""));
}
private static Task<ProcessResult> FakeFfmpegReportsResolution(ProcessSpec spec, Action<string, string>? onLine, CancellationToken _)
{
var line = " Stream #0:0[0x1](und): Video: h264 (High), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 5000 kb/s";
onLine?.Invoke("err", line);
return Task.FromResult(new ProcessResult(0, "", line));
}
[Fact]
public async Task 썸네일_생성후_사이드카_캐시로_재실행_없음()
{
var media = NewMedia();
var runner = new FakeProcessRunner { Behavior = FakeFfmpegCreatesJpg };
var t = new Thumbnailer(runner);
var bytes1 = await t.GetThumbnailAsync("ffmpeg", media);
Assert.NotNull(bytes1);
Assert.True(bytes1!.Length == 4);
runner.Behavior = (_, _, _) => throw new Xunit.Sdk.XunitException("캐시 히트 시 ffmpeg 재실행 없음");
var bytes2 = await t.GetThumbnailAsync("ffmpeg", media); // 캐시
Assert.Equal(bytes1, bytes2);
Directory.Delete(_dir, true);
}
[Fact]
public async Task ffmpeg_실패_시_null()
{
var media = NewMedia();
var runner = new FakeProcessRunner { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(1, "", "boom")) };
Assert.Null(await new Thumbnailer(runner).GetThumbnailAsync("ffmpeg", media));
Directory.Delete(_dir, true);
}
[Fact]
public async Task 해상도_추출후_사이드카_캐시()
{
var media = NewMedia();
var runner = new FakeProcessRunner { Behavior = FakeFfmpegReportsResolution };
var t = new Thumbnailer(runner);
Assert.Equal("1920x1080", await t.GetResolutionAsync("ffmpeg", media));
runner.Behavior = (_, _, _) => throw new Xunit.Sdk.XunitException("캐시 히트 시 재실행 없음");
Assert.Equal("1920x1080", await t.GetResolutionAsync("ffmpeg", media));
Directory.Delete(_dir, true);
}
[Fact]
public async Task 해상기_추출_불가_시_null()
{
var media = NewMedia();
var runner = new FakeProcessRunner { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(0, "", "no streams")) };
Assert.Null(await new Thumbnailer(runner).GetResolutionAsync("ffmpeg", media));
Directory.Delete(_dir, true);
}
}

View file

@ -16,5 +16,6 @@
<ProjectReference Include="..\VideoDownloader.App\VideoDownloader.App.csproj" />
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
<ProjectReference Include="..\VideoDownloader.Server\VideoDownloader.Server.csproj" />
<ProjectReference Include="..\VideoDownloader.Mobile.Core\VideoDownloader.Mobile.Core.csproj" />
</ItemGroup>
</Project>