Skip to content

Commit d768e00

Browse files
authored
test: add unit test (vspo-lab#686)
1 parent a12fe47 commit d768e00

30 files changed

+19242
-150
lines changed

service/server/.env.test

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
SERVICE_NAME=vspo-server
2+
ENVIRONMENT=local
3+
LOG_TYPE=pretty
4+
LOG_MINLEVEL=0
5+
LOG_HIDE_POSITION=false
6+
OPENAI_ORGANIZATION=xxx
7+
OPENAI_PROJECT=xxx
8+
OPENAI_API_KEY=xxx
9+
OPENAI_BASE_URL=xxx
10+
BASELIME_API_KEY=xxx
11+
OTEL_EXPORTER_URL=http://localhost:4318/v1/traces
12+
YOUTUBE_API_KEY=xxx
13+
TWITCH_CLIENT_ID=xxx
14+
TWITCH_CLIENT_SECRET=xxx
15+
TWITCASTING_ACCESS_TOKEN=xxx
16+
DEV_DB_CONNECTION_STRING=postgres://user:password@localhost:5432/vspo
17+
DISCORD_APPLICATION_ID=xxx
18+
DISCORD_PUBLIC_KEY=xxx
19+
DISCORD_TOKEN=xxx

service/server/domain/service/video.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,18 @@ export class VideoService implements IVideoService {
6767
query: query.VSPO_JP,
6868
eventType: "live",
6969
}),
70-
// this.deps.youtubeClient.searchVideos({ query: query.VSPO_EN, eventType: "live" }),
70+
this.deps.youtubeClient.searchVideos({
71+
query: query.VSPO_EN,
72+
eventType: "live",
73+
}),
7174
this.deps.youtubeClient.searchVideos({
7275
query: query.VSPO_JP,
7376
eventType: "upcoming",
7477
}),
75-
// this.deps.youtubeClient.searchVideos({ query: query.VSPO_EN, eventType: "upcoming" }),
78+
this.deps.youtubeClient.searchVideos({
79+
query: query.VSPO_EN,
80+
eventType: "upcoming",
81+
}),
7682
];
7783

