fix(archive): 지난 플레이를 현재 프로필 기록으로 제한

현재 profile schema의 Core2026 기록과 같은 source_profile의 PHP archive만 결합한다. 클라이언트 profile 입력과 7개 교차 profile 탭을 제거하고 API 및 Chromium 회귀 검증을 갱신한다.
This commit is contained in:
2026-08-19 05:52:12 +00:00
parent 71bbe9d240
commit 4c76afd576
4 changed files with 52 additions and 141 deletions
+12 -21
View File
@@ -23,7 +23,7 @@ import {
findLegacyGeneralsByOwner,
findLegacyGames,
findLegacyNations,
LEGACY_ARCHIVE_PROFILES,
isLegacyArchiveProfile,
type LegacyArchiveProfile,
type LegacyGeneralHallRow,
} from '../../services/legacyArchiveStore.js';
@@ -50,15 +50,10 @@ const canonicalSnapshot = (value: unknown, fallbackName: string): ArchivedGenera
const zPastPlayDetailInput = z.object({
source: z.enum(['current', 'legacy']).default('current'),
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES).optional(),
serverId: z.string().trim().min(1).max(64),
generalNo: z.number().int().positive(),
});
const zPastPlaysInput = z.object({
sourceProfile: z.enum(LEGACY_ARCHIVE_PROFILES),
});
type ArchiveSource = 'current' | 'legacy';
interface GeneralArchiveEntry {
@@ -232,18 +227,17 @@ const buildGeneralDetail = async (entry: GeneralArchiveEntry, nation: ReturnType
};
export const archiveRouter = router({
myPastPlays: readOnlyAuthedProcedure.input(zPastPlaysInput).query(async ({ ctx, input }) => {
myPastPlays: readOnlyAuthedProcedure.query(async ({ ctx }) => {
const owner = ctx.auth?.user.id;
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
const legacyProfile = isLegacyArchiveProfile(ctx.profile.id) ? ctx.profile.id : null;
const [legacyRows, currentRows] = await Promise.all([
findLegacyGeneralsByOwner(ctx.db, { owner, sourceProfile: input.sourceProfile }),
input.sourceProfile === ctx.profile.id
? ctx.db.oldGeneral.findMany({
where: { owner },
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
})
: [],
legacyProfile ? findLegacyGeneralsByOwner(ctx.db, { owner, sourceProfile: legacyProfile }) : [],
ctx.db.oldGeneral.findMany({
where: { owner },
orderBy: [{ lastYearMonth: 'desc' }, { serverId: 'desc' }, { generalNo: 'asc' }],
}),
]);
const legacyIdentity = new Set(
legacyRows.map((row) => `${row.sourceProfile}:${row.serverId}:${row.generalNo}`)
@@ -488,10 +482,7 @@ export const archiveRouter = router({
myPastPlayDetail: readOnlyAuthedProcedure.input(zPastPlayDetailInput).query(async ({ ctx, input }) => {
const owner = ctx.auth?.user.id;
if (!owner) throw new Error('Authenticated archive query is missing its user identity');
const sourceProfile = input.sourceProfile ?? ctx.profile.id;
if (input.source === 'current' && sourceProfile !== ctx.profile.id) {
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
}
const sourceProfile = ctx.profile.id;
let entry: GeneralArchiveEntry | null = null;
let nationRows: ArchiveNationEntry[] = [];
@@ -500,10 +491,10 @@ export const archiveRouter = router({
let battleResultAvailable = false;
let hallBattle = legacyHallBattleSummary([]);
if (input.source === 'legacy') {
if (!LEGACY_ARCHIVE_PROFILES.includes(sourceProfile as LegacyArchiveProfile)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '지원하지 않는 이전 서버 프로필입니다.' });
if (!isLegacyArchiveProfile(sourceProfile)) {
throw new TRPCError({ code: 'NOT_FOUND', message: '지난 장수 기록을 찾을 수 없습니다.' });
}
const profile = sourceProfile as LegacyArchiveProfile;
const profile: LegacyArchiveProfile = sourceProfile;
const row = await findLegacyGeneral(ctx.db, {
owner,
sourceProfile: profile,
+26 -29
View File
@@ -57,11 +57,11 @@ const context = (
];
}
if (sql.includes('legacy_archive"."general')) {
if (!query.values?.includes('hwe')) return [];
if (!query.values?.includes('che')) return [];
return [
{
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
sourceProfile: 'che',
serverId: 'che_archive_1',
generalNo: 21,
legacyId: 21,
owner: 'user-1',
@@ -146,8 +146,8 @@ const context = (
if (sql.includes('legacy_archive"."game_history')) {
return [
{
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
sourceProfile: 'che',
serverId: 'che_archive_1',
legacyId: 1,
openedAt: new Date('2019-09-21T00:00:00.000Z'),
completedAt: null,
@@ -164,9 +164,9 @@ const context = (
if (sql.includes('legacy_archive"."nation')) {
return [
{
sourceProfile: 'hwe',
sourceProfile: 'che',
legacyId: 1,
serverId: 'hwe_archive_1',
serverId: 'che_archive_1',
nation: 2,
data: { name: '이전국', color: '#0000ff', level: 7 },
archivedAt: new Date('2020-01-02T00:00:00.000Z'),
@@ -174,7 +174,7 @@ const context = (
];
}
if (sql.includes('legacy_archive"."emperor')) {
return [{ id: 99n, sourceProfile: 'hwe', legacyId: 1, serverId: 'hwe_archive_1', data: {} }];
return [{ id: 99n, sourceProfile: 'che', legacyId: 1, serverId: 'che_archive_1', data: {} }];
}
return [];
},
@@ -314,12 +314,10 @@ const context = (
describe('archive.myPastPlays', () => {
it('requires authentication and returns only the authenticated owner archive', async () => {
await expect(
appRouter.createCaller(context(null)).archive.myPastPlays({ sourceProfile: 'che' })
).rejects.toMatchObject({
await expect(appRouter.createCaller(context(null)).archive.myPastPlays()).rejects.toMatchObject({
code: 'UNAUTHORIZED',
});
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays({ sourceProfile: 'che' });
const result = await appRouter.createCaller(context(auth)).archive.myPastPlays();
expect(result.seasons).toEqual([
expect.objectContaining({
source: 'current',
@@ -397,9 +395,7 @@ describe('archive.myPastPlays', () => {
});
it('labels a retained cancellation as an unnumbered abandoned game without a dynasty link', async () => {
const result = await appRouter
.createCaller(context(auth, false, true))
.archive.myPastPlays({ sourceProfile: 'che' });
const result = await appRouter.createCaller(context(auth, false, true)).archive.myPastPlays();
expect(result.seasons).toEqual([
expect.objectContaining({
@@ -415,12 +411,12 @@ describe('archive.myPastPlays', () => {
it('returns normalized previous-server detail from the dedicated archive without exposing raw data', async () => {
const caller = appRouter.createCaller(context(auth, true));
const list = await caller.archive.myPastPlays({ sourceProfile: 'hwe' });
const list = await caller.archive.myPastPlays();
expect(list.seasons).toContainEqual(
expect.objectContaining({
source: 'legacy',
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
sourceProfile: 'che',
serverId: 'che_archive_1',
openedAt: '2019-09-21T00:00:00.000Z',
dynastyId: 99,
generals: [expect.objectContaining({ name: '이전서버장수', nationName: '이전국' })],
@@ -429,13 +425,12 @@ describe('archive.myPastPlays', () => {
const detail = await caller.archive.myPastPlayDetail({
source: 'legacy',
sourceProfile: 'hwe',
serverId: 'hwe_archive_1',
serverId: 'che_archive_1',
generalNo: 21,
});
expect(detail).toMatchObject({
source: 'legacy',
sourceProfile: 'hwe',
sourceProfile: 'che',
dynastyPath: '/dynasty/99?source=legacy',
nation: { id: 2, name: '이전국', color: '#0000ff' },
general: expect.objectContaining({
@@ -473,16 +468,18 @@ describe('archive.myPastPlays', () => {
expect(JSON.stringify(detail)).not.toContain('raw_data');
});
it('limits the archive list to the requested source profile', async () => {
it('combines only the current profile Core and PHP archives without a profile selector', async () => {
const caller = appRouter.createCaller(context(auth, true));
const che = await caller.archive.myPastPlays({ sourceProfile: 'che' });
expect(che.seasons).toHaveLength(1);
expect(che.seasons[0]?.sourceProfile).toBe('che');
const result = await caller.archive.myPastPlays();
expect(result.seasons).toHaveLength(2);
expect(result.seasons.map((season) => season.sourceProfile)).toEqual(['che', 'che']);
expect(result.seasons.map((season) => season.source).sort()).toEqual(['current', 'legacy']);
const hwe = await caller.archive.myPastPlays({ sourceProfile: 'hwe' });
expect(hwe.seasons).toHaveLength(1);
expect(hwe.seasons[0]?.sourceProfile).toBe('hwe');
expect(hwe.seasons[0]?.source).toBe('legacy');
const queryWithInjectedProfile = caller.archive.myPastPlays as unknown as (input: {
sourceProfile: string;
}) => ReturnType<typeof caller.archive.myPastPlays>;
const attemptedCrossProfile = await queryWithInjectedProfile({ sourceProfile: 'hwe' });
expect(attemptedCrossProfile.seasons.map((season) => season.sourceProfile)).toEqual(['che', 'che']);
});
});