using Application.Abstractions.Cache;
using Application.Abstractions.Data;
using Application.Abstractions.Stocks;
using Application.Features.Api.Forum.StockBoard.CreatePost;
using Domain.Entities.Common;
using Domain.Entities.Common.ValueObject;
using Domain.Entities.Forum.Posts.ValueObject;
using Domain.Entities.Members;
using Domain.Entities.Stocks;
using Domain.Entities.Stocks.ValueObject;
using Domain.Entities.Wallets;
using Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using CreateCommand = Application.Features.Api.Forum.StockBoard.CreatePost.Command;
using ListHandler = Application.Features.Api.Forum.StockBoard.ListPosts.Handler;
using ListQuery = Application.Features.Api.Forum.StockBoard.ListPosts.Query;
using PredictionHandler = Application.Features.Api.Forum.StockBoard.GetPrediction.Handler;
using PredictionQuery = Application.Features.Api.Forum.StockBoard.GetPrediction.Query;
namespace Application.Tests;
///
/// D2 M2 종목 가상보드 통합 테스트 — 글 작성(StockCode 세팅 + PostStockTag + StockBoardStats 증가 +
/// 예측 Pending 생성), 시세 없을 때 예측 거부, 목록 필터를 검증한다.
/// 공유 antooza_test DB 이므로 종목코드는 970xxx 고범위 + GUID 회원으로 충돌을 피한다.
///
[TestClass]
public sealed class StockBoardTests
{
// 캐시는 항상 miss → DB Config 폴백. RewardService 가 요구.
private sealed class NullCacheService : ICacheService
{
public Task GetAsync(string key, CancellationToken ct = default) => Task.FromResult(default);
public Task SetAsync(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
public Task SetAsync(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask;
public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask;
public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask;
public Task TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
}
// 트렌딩 캐시는 Redis 의존 — 무효화 no-op, 조회 미사용.
private sealed class NullTrendingCache : IStockBoardTrendingCache
{
public Task> GetTopAsync(int count, IAppDbContext db, CancellationToken ct = default)
=> Task.FromResult>([]);
public Task InvalidateAsync(CancellationToken ct = default) => Task.CompletedTask;
}
private static Handler NewHandler(AppDbContext db) => new(db, new NullCacheService(), new NullTrendingCache());
/// 회원 + MemberStats + Wallet 생성 후 ID 반환.
private static async Task CreateMemberAsync(AppDbContext db)
{
var member = Member.Create($"stockboard-{Guid.NewGuid():N}@antooza.test");
await db.Member.AddAsync(member);
await db.SaveChangesAsync(default);
await db.MemberStats.AddAsync(MemberStats.Create(member.ID));
await db.Wallet.AddAsync(Wallet.Create(member.ID));
await db.SaveChangesAsync(default);
return member.ID;
}
/// 종목 시드 후 코드 반환. price > 0 이면 일별 종가 1건 시드.
private static async Task CreateStockAsync(AppDbContext db, string code, int price = 0)
{
var stock = Stock.Create(code, $"테스트종목{code}", StockMarket.KOSPI, new DateOnly(2020, 1, 2));
await db.Stock.AddAsync(stock);
await db.SaveChangesAsync(default);
if (price > 0)
{
var daily = StockDailyPrice.Create(stock.ID, new DateOnly(2026, 7, 3), price, price, price, price, 1000, 1000000, 0, 0m);
await db.StockDailyPrice.AddAsync(daily);
await db.SaveChangesAsync(default);
}
return code;
}
/// 테스트 간 고유한 970xxx 종목코드 생성.
private static string NextCode()
{
// 970000~979999 범위에서 무작위 6자리
var n = 970000 + Random.Shared.Next(0, 10000);
return n.ToString("D6");
}
private static async Task SeedRewardConfigAsync(AppDbContext db)
{
var config = Config.Create();
config.UpdateReward(new RewardConfig { PostExp = 5, PostPoint = 10, MinPostLength = 0 });
await db.Config.AddAsync(config);
await db.SaveChangesAsync(default);
}
[TestMethod]
public async Task CreatePost_SetsStockCode_AndPostStockTag_AndStats()
{
var code = NextCode();
int memberID;
using (var db = TestDb.Create())
{
await SeedRewardConfigAsync(db);
memberID = await CreateMemberAsync(db);
await CreateStockAsync(db, code);
}
int postID;
using (var db = TestDb.Create())
{
var result = await NewHandler(db).Handle(
new CreateCommand(memberID, code, "분석글 제목", $"본문 $ {code} 은 예시. 실제 토큰 ${code}(테스트종목) 참고.", Prediction: null),
default);
Assert.IsTrue(result.IsSuccess, "정상 종목보드 글은 성공해야 한다");
postID = result.Value;
}
using (var verify = TestDb.Create())
{
var post = await verify.Post.AsNoTracking().SingleAsync(c => c.ID == postID);
Assert.AreEqual(code, post.StockCode, "Post.StockCode 가 설정된다");
// primary 코드 포함 + 중복 제거 → 1건
var tags = await verify.PostStockTag.AsNoTracking().Where(c => c.PostID == postID).ToListAsync();
Assert.AreEqual(1, tags.Count, "primary 코드 1건 (본문 $태그와 dedupe)");
Assert.AreEqual(code, tags[0].StockCode);
var stats = await verify.StockBoardStats.AsNoTracking().SingleAsync(c => c.StockCode == code);
Assert.AreEqual(1, stats.Posts, "StockBoardStats.Posts 가 1 증가");
Assert.IsNotNull(stats.LastPostAt);
}
}
[TestMethod]
public async Task CreatePost_SecondPost_IncrementsStats()
{
var code = NextCode();
int memberID;
using (var db = TestDb.Create())
{
await SeedRewardConfigAsync(db);
memberID = await CreateMemberAsync(db);
await CreateStockAsync(db, code);
}
using (var db = TestDb.Create())
{
await NewHandler(db).Handle(new CreateCommand(memberID, code, "첫 글", "본문1", null), default);
}
using (var db = TestDb.Create())
{
await NewHandler(db).Handle(new CreateCommand(memberID, code, "둘째 글", "본문2", null), default);
}
using (var verify = TestDb.Create())
{
var stats = await verify.StockBoardStats.AsNoTracking().SingleAsync(c => c.StockCode == code);
Assert.AreEqual(2, stats.Posts, "글 2건 → Posts=2");
}
}
[TestMethod]
public async Task CreatePost_WithPrediction_CreatesPending_WithBasePriceAndDueDate()
{
var code = NextCode();
int memberID;
using (var db = TestDb.Create())
{
await SeedRewardConfigAsync(db);
memberID = await CreateMemberAsync(db);
await CreateStockAsync(db, code, price: 70000);
}
int postID;
using (var db = TestDb.Create())
{
var result = await NewHandler(db).Handle(
new CreateCommand(memberID, code, "상승 예측", "오릅니다",
new PredictionInput(Direction: (byte)PredictionDirection.Up, HorizonDays: 5, TargetPrice: 80000m)),
default);
Assert.IsTrue(result.IsSuccess, "시세가 있으면 예측 글은 성공");
postID = result.Value;
}
using (var verify = TestDb.Create())
{
var prediction = await verify.PostPrediction.AsNoTracking().SingleAsync(c => c.PostID == postID);
Assert.AreEqual(PredictionStatus.Pending, prediction.Status, "생성 시 Pending");
Assert.AreEqual(70000m, prediction.BasePrice, "BasePrice = 최신 종가");
Assert.AreEqual(80000m, prediction.TargetPrice);
Assert.AreEqual(PredictionDirection.Up, prediction.Direction);
Assert.AreEqual((short)5, prediction.HorizonDays);
Assert.IsTrue(prediction.DueDate > new DateOnly(2026, 1, 1), "DueDate 가 산정된다");
Assert.IsNull(prediction.SettledPrice, "M2 는 미채점");
Assert.IsNull(prediction.SettledAt);
}
}
[TestMethod]
public async Task CreatePost_PredictionButNoPrice_RejectsWholeRequest()
{
var code = NextCode();
int memberID;
using (var db = TestDb.Create())
{
await SeedRewardConfigAsync(db);
memberID = await CreateMemberAsync(db);
await CreateStockAsync(db, code, price: 0); // 시세 없음
}
using (var db = TestDb.Create())
{
var result = await NewHandler(db).Handle(
new CreateCommand(memberID, code, "예측 시도", "본문",
new PredictionInput((byte)PredictionDirection.Down, 20, null)),
default);
Assert.IsTrue(result.IsFailure, "시세 없으면 예측 요청 전체 실패");
Assert.AreEqual("StockBoard.NoPrice", result.Error.Code);
}
using (var verify = TestDb.Create())
{
Assert.AreEqual(0, await verify.Post.CountAsync(c => c.StockCode == code), "실패 시 글도 생성되지 않는다");
Assert.AreEqual(0, await verify.StockBoardStats.CountAsync(c => c.StockCode == code), "실패 시 통계 미증가");
}
}
[TestMethod]
public async Task CreatePost_UnknownStock_Rejected()
{
var code = NextCode();
int memberID;
using (var db = TestDb.Create())
{
await SeedRewardConfigAsync(db);
memberID = await CreateMemberAsync(db);
// 종목 시드하지 않음
}
using (var db = TestDb.Create())
{
var result = await NewHandler(db).Handle(new CreateCommand(memberID, code, "제목", "본문", null), default);
Assert.IsTrue(result.IsFailure, "존재하지 않는 종목은 거부");
Assert.AreEqual("StockBoard.StockNotFound", result.Error.Code);
}
}
[TestMethod]
public async Task ListPosts_FiltersByStockCode_WithStatsHeader()
{
var codeA = NextCode();
var codeB = NextCode();
int memberID;
using (var db = TestDb.Create())
{
await SeedRewardConfigAsync(db);
memberID = await CreateMemberAsync(db);
await CreateStockAsync(db, codeA);
await CreateStockAsync(db, codeB);
}
using (var db = TestDb.Create())
{
await NewHandler(db).Handle(new CreateCommand(memberID, codeA, "A글1", "본문", null), default);
await NewHandler(db).Handle(new CreateCommand(memberID, codeA, "A글2", "본문", null), default);
await NewHandler(db).Handle(new CreateCommand(memberID, codeB, "B글1", "본문", null), default);
}
using (var verify = TestDb.Create())
{
var result = await new ListHandler(verify).Handle(new ListQuery(codeA, Page: 1, PerPage: 20), default);
Assert.AreEqual(2, result.Total, "codeA 파티션은 2건만");
Assert.IsTrue(result.List.All(c => c.StockCode == codeA), "다른 종목 글은 섞이지 않는다");
Assert.AreEqual(2, result.Stats.Posts, "헤더 통계 = 2");
Assert.AreEqual(codeA, result.Stats.StockCode);
Assert.IsNotNull(result.Stats.StockName, "종목명 조인");
}
}
[TestMethod]
public async Task GetPrediction_ReturnsMeta_OrNotFound()
{
var code = NextCode();
int memberID;
using (var db = TestDb.Create())
{
await SeedRewardConfigAsync(db);
memberID = await CreateMemberAsync(db);
await CreateStockAsync(db, code, price: 50000);
}
int predPostID, plainPostID;
using (var db = TestDb.Create())
{
var r1 = await NewHandler(db).Handle(
new CreateCommand(memberID, code, "예측글", "본문",
new PredictionInput((byte)PredictionDirection.Up, 5, null)),
default);
predPostID = r1.Value;
var r2 = await NewHandler(db).Handle(new CreateCommand(memberID, code, "일반글", "본문", null), default);
plainPostID = r2.Value;
}
using (var verify = TestDb.Create())
{
var hasPred = await new PredictionHandler(verify).Handle(new PredictionQuery(predPostID), default);
Assert.IsTrue(hasPred.IsSuccess, "예측 있는 글은 메타 반환");
Assert.AreEqual(PredictionStatus.Pending, (PredictionStatus)hasPred.Value.Status);
Assert.AreEqual("상승", hasPred.Value.DirectionLabel);
Assert.AreEqual(50000m, hasPred.Value.BasePrice);
var noPred = await new PredictionHandler(verify).Handle(new PredictionQuery(plainPostID), default);
Assert.IsTrue(noPred.IsFailure, "예측 없는 글은 NotFound");
Assert.AreEqual("Prediction.NotFound", noPred.Error.Code);
}
}
}