7884
const results = await Promise.allSettled(promises);
@@ -98,15 +104,19 @@ export class VideoService implements IVideoService {
98104
.concat(c.val.en.map((c) => c.channel?.youtube?.rawId))
99105
.filter((id) => id !== undefined);
100106

101-
const videoIds = videos
102-
.map((v) => {
103-
const channelId = v.rawChannelID;
104-
if (channelIds.includes(channelId)) {
105-
return v.rawId;
106-
}
107-
return null;
108-
})
109-
.filter((id) => id !== null);
107+
const videoIds = Array.from(
108+
new Set(
109+
videos
110+
.map((v) => {
111+
const channelId = v.rawChannelID;
112+
if (channelIds.includes(channelId)) {
113+
return v.rawId;
114+
}
115+
return null;
116+
})
117+
.filter((id) => id !== null),
118+
),
119+
);
110120

111121
const fetchedVideos = await this.getVideosByIDs({
112122
youtubeVideoIds: videoIds,
@@ -128,14 +138,12 @@ export class VideoService implements IVideoService {
128138
.map((c) => c.channel?.twitch?.rawId)
129139
.concat(c.val.en.map((c) => c.channel?.twitch?.rawId))
130140
.filter((id) => id !== undefined);
131-
132141
const result = await this.deps.twitchClient.getStreams({
133142
userIds: userIds,
134143
});
135144
if (result.err) {
136145
return result;
137146
}
138-
139147
return Ok(result.val);
140148
}
141149

service/server/domain/video.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ export {
157157
type Video,
158158
type Videos,
159159
type Platform,
160+
type VideoInput,
161+
type VideosInput,
160162
createVideo,
161163
createVideos,
162164
};

service/server/infra/discord/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ export class DiscordClient implements IDiscordClient {
7979
params: SendMessageParams,
8080
): Promise<Result<void, AppError>> {
8181
const { channelId, content, embeds } = params;
82-
console.log("sendMessage", JSON.stringify(params));
8382
const responseResult = await wrap(
8483
this.rest.sendMessage(channelId, { content, embeds }),
8584
(err: Error) =>

service/server/infra/repository/creator.ts

Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -186,20 +186,30 @@ export class CreatorRepository implements ICreatorRepository {
186186
const dbChannels: InsertChannel[] = [];
187187

188188
for (const c of creators) {
189-
dbCreatorss.push({
190-
id: c.id,
191-
memberType: c.memberType,
192-
representativeThumbnailUrl: c.thumbnailURL,
193-
updatedAt: getCurrentUTCDate(),
194-
});
189+
if (!dbCreatorss.some((creator) => creator.id === c.id)) {
190+
dbCreatorss.push({
191+
id: c.id,
192+
memberType: c.memberType,
193+
representativeThumbnailUrl: c.thumbnailURL,
194+
updatedAt: getCurrentUTCDate(),
195+
});
196+
}
195197

196-
dbCreatorTranslations.push({
197-
id: c.id,
198-
creatorId: c.id,
199-
languageCode: c.languageCode,
200-
name: c.name,
201-
updatedAt: getCurrentUTCDate(),
202-
});
198+
if (
199+
!dbCreatorTranslations.some(
200+
(translation) =>
201+
translation.creatorId === c.id &&
202+
translation.languageCode === c.languageCode,
203+
)
204+
) {
205+
dbCreatorTranslations.push({
206+
id: c.id,
207+
creatorId: c.id,
208+
languageCode: c.languageCode,
209+
name: c.name,
210+
updatedAt: getCurrentUTCDate(),
211+
});
212+
}
203213

204214
if (!c.channel) {
205215
continue;
@@ -209,19 +219,21 @@ export class CreatorRepository implements ICreatorRepository {
209219
if (!d.detail) {
210220
continue;
211221
}
212-
dbChannels.push({
213-
id: c.channel.id,
214-
platformChannelId: d.detail.rawId,
215-
creatorId: c.id,
216-
platformType: d.platform,
217-
title: d.detail.name,
218-
description: d.detail.description ?? "",
219-
thumbnailUrl: d.detail.thumbnailURL,
220-
publishedAt: d.detail?.publishedAt
221-
? convertToUTCDate(d.detail.publishedAt)
222-
: getCurrentUTCDate(),
223-
subscriberCount: d.detail.subscriberCount ?? 0,
224-
});
222+
if (!d.detail.rawId) {
223+
dbChannels.push({
224+
id: c.channel.id,
225+
platformChannelId: d.detail.rawId,
226+
creatorId: c.id,
227+
platformType: d.platform,
228+
title: d.detail.name,
229+
description: d.detail.description ?? "",
230+
thumbnailUrl: d.detail.thumbnailURL,
231+
publishedAt: d.detail?.publishedAt
232+
? convertToUTCDate(d.detail.publishedAt)
233+
: getCurrentUTCDate(),
234+
subscriberCount: d.detail.subscriberCount ?? 0,
235+
});
236+
}
225237
}
226238

227239
const creatorResult = await wrap(

service/server/infra/repository/transaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export interface ITxManager {
2828
): Promise<Result<T, E>>;
2929
}
3030

31-
const defaultConfig: PgTransactionConfig = {
31+
export const defaultConfig: PgTransactionConfig = {
3232
isolationLevel: "read committed",
3333
accessMode: "read write",
3434
deferrable: false,

service/server/infra/repository/video.ts

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -194,39 +194,54 @@ export class VideoRepository implements IVideoRepository {
194194

195195
for (const v of videos) {
196196
const videoId = v.id || createUUID();
197-
dbVideos.push(
198-
createInsertVideo({
199-
id: videoId,
200-
rawId: v.rawId,
201-
channelId: v.rawChannelID,
202-
platformType: v.platform,
203-
videoType: v.videoType,
204-
publishedAt: convertToUTCDate(v.publishedAt),
205-
tags: v.tags.join(","),
206-
thumbnailUrl: v.thumbnailURL,
207-
}),
197+
const existingVideo = dbVideos.find((video) => video.rawId === v.rawId);
198+
if (!existingVideo) {
199+
dbVideos.push(
200+
createInsertVideo({
201+
id: videoId,
202+
rawId: v.rawId,
203+
channelId: v.rawChannelID,
204+
platformType: v.platform,
205+
videoType: v.videoType,
206+
publishedAt: convertToUTCDate(v.publishedAt),
207+
tags: v.tags.join(","),
208+
thumbnailUrl: v.thumbnailURL,
209+
}),
210+
);
211+
}
212+
213+
const existingStreamStatus = dbStreamStatus.find(
214+
(status) => status.videoId === v.rawId,
208215
);
216+
if (!existingStreamStatus) {
217+
dbStreamStatus.push(
218+
createInsertStreamStatus({
219+
id: createUUID(),
220+
videoId: v.rawId,
221+
status: v.status,
222+
startedAt: v.startedAt ? convertToUTCDate(v.startedAt) : null,
223+
endedAt: v.endedAt ? convertToUTCDate(v.endedAt) : null,
224+
viewCount: v.viewCount,
225+
updatedAt: getCurrentUTCDate(),
226+
}),
227+
);
228+
}
209229

210-
dbStreamStatus.push(
211-
createInsertStreamStatus({
230+
const existingTranslation = dbVideoTranslation.find(
231+
(translation) =>
232+
translation.videoId === v.rawId &&
233+
translation.languageCode === v.languageCode,
234+
);
235+
if (!existingTranslation) {
236+
dbVideoTranslation.push({
212237
id: createUUID(),
213238
videoId: v.rawId,
214-
status: v.status,
215-
startedAt: v.startedAt ? convertToUTCDate(v.startedAt) : null,
216-
endedAt: v.endedAt ? convertToUTCDate(v.endedAt) : null,
217-
viewCount: v.viewCount,
239+
languageCode: v.languageCode,
240+
title: v.title,
241+
description: v.description,
218242
updatedAt: getCurrentUTCDate(),
219-
}),
220-
);
221-
222-
dbVideoTranslation.push({
223-
id: createUUID(),
224-
videoId: v.rawId,
225-
languageCode: v.languageCode,
226-
title: v.title,
227-
description: v.description,
228-
updatedAt: getCurrentUTCDate(),
229-
});
243+
});
244+
}
230245
}
231246

232247
const videoResult = await wrap(
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
import {
3+
type TestCase,
4+
mockTwitcastingResponses,
5+
} from "../../test/mock/twitcasting";
6+
import { TwitcastingService } from "./index";
7+
8+
describe("TwitcastingService", () => {
9+
let twitcastingService: TwitcastingService;
10+
11+
beforeEach(() => {
12+
twitcastingService = new TwitcastingService("dummy_token");
13+
});
14+
15+
describe("getVideos", () => {
16+
const testCases: TestCase<{ userIds: string[] }>[] = [
17+
{
18+
name: "should fetch videos successfully",
19+
userIds: ["user_id_1"],
20+
mockResponses: [mockTwitcastingResponses.validMovies],
21+
expectedResult: {
22+
rawId: "movie_id_1",
23+
rawChannelID: "user_id_1",
24+
title: "🔴 Live Stream",
25+
platform: "twitcasting",
26+
status: "live",
27+
viewCount: 1000,
28+
tags: [],
29+
languageCode: "default",
30+
platformIconURL:
31+
"https://raw.githubusercontent.com/sugar-cat7/vspo-portal/main/service/server/assets/icon/twitcasting.png",
32+
link: "https://twitcasting.tv/user_id_1/movie/movie_id_1",
33+
statusColor: 16711680, // Red for live
34+
startedAt: new Date(1704067200 * 1000).toISOString(), // Convert UNIX timestamp
35+
endedAt: null,
36+
},
37+
},
38+
{
39+
name: "should handle API error - invalid token",
40+
userIds: ["invalid"],
41+
mockResponses: [mockTwitcastingResponses.invalidToken],
42+
expectedError:
43+
"Failed to fetch videos for user invalid: 401 Unauthorized",
44+
},
45+
{
46+
name: "should handle network error",
47+
userIds: ["error"],
48+
mockResponses: [mockTwitcastingResponses.networkError],
49+
expectedError: "Network error while fetching videos for user error",
50+
},
51+
{
52+
name: "should handle invalid response format",
53+
userIds: ["invalid_response"],
54+
mockResponses: [mockTwitcastingResponses.invalidResponse],
55+
expectedError:
56+
"Failed to fetch videos for user invalid_response: 400 Bad Request",
57+
},
58+
];
59+
60+
it.concurrent.each(testCases)(
61+
"$name",
62+
async ({ userIds, expectedError, expectedResult }) => {
63+
const result = await twitcastingService.getVideos({ userIds });
64+
65+
if (expectedError) {
66+
expect(result.err).toBeDefined();
67+
expect(result.err?.message).toContain(expectedError);
68+
return;
69+
}
70+
71+
expect(result.err).toBeUndefined();
72+
if (result.err || !expectedResult) return;
73+
74+
const videos = result.val;
75+
expect(videos).toHaveLength(2); // 2 videos in mock response
76+
const video = videos[0]; // Test first video (live)
77+
expect(video).toMatchObject(expectedResult);
78+
79+
// Test second video (ended)
80+
const endedVideo = videos[1];
81+
expect(endedVideo).toMatchObject({
82+
rawId: "movie_id_2",
83+
rawChannelID: "user_id_1",
84+
title: "Past Stream",
85+
platform: "twitcasting",
86+
status: "ended",
87+
viewCount: 5000,
88+
startedAt: new Date(1704153600 * 1000).toISOString(), // Convert UNIX timestamp
89+
});
90+
},
91+
);
92+
});
93+
});

0 commit comments

Comments
 (0)