init
This commit is contained in:
13
server/Dockerfile
Normal file
13
server/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:24-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
2851
server/package-lock.json
generated
Normal file
2851
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
server/package.json
Normal file
25
server/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "zen-kanji-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"test": "vitest",
|
||||
"test:cov": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"fastify": "^5.6.2",
|
||||
"fastify-cors": "^6.0.3",
|
||||
"mongoose": "^9.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
73
server/server.js
Normal file
73
server/server.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import { PORT, JWT_SECRET } from './src/config/constants.js';
|
||||
import { connectDB } from './src/config/db.js';
|
||||
import routes from './src/routes/v1.js';
|
||||
import { User } from './src/models/User.js';
|
||||
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
await connectDB();
|
||||
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173',
|
||||
'http://localhost',
|
||||
'capacitor://localhost',
|
||||
'https://10.0.2.2:5173',
|
||||
'https://zenkanji.crylia.de'
|
||||
];
|
||||
|
||||
if (process.env.CORS_ORIGINS) {
|
||||
const prodOrigins = process.env.CORS_ORIGINS.split(',');
|
||||
allowedOrigins.push(...prodOrigins);
|
||||
}
|
||||
|
||||
await fastify.register(cors, {
|
||||
origin: allowedOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
credentials: true
|
||||
});
|
||||
|
||||
await fastify.register(jwt, {
|
||||
secret: JWT_SECRET
|
||||
});
|
||||
|
||||
fastify.decorate('authenticate', async function (req, reply) {
|
||||
try {
|
||||
const payload = await req.jwtVerify();
|
||||
|
||||
const user = await User.findById(payload.userId);
|
||||
|
||||
if (!user) {
|
||||
reply.code(401).send({ message: 'User not found', code: 'INVALID_USER' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.version !== user.tokenVersion) {
|
||||
reply.code(401).send({ message: 'Session invalid', code: 'INVALID_SESSION' });
|
||||
return;
|
||||
}
|
||||
if (payload.version !== user.tokenVersion) {
|
||||
throw new Error('Session invalid');
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
|
||||
} catch (err) {
|
||||
reply.code(401).send(err);
|
||||
}
|
||||
});
|
||||
|
||||
await fastify.register(routes);
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
await fastify.listen({ port: PORT, host: '0.0.0.0' });
|
||||
console.log(`Server running at http://localhost:${PORT}`);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
start();
|
||||
4
server/src/config/constants.js
Normal file
4
server/src/config/constants.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export const PORT = process.env.PORT || 3000;
|
||||
export const MONGO_URI = process.env.MONGO_URI || 'mongodb://mongo:27017/zenkanji';
|
||||
export const SRS_TIMINGS_HOURS = [0, 0, 4, 8, 23, 47];
|
||||
export const JWT_SECRET = process.env.JWT_SECRET;
|
||||
12
server/src/config/db.js
Normal file
12
server/src/config/db.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import mongoose from 'mongoose';
|
||||
import { MONGO_URI } from './constants.js';
|
||||
|
||||
export const connectDB = async () => {
|
||||
try {
|
||||
await mongoose.connect(MONGO_URI);
|
||||
console.log('✅ Connected to MongoDB');
|
||||
} catch (err) {
|
||||
console.error('❌ MongoDB Connection Error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
28
server/src/controllers/auth.controller.js
Normal file
28
server/src/controllers/auth.controller.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as AuthService from '../services/auth.service.js';
|
||||
|
||||
export const login = async (req, reply) => {
|
||||
const { apiKey } = req.body;
|
||||
if (!apiKey) return reply.code(400).send({ error: 'API Key required' });
|
||||
|
||||
try {
|
||||
const user = await AuthService.loginUser(apiKey);
|
||||
|
||||
const token = await reply.jwtSign(
|
||||
{ userId: user._id, version: user.tokenVersion },
|
||||
{ expiresIn: '365d' }
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user: { settings: user.settings, stats: user.stats }
|
||||
};
|
||||
} catch (err) {
|
||||
return reply.code(401).send({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async (req, reply) => {
|
||||
await AuthService.logoutUser(req.user._id);
|
||||
return { success: true };
|
||||
};
|
||||
33
server/src/controllers/collection.controller.js
Normal file
33
server/src/controllers/collection.controller.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { User } from '../models/User.js';
|
||||
import * as ReviewService from '../services/review.service.js';
|
||||
import * as StatsService from '../services/stats.service.js';
|
||||
import { StudyItem } from '../models/StudyItem.js';
|
||||
|
||||
export const getCollection = async (req, reply) => {
|
||||
const items = await StudyItem.find({ userId: req.user._id });
|
||||
return reply.send(items);
|
||||
};
|
||||
|
||||
export const getQueue = async (req, reply) => {
|
||||
const { limit, sort } = req.query;
|
||||
const queue = await ReviewService.getQueue(req.user, parseInt(limit) || 20, sort);
|
||||
return reply.send(queue);
|
||||
};
|
||||
|
||||
export const getStats = async (req, reply) => {
|
||||
const stats = await StatsService.getUserStats(req.user);
|
||||
return reply.send(stats);
|
||||
};
|
||||
|
||||
export const updateSettings = async (req, reply) => {
|
||||
const { batchSize, drawingAccuracy } = req.body;
|
||||
const user = req.user;
|
||||
|
||||
if (!user.settings) user.settings = {};
|
||||
|
||||
if (batchSize) user.settings.batchSize = batchSize;
|
||||
if (drawingAccuracy) user.settings.drawingAccuracy = drawingAccuracy;
|
||||
|
||||
await user.save();
|
||||
return reply.send({ success: true, settings: user.settings });
|
||||
};
|
||||
11
server/src/controllers/review.controller.js
Normal file
11
server/src/controllers/review.controller.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as ReviewService from '../services/review.service.js';
|
||||
|
||||
export const submitReview = async (req, reply) => {
|
||||
const { subjectId, success } = req.body;
|
||||
try {
|
||||
const result = await ReviewService.processReview(req.user, subjectId, success);
|
||||
return reply.send(result);
|
||||
} catch (err) {
|
||||
return reply.code(404).send({ error: err.message });
|
||||
}
|
||||
};
|
||||
10
server/src/controllers/sync.controller.js
Normal file
10
server/src/controllers/sync.controller.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as SyncService from '../services/sync.service.js';
|
||||
|
||||
export const sync = async (req, reply) => {
|
||||
try {
|
||||
const result = await SyncService.syncWithWaniKani(req.user);
|
||||
return reply.send(result);
|
||||
} catch (error) {
|
||||
return reply.code(500).send({ error: error.message });
|
||||
}
|
||||
};
|
||||
10
server/src/models/ReviewLog.js
Normal file
10
server/src/models/ReviewLog.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const reviewLogSchema = new mongoose.Schema({
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
date: { type: String, required: true },
|
||||
count: { type: Number, default: 0 }
|
||||
});
|
||||
|
||||
reviewLogSchema.index({ userId: 1, date: 1 }, { unique: true });
|
||||
export const ReviewLog = mongoose.model('ReviewLog', reviewLogSchema);
|
||||
21
server/src/models/StudyItem.js
Normal file
21
server/src/models/StudyItem.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const studyItemSchema = new mongoose.Schema({
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
wkSubjectId: { type: Number, required: true },
|
||||
char: { type: String, required: true },
|
||||
meaning: { type: String, required: true },
|
||||
level: { type: Number, required: true },
|
||||
srsLevel: { type: Number, default: 0 },
|
||||
nextReview: { type: Date, default: Date.now },
|
||||
onyomi: { type: [String], default: [] },
|
||||
kunyomi: { type: [String], default: [] },
|
||||
nanori: { type: [String], default: [] },
|
||||
stats: {
|
||||
correct: { type: Number, default: 0 },
|
||||
total: { type: Number, default: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
studyItemSchema.index({ userId: 1, wkSubjectId: 1 }, { unique: true });
|
||||
export const StudyItem = mongoose.model('StudyItem', studyItemSchema);
|
||||
21
server/src/models/User.js
Normal file
21
server/src/models/User.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const userSchema = new mongoose.Schema({
|
||||
wkApiKey: { type: String, required: true, unique: true },
|
||||
lastSync: { type: Date, default: Date.now },
|
||||
settings: {
|
||||
batchSize: { type: Number, default: 20 },
|
||||
drawingAccuracy: { type: Number, default: 10 }
|
||||
},
|
||||
tokenVersion: { type: Number, default: 0 },
|
||||
stats: {
|
||||
totalReviews: { type: Number, default: 0 },
|
||||
correctReviews: { type: Number, default: 0 },
|
||||
currentStreak: { type: Number, default: 0 },
|
||||
maxStreak: { type: Number, default: 0 },
|
||||
lastStudyDate: { type: String, default: null },
|
||||
lastFreezeDate: { type: Date, default: null }
|
||||
}
|
||||
});
|
||||
|
||||
export const User = mongoose.model('User', userSchema);
|
||||
22
server/src/routes/v1.js
Normal file
22
server/src/routes/v1.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { login, logout } from '../controllers/auth.controller.js';
|
||||
import { sync } from '../controllers/sync.controller.js';
|
||||
import { submitReview } from '../controllers/review.controller.js';
|
||||
import { getStats, getQueue, getCollection, updateSettings } from '../controllers/collection.controller.js';
|
||||
|
||||
async function routes(fastify, options) {
|
||||
fastify.post('/api/auth/login', login);
|
||||
|
||||
fastify.register(async (privateParams) => {
|
||||
privateParams.addHook('onRequest', fastify.authenticate);
|
||||
|
||||
privateParams.post('/api/auth/logout', logout);
|
||||
privateParams.post('/api/sync', sync);
|
||||
privateParams.post('/api/review', submitReview);
|
||||
privateParams.get('/api/stats', getStats);
|
||||
privateParams.get('/api/queue', getQueue);
|
||||
privateParams.get('/api/collection', getCollection);
|
||||
privateParams.post('/api/settings', updateSettings);
|
||||
});
|
||||
}
|
||||
|
||||
export default routes;
|
||||
29
server/src/services/auth.service.js
Normal file
29
server/src/services/auth.service.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { User } from '../models/User.js';
|
||||
|
||||
export const loginUser = async (apiKey) => {
|
||||
const response = await fetch('https://api.wanikani.com/v2/user', {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Invalid API Key');
|
||||
}
|
||||
|
||||
let user = await User.findOne({ wkApiKey: apiKey });
|
||||
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
wkApiKey: apiKey,
|
||||
tokenVersion: 0,
|
||||
stats: { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 },
|
||||
settings: { batchSize: 20 }
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
export const logoutUser = async (userId) => {
|
||||
await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
|
||||
return true;
|
||||
};
|
||||
85
server/src/services/review.service.js
Normal file
85
server/src/services/review.service.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ReviewLog } from '../models/ReviewLog.js';
|
||||
import { StudyItem } from '../models/StudyItem.js';
|
||||
import { getDaysDiff, getSRSDate } from '../utils/dateUtils.js';
|
||||
|
||||
export const processReview = async (user, subjectId, success) => {
|
||||
if (!user.stats) user.stats = { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 };
|
||||
user.stats.totalReviews += 1;
|
||||
if (success) user.stats.correctReviews += 1;
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const lastStudyStr = user.stats.lastStudyDate;
|
||||
|
||||
if (lastStudyStr !== todayStr) {
|
||||
if (!lastStudyStr) {
|
||||
user.stats.currentStreak = 1;
|
||||
} else {
|
||||
const diff = getDaysDiff(lastStudyStr, todayStr);
|
||||
if (diff === 1) {
|
||||
user.stats.currentStreak += 1;
|
||||
} else if (diff > 1) {
|
||||
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
|
||||
let daysSinceFreeze = 999;
|
||||
if (lastFreeze) daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
|
||||
|
||||
const canUseShield = daysSinceFreeze >= 7;
|
||||
|
||||
if (canUseShield && diff === 2) {
|
||||
console.log(`User ${user._id} saved by Zen Shield!`);
|
||||
user.stats.lastFreezeDate = new Date();
|
||||
user.stats.currentStreak += 1;
|
||||
} else {
|
||||
user.stats.currentStreak = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
user.stats.lastStudyDate = todayStr;
|
||||
if (user.stats.currentStreak > user.stats.maxStreak) user.stats.maxStreak = user.stats.currentStreak;
|
||||
}
|
||||
await user.save();
|
||||
|
||||
await ReviewLog.findOneAndUpdate(
|
||||
{ userId: user._id, date: todayStr },
|
||||
{ $inc: { count: 1 } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
|
||||
const item = await StudyItem.findOne({ userId: user._id, wkSubjectId: subjectId });
|
||||
if (!item) throw new Error('Item not found');
|
||||
|
||||
if (!item.stats) item.stats = { correct: 0, total: 0 };
|
||||
item.stats.total += 1;
|
||||
if (success) item.stats.correct += 1;
|
||||
|
||||
if (success) {
|
||||
const nextLevel = Math.min(item.srsLevel + 1, 10);
|
||||
item.srsLevel = nextLevel;
|
||||
item.nextReview = getSRSDate(nextLevel);
|
||||
} else {
|
||||
item.srsLevel = Math.max(1, item.srsLevel - 1);
|
||||
item.nextReview = Date.now();
|
||||
}
|
||||
|
||||
await item.save();
|
||||
return { nextReview: item.nextReview, srsLevel: item.srsLevel };
|
||||
};
|
||||
|
||||
export const getQueue = async (user, limit = 100, sortMode) => {
|
||||
const query = {
|
||||
userId: user._id,
|
||||
srsLevel: { $lt: 10, $gt: 0 },
|
||||
nextReview: { $lte: new Date() }
|
||||
};
|
||||
|
||||
let dueItems;
|
||||
if (sortMode === 'priority') {
|
||||
dueItems = await StudyItem.find(query).sort({ srsLevel: 1, level: 1 }).limit(limit);
|
||||
} else {
|
||||
dueItems = await StudyItem.find(query).limit(limit);
|
||||
for (let i = dueItems.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[dueItems[i], dueItems[j]] = [dueItems[j], dueItems[i]];
|
||||
}
|
||||
}
|
||||
return dueItems;
|
||||
};
|
||||
112
server/src/services/stats.service.js
Normal file
112
server/src/services/stats.service.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { StudyItem } from '../models/StudyItem.js';
|
||||
import { ReviewLog } from '../models/ReviewLog.js';
|
||||
import { getDaysDiff } from '../utils/dateUtils.js';
|
||||
|
||||
export const getUserStats = async (user) => {
|
||||
const userId = user._id;
|
||||
|
||||
const srsCounts = await StudyItem.aggregate([
|
||||
{ $match: { userId: userId } },
|
||||
{ $group: { _id: "$srsLevel", count: { $sum: 1 } } }
|
||||
]);
|
||||
const dist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
|
||||
srsCounts.forEach(g => { if (dist[g._id] !== undefined) dist[g._id] = g.count; });
|
||||
|
||||
const now = new Date();
|
||||
const next24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
const upcoming = await StudyItem.find({
|
||||
userId: userId,
|
||||
srsLevel: { $lt: 6, $gt: 0 },
|
||||
nextReview: { $lte: next24h }
|
||||
}).select('nextReview');
|
||||
|
||||
const forecast = new Array(24).fill(0);
|
||||
upcoming.forEach(item => {
|
||||
const diff = Math.floor((new Date(item.nextReview) - now) / 3600000);
|
||||
if (diff < 0) forecast[0]++;
|
||||
else if (diff < 24) forecast[diff]++;
|
||||
});
|
||||
|
||||
const queueItems = await StudyItem.find({
|
||||
userId: userId,
|
||||
srsLevel: { $lt: 6, $gt: 0 },
|
||||
nextReview: { $lte: now }
|
||||
}).select('srsLevel');
|
||||
const queueCount = queueItems.length;
|
||||
|
||||
let hasLowerLevels = false;
|
||||
let lowerLevelCount = 0;
|
||||
if (queueCount > 0) {
|
||||
const levels = queueItems.map(i => i.srsLevel);
|
||||
const minSrs = Math.min(...levels);
|
||||
const maxSrs = Math.max(...levels);
|
||||
if (minSrs < maxSrs) {
|
||||
hasLowerLevels = true;
|
||||
lowerLevelCount = queueItems.filter(i => i.srsLevel === minSrs).length;
|
||||
}
|
||||
}
|
||||
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
|
||||
const dateStr = oneYearAgo.toISOString().split('T')[0];
|
||||
const logs = await ReviewLog.find({ userId: userId, date: { $gte: dateStr } });
|
||||
const heatmap = {};
|
||||
logs.forEach(l => { heatmap[l.date] = l.count; });
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const lastStudyStr = user.stats.lastStudyDate || null;
|
||||
let displayStreak = user.stats.currentStreak || 0;
|
||||
|
||||
if (lastStudyStr) {
|
||||
const diff = getDaysDiff(lastStudyStr, todayStr);
|
||||
if (diff > 1) displayStreak = 0;
|
||||
}
|
||||
|
||||
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
|
||||
let daysSinceFreeze = 999;
|
||||
if (lastFreeze) {
|
||||
daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
|
||||
}
|
||||
|
||||
const shieldReady = daysSinceFreeze >= 7;
|
||||
const shieldCooldown = shieldReady ? 0 : (7 - daysSinceFreeze);
|
||||
|
||||
const history7Days = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
const dStr = d.toISOString().split('T')[0];
|
||||
const count = heatmap[dStr] || 0;
|
||||
history7Days.push({ date: dStr, active: count > 0 });
|
||||
}
|
||||
|
||||
const allGhosts = await StudyItem.find({ userId: userId, 'stats.total': { $gte: 2 } })
|
||||
.select('char meaning stats srsLevel');
|
||||
|
||||
const sortedGhosts = allGhosts.map(item => ({
|
||||
...item.toObject(),
|
||||
accuracy: Math.round((item.stats.correct / item.stats.total) * 100)
|
||||
})).filter(i => i.accuracy < 85)
|
||||
.sort((a, b) => a.accuracy - b.accuracy)
|
||||
.slice(0, 4);
|
||||
|
||||
return {
|
||||
distribution: dist,
|
||||
forecast: forecast,
|
||||
queueLength: queueCount,
|
||||
hasLowerLevels,
|
||||
lowerLevelCount,
|
||||
heatmap: heatmap,
|
||||
ghosts: sortedGhosts,
|
||||
streak: {
|
||||
current: displayStreak,
|
||||
best: user.stats.maxStreak || 0,
|
||||
shield: { ready: shieldReady, cooldown: Math.max(0, shieldCooldown) },
|
||||
history: history7Days
|
||||
},
|
||||
accuracy: {
|
||||
total: user.stats.totalReviews || 0,
|
||||
correct: user.stats.correctReviews || 0
|
||||
}
|
||||
};
|
||||
};
|
||||
78
server/src/services/sync.service.js
Normal file
78
server/src/services/sync.service.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { User } from '../models/User.js';
|
||||
import { StudyItem } from '../models/StudyItem.js';
|
||||
|
||||
export const syncWithWaniKani = async (user) => {
|
||||
const apiKey = user.wkApiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('User has no WaniKani API Key');
|
||||
}
|
||||
|
||||
console.log(`Starting sync for user: ${user._id}`);
|
||||
|
||||
let allSubjectIds = [];
|
||||
let nextUrl = 'https://api.wanikani.com/v2/assignments?subject_types=kanji&started=true';
|
||||
|
||||
try {
|
||||
while (nextUrl) {
|
||||
const res = await fetch(nextUrl, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`WaniKani API Error: ${res.statusText}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
allSubjectIds = allSubjectIds.concat(json.data.map(d => d.data.subject_id));
|
||||
nextUrl = json.pages.next_url;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching assignments:", err);
|
||||
throw new Error("Failed to connect to WaniKani. Check your internet connection.");
|
||||
}
|
||||
|
||||
if (allSubjectIds.length === 0) {
|
||||
return { count: 0, message: "No unlocked kanji found.", settings: user.settings };
|
||||
}
|
||||
|
||||
const existingItems = await StudyItem.find({ userId: user._id }).select('wkSubjectId');
|
||||
const existingIds = new Set(existingItems.map(i => i.wkSubjectId));
|
||||
const newIds = allSubjectIds.filter(id => !existingIds.has(id));
|
||||
|
||||
console.log(`Found ${newIds.length} new items to download.`);
|
||||
|
||||
const CHUNK_SIZE = 100;
|
||||
for (let i = 0; i < newIds.length; i += CHUNK_SIZE) {
|
||||
const chunk = newIds.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
const subRes = await fetch(`https://api.wanikani.com/v2/subjects?ids=${chunk.join(',')}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
const subJson = await subRes.json();
|
||||
|
||||
const operations = subJson.data.map(d => {
|
||||
const readings = d.data.readings || [];
|
||||
return {
|
||||
userId: user._id,
|
||||
wkSubjectId: d.id,
|
||||
char: d.data.characters,
|
||||
meaning: d.data.meanings.find(m => m.primary)?.meaning || 'Unknown',
|
||||
level: d.data.level,
|
||||
srsLevel: 1,
|
||||
nextReview: Date.now(),
|
||||
onyomi: readings.filter(r => r.type === 'onyomi').map(r => r.reading),
|
||||
kunyomi: readings.filter(r => r.type === 'kunyomi').map(r => r.reading),
|
||||
nanori: readings.filter(r => r.type === 'nanori').map(r => r.reading),
|
||||
stats: { correct: 0, total: 0 }
|
||||
};
|
||||
});
|
||||
|
||||
if (operations.length > 0) {
|
||||
await StudyItem.insertMany(operations);
|
||||
}
|
||||
}
|
||||
|
||||
const finalCount = await StudyItem.countDocuments({ userId: user._id });
|
||||
return { success: true, count: finalCount, settings: user.settings };
|
||||
};
|
||||
32
server/src/utils/dateUtils.js
Normal file
32
server/src/utils/dateUtils.js
Normal file
@@ -0,0 +1,32 @@
|
||||
export const getDaysDiff = (date1Str, date2Str) => {
|
||||
const d1 = new Date(date1Str);
|
||||
const d2 = new Date(date2Str);
|
||||
const diff = d2 - d1;
|
||||
return Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const getSRSDate = (level) => {
|
||||
const now = new Date();
|
||||
let hoursToAdd = 0;
|
||||
|
||||
switch (level) {
|
||||
case 1: hoursToAdd = 4; break;
|
||||
case 2: hoursToAdd = 8; break;
|
||||
case 3: hoursToAdd = 24; break;
|
||||
case 4: hoursToAdd = 2 * 24; break;
|
||||
case 5: hoursToAdd = 7 * 24; break;
|
||||
case 6: hoursToAdd = 14 * 24; break;
|
||||
|
||||
case 7: hoursToAdd = 7 * 24; break;
|
||||
case 8: hoursToAdd = 30 * 24; break;
|
||||
case 9: hoursToAdd = 90 * 24; break;
|
||||
|
||||
case 10: break;
|
||||
default: hoursToAdd = 4;
|
||||
}
|
||||
|
||||
if (hoursToAdd === 0) return null;
|
||||
|
||||
now.setUTCHours(now.getUTCHours() + hoursToAdd);
|
||||
return now;
|
||||
};
|
||||
52
server/tests/services/auth.service.test.js
Normal file
52
server/tests/services/auth.service.test.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { loginUser, logoutUser } from '../../src/services/auth.service.js';
|
||||
import { User } from '../../src/models/User.js';
|
||||
|
||||
vi.mock('../../src/models/User.js');
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('Auth Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('loginUser', () => {
|
||||
it('should throw error for invalid API Key', async () => {
|
||||
fetch.mockResolvedValue({ status: 401 });
|
||||
await expect(loginUser('bad_key')).rejects.toThrow('Invalid API Key');
|
||||
});
|
||||
|
||||
it('should return existing user if found', async () => {
|
||||
fetch.mockResolvedValue({ status: 200 });
|
||||
const mockUser = { wkApiKey: 'valid_key', _id: '123' };
|
||||
User.findOne.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await loginUser('valid_key');
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(User.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create new user if not found', async () => {
|
||||
fetch.mockResolvedValue({ status: 200 });
|
||||
User.findOne.mockResolvedValue(null);
|
||||
const newUser = { wkApiKey: 'valid_key', _id: 'new_id' };
|
||||
User.create.mockResolvedValue(newUser);
|
||||
|
||||
const result = await loginUser('valid_key');
|
||||
expect(result).toEqual(newUser);
|
||||
expect(User.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
wkApiKey: 'valid_key',
|
||||
tokenVersion: 0
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutUser', () => {
|
||||
it('should increment token version', async () => {
|
||||
User.findByIdAndUpdate.mockResolvedValue(true);
|
||||
const result = await logoutUser('userId');
|
||||
expect(User.findByIdAndUpdate).toHaveBeenCalledWith('userId', { $inc: { tokenVersion: 1 } });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
server/tests/services/controllers.test.js
Normal file
142
server/tests/services/controllers.test.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as AuthController from '../../src/controllers/auth.controller.js';
|
||||
import * as ReviewController from '../../src/controllers/review.controller.js';
|
||||
import * as SyncController from '../../src/controllers/sync.controller.js';
|
||||
import * as CollectionController from '../../src/controllers/collection.controller.js';
|
||||
|
||||
import * as AuthService from '../../src/services/auth.service.js';
|
||||
import * as ReviewService from '../../src/services/review.service.js';
|
||||
import * as SyncService from '../../src/services/sync.service.js';
|
||||
import * as StatsService from '../../src/services/stats.service.js';
|
||||
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||
|
||||
vi.mock('../../src/services/auth.service.js');
|
||||
vi.mock('../../src/services/review.service.js');
|
||||
vi.mock('../../src/services/sync.service.js');
|
||||
vi.mock('../../src/services/stats.service.js');
|
||||
vi.mock('../../src/models/StudyItem.js');
|
||||
|
||||
const mockReq = (body = {}, user = {}, query = {}) => ({ body, user, query });
|
||||
const mockReply = () => {
|
||||
const res = {};
|
||||
res.code = vi.fn().mockReturnValue(res);
|
||||
res.send = vi.fn().mockReturnValue(res);
|
||||
res.jwtSign = vi.fn().mockResolvedValue('token');
|
||||
return res;
|
||||
};
|
||||
|
||||
describe('Controllers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Auth Controller', () => {
|
||||
it('login should fail without apiKey', async () => {
|
||||
const reply = mockReply();
|
||||
await AuthController.login(mockReq({}), reply);
|
||||
expect(reply.code).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('login should succeed', async () => {
|
||||
const reply = mockReply();
|
||||
AuthService.loginUser.mockResolvedValue({ _id: 1, tokenVersion: 1 });
|
||||
await AuthController.login(mockReq({ apiKey: 'key' }), reply);
|
||||
expect(reply.jwtSign).toHaveBeenCalled();
|
||||
expect(reply.code).not.toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('login should catch errors', async () => {
|
||||
const reply = mockReply();
|
||||
AuthService.loginUser.mockRejectedValue(new Error('fail'));
|
||||
await AuthController.login(mockReq({ apiKey: 'k' }), reply);
|
||||
expect(reply.code).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('logout should succeed', async () => {
|
||||
const reply = mockReply();
|
||||
await AuthController.logout(mockReq({}, { _id: 1 }), reply);
|
||||
expect(AuthService.logoutUser).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Review Controller', () => {
|
||||
it('submitReview should succeed', async () => {
|
||||
const reply = mockReply();
|
||||
ReviewService.processReview.mockResolvedValue({});
|
||||
await ReviewController.submitReview(mockReq({}), reply);
|
||||
expect(reply.send).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submitReview should handle error', async () => {
|
||||
const reply = mockReply();
|
||||
ReviewService.processReview.mockRejectedValue(new Error('err'));
|
||||
await ReviewController.submitReview(mockReq({}), reply);
|
||||
expect(reply.code).toHaveBeenCalledWith(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sync Controller', () => {
|
||||
it('sync should succeed', async () => {
|
||||
const reply = mockReply();
|
||||
SyncService.syncWithWaniKani.mockResolvedValue({});
|
||||
await SyncController.sync(mockReq({}, {}), reply);
|
||||
expect(reply.send).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sync should handle error', async () => {
|
||||
const reply = mockReply();
|
||||
SyncService.syncWithWaniKani.mockRejectedValue(new Error('err'));
|
||||
await SyncController.sync(mockReq({}, {}), reply);
|
||||
expect(reply.code).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Collection Controller', () => {
|
||||
it('getCollection should return items', async () => {
|
||||
const reply = mockReply();
|
||||
StudyItem.find.mockResolvedValue([]);
|
||||
await CollectionController.getCollection(mockReq({}, { _id: 1 }), reply);
|
||||
expect(reply.send).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('getQueue should call service with default limit', async () => {
|
||||
const reply = mockReply();
|
||||
ReviewService.getQueue.mockResolvedValue([]);
|
||||
await CollectionController.getQueue(mockReq({}, {}, {}), reply);
|
||||
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 20, undefined);
|
||||
});
|
||||
|
||||
it('getQueue should call service with provided limit', async () => {
|
||||
const reply = mockReply();
|
||||
ReviewService.getQueue.mockResolvedValue([]);
|
||||
await CollectionController.getQueue(mockReq({}, {}, { limit: '50' }), reply);
|
||||
expect(ReviewService.getQueue).toHaveBeenCalledWith(expect.anything(), 50, undefined);
|
||||
});
|
||||
|
||||
it('getStats should call service', async () => {
|
||||
const reply = mockReply();
|
||||
StatsService.getUserStats.mockResolvedValue({});
|
||||
await CollectionController.getStats(mockReq({}, {}), reply);
|
||||
expect(StatsService.getUserStats).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateSettings should initialize settings if missing', async () => {
|
||||
const reply = mockReply();
|
||||
const save = vi.fn();
|
||||
const user = { save };
|
||||
await CollectionController.updateSettings(mockReq({ batchSize: 50 }, user), reply);
|
||||
expect(user.settings).toBeDefined();
|
||||
expect(user.settings.batchSize).toBe(50);
|
||||
expect(save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateSettings should update drawingAccuracy', async () => {
|
||||
const reply = mockReply();
|
||||
const save = vi.fn();
|
||||
const user = { settings: {}, save };
|
||||
await CollectionController.updateSettings(mockReq({ drawingAccuracy: 5 }, user), reply);
|
||||
expect(user.settings.drawingAccuracy).toBe(5);
|
||||
expect(save).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
144
server/tests/services/review.service.test.js
Normal file
144
server/tests/services/review.service.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as ReviewService from '../../src/services/review.service.js';
|
||||
import { User } from '../../src/models/User.js';
|
||||
import { ReviewLog } from '../../src/models/ReviewLog.js';
|
||||
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||
|
||||
vi.mock('../../src/models/ReviewLog.js');
|
||||
vi.mock('../../src/models/StudyItem.js');
|
||||
|
||||
describe('Review Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2023-01-10T00:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('processReview', () => {
|
||||
const mockUser = (stats = {}) => ({
|
||||
_id: 'u1',
|
||||
stats: { ...stats },
|
||||
save: vi.fn()
|
||||
});
|
||||
|
||||
const mockItem = (srs = 1) => ({
|
||||
userId: 'u1',
|
||||
wkSubjectId: 100,
|
||||
srsLevel: srs,
|
||||
stats: { correct: 0, total: 0 },
|
||||
save: vi.fn(),
|
||||
nextReview: null
|
||||
});
|
||||
|
||||
it('should throw error if item not found', async () => {
|
||||
const user = mockUser();
|
||||
StudyItem.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(ReviewService.processReview(user, 999, true))
|
||||
.rejects.toThrow('Item not found');
|
||||
|
||||
expect(StudyItem.findOne).toHaveBeenCalledWith({
|
||||
userId: user._id,
|
||||
wkSubjectId: 999
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle standard success flow', async () => {
|
||||
const user = mockUser();
|
||||
delete user.stats;
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
const res = await ReviewService.processReview(user, 100, true);
|
||||
expect(user.save).toHaveBeenCalled();
|
||||
expect(res.srsLevel).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle failure flow', async () => {
|
||||
const user = mockUser();
|
||||
const item = mockItem(2);
|
||||
StudyItem.findOne.mockResolvedValue(item);
|
||||
await ReviewService.processReview(user, 100, false);
|
||||
expect(item.srsLevel).toBe(1);
|
||||
});
|
||||
|
||||
it('should increment streak (diff = 1)', async () => {
|
||||
const user = mockUser({ lastStudyDate: '2023-01-09', currentStreak: 5 });
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
await ReviewService.processReview(user, 100, true);
|
||||
expect(user.stats.currentStreak).toBe(6);
|
||||
});
|
||||
|
||||
it('should maintain streak (diff = 0)', async () => {
|
||||
const user = mockUser({ lastStudyDate: '2023-01-10', currentStreak: 5 });
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
await ReviewService.processReview(user, 100, true);
|
||||
expect(user.stats.currentStreak).toBe(5);
|
||||
});
|
||||
|
||||
it('should reset streak (diff > 1, no shield)', async () => {
|
||||
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2023-01-09' });
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
await ReviewService.processReview(user, 100, true);
|
||||
expect(user.stats.currentStreak).toBe(1);
|
||||
});
|
||||
|
||||
it('should use shield (diff = 2, shield ready)', async () => {
|
||||
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5, lastFreezeDate: '2022-01-01' });
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
await ReviewService.processReview(user, 100, true);
|
||||
expect(user.stats.currentStreak).toBe(6);
|
||||
expect(user.stats.lastFreezeDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use shield (diff = 2) when lastFreezeDate is undefined', async () => {
|
||||
const user = mockUser({ lastStudyDate: '2023-01-08', currentStreak: 5 });
|
||||
user.stats.lastFreezeDate = null;
|
||||
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
|
||||
await ReviewService.processReview(user, 100, true);
|
||||
|
||||
expect(user.stats.currentStreak).toBe(6);
|
||||
expect(user.stats.lastFreezeDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should initialize item stats if missing', async () => {
|
||||
const user = mockUser();
|
||||
const item = mockItem();
|
||||
delete item.stats;
|
||||
StudyItem.findOne.mockResolvedValue(item);
|
||||
|
||||
await ReviewService.processReview(user, 100, true);
|
||||
|
||||
expect(item.stats).toEqual(expect.objectContaining({ correct: 1, total: 1 }));
|
||||
});
|
||||
|
||||
it('should not break on time travel (diff < 0)', async () => {
|
||||
const user = mockUser({ lastStudyDate: '2023-01-11', currentStreak: 5 });
|
||||
StudyItem.findOne.mockResolvedValue(mockItem());
|
||||
|
||||
await ReviewService.processReview(user, 100, true);
|
||||
|
||||
expect(user.stats.lastStudyDate).toBe('2023-01-10');
|
||||
expect(user.stats.currentStreak).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getQueue', () => {
|
||||
it('should sort by priority', async () => {
|
||||
const mockFind = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockResolvedValue([]) };
|
||||
StudyItem.find.mockReturnValue(mockFind);
|
||||
await ReviewService.getQueue({ _id: 'u1' }, 10, 'priority');
|
||||
expect(mockFind.sort).toHaveBeenCalled();
|
||||
});
|
||||
it('should shuffle (default)', async () => {
|
||||
const mockFind = { limit: vi.fn().mockResolvedValue([1, 2]) };
|
||||
StudyItem.find.mockReturnValue(mockFind);
|
||||
await ReviewService.getQueue({ _id: 'u1' }, 10, 'random');
|
||||
expect(mockFind.limit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
147
server/tests/services/stats.service.test.js
Normal file
147
server/tests/services/stats.service.test.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { getUserStats } from '../../src/services/stats.service.js';
|
||||
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||
import { ReviewLog } from '../../src/models/ReviewLog.js';
|
||||
|
||||
vi.mock('../../src/models/StudyItem.js');
|
||||
vi.mock('../../src/models/ReviewLog.js');
|
||||
|
||||
describe('Stats Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2023-01-10T12:00:00Z'));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockUser = {
|
||||
_id: 'u1',
|
||||
stats: {
|
||||
totalReviews: 10,
|
||||
correctReviews: 8,
|
||||
currentStreak: 5,
|
||||
lastStudyDate: '2023-01-10',
|
||||
lastFreezeDate: '2022-01-01'
|
||||
}
|
||||
};
|
||||
|
||||
it('should calculate stats correctly including ghosts, forecast, and unknown SRS levels', async () => {
|
||||
StudyItem.aggregate.mockResolvedValue([
|
||||
{ _id: 1, count: 5 },
|
||||
{ _id: 8, count: 2 }
|
||||
]);
|
||||
|
||||
StudyItem.find.mockImplementation(() => ({
|
||||
select: vi.fn().mockImplementation((fields) => {
|
||||
if (fields.includes('nextReview')) {
|
||||
const now = new Date();
|
||||
return Promise.resolve([
|
||||
{ nextReview: new Date(now.getTime() - 1000) },
|
||||
{ nextReview: new Date(now.getTime() + 3600000) },
|
||||
{ nextReview: new Date(now.getTime() + 24 * 3600000) }
|
||||
]);
|
||||
}
|
||||
if (fields.includes('stats')) {
|
||||
const mkGhost = (acc) => ({
|
||||
char: 'A', meaning: 'B', stats: { correct: acc, total: 100 }, srsLevel: 1,
|
||||
toObject: () => ({ char: 'A', meaning: 'B', stats: { correct: acc, total: 100 }, srsLevel: 1 })
|
||||
});
|
||||
return Promise.resolve([mkGhost(60), mkGhost(50), mkGhost(90)]);
|
||||
}
|
||||
if (fields.includes('srsLevel')) {
|
||||
return Promise.resolve([{ srsLevel: 1 }, { srsLevel: 4 }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
})
|
||||
}));
|
||||
|
||||
ReviewLog.find.mockResolvedValue([
|
||||
{ date: '2023-01-09', count: 5 }
|
||||
]);
|
||||
|
||||
const stats = await getUserStats(mockUser);
|
||||
|
||||
expect(stats.forecast[0]).toBe(1);
|
||||
expect(stats.forecast[1]).toBe(1);
|
||||
|
||||
expect(stats.ghosts.length).toBe(2);
|
||||
expect(stats.ghosts[0].accuracy).toBe(50);
|
||||
expect(stats.ghosts[1].accuracy).toBe(60);
|
||||
|
||||
expect(stats.distribution[1]).toBe(5);
|
||||
expect(stats.distribution[8]).toBeUndefined();
|
||||
|
||||
expect(stats.heatmap['2023-01-09']).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle queue with same levels (hasLowerLevels = false)', async () => {
|
||||
StudyItem.aggregate.mockResolvedValue([]);
|
||||
StudyItem.find.mockImplementation(() => ({
|
||||
select: vi.fn().mockImplementation((fields) => {
|
||||
if (fields.includes('stats')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
if (fields.includes('srsLevel')) {
|
||||
return Promise.resolve([{ srsLevel: 2 }, { srsLevel: 2 }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
})
|
||||
}));
|
||||
ReviewLog.find.mockResolvedValue([]);
|
||||
|
||||
const stats = await getUserStats(mockUser);
|
||||
expect(stats.hasLowerLevels).toBe(false);
|
||||
});
|
||||
|
||||
it('should calculate shield cooldown', async () => {
|
||||
StudyItem.aggregate.mockResolvedValue([]);
|
||||
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||
ReviewLog.find.mockResolvedValue([]);
|
||||
|
||||
const frozenUser = {
|
||||
...mockUser,
|
||||
stats: { ...mockUser.stats, lastFreezeDate: '2023-01-07' }
|
||||
};
|
||||
|
||||
const stats = await getUserStats(frozenUser);
|
||||
expect(stats.streak.shield.ready).toBe(false);
|
||||
expect(stats.streak.shield.cooldown).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle streak logic when lastStudyDate is missing or old', async () => {
|
||||
const userNoDate = { ...mockUser, stats: { ...mockUser.stats, lastStudyDate: null, currentStreak: 5 } };
|
||||
StudyItem.aggregate.mockResolvedValue([]);
|
||||
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||
ReviewLog.find.mockResolvedValue([]);
|
||||
|
||||
const res1 = await getUserStats(userNoDate);
|
||||
expect(res1.streak.current).toBe(5);
|
||||
|
||||
const userMissed = { ...mockUser, stats: { ...mockUser.stats, lastStudyDate: '2023-01-08' } };
|
||||
const res2 = await getUserStats(userMissed);
|
||||
expect(res2.streak.current).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle missing user stats fields (null checks)', async () => {
|
||||
const emptyUser = {
|
||||
_id: 'u2',
|
||||
stats: {}
|
||||
};
|
||||
|
||||
StudyItem.aggregate.mockResolvedValue([]);
|
||||
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||
ReviewLog.find.mockResolvedValue([]);
|
||||
|
||||
const stats = await getUserStats(emptyUser);
|
||||
|
||||
expect(stats.streak.current).toBe(0);
|
||||
expect(stats.streak.best).toBe(0);
|
||||
expect(stats.accuracy.total).toBe(0);
|
||||
expect(stats.accuracy.correct).toBe(0);
|
||||
|
||||
expect(stats.streak.shield.ready).toBe(true);
|
||||
});
|
||||
});
|
||||
212
server/tests/services/sync.service.test.js
Normal file
212
server/tests/services/sync.service.test.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { syncWithWaniKani } from '../../src/services/sync.service.js';
|
||||
import { StudyItem } from '../../src/models/StudyItem.js';
|
||||
|
||||
vi.mock('../../src/models/StudyItem.js');
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe('Sync Service', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockUser = { _id: 'u1', wkApiKey: 'key', settings: {} };
|
||||
|
||||
it('should throw if no API key', async () => {
|
||||
await expect(syncWithWaniKani({}))
|
||||
.rejects.toThrow('User has no WaniKani API Key');
|
||||
});
|
||||
|
||||
it('should handle API fetch error', async () => {
|
||||
fetch.mockResolvedValue({ ok: false, statusText: 'Unauthorized' });
|
||||
await expect(syncWithWaniKani(mockUser))
|
||||
.rejects.toThrow('Failed to connect to WaniKani');
|
||||
});
|
||||
|
||||
it('should return 0 if no unlocked kanji', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [], pages: { next_url: null } })
|
||||
});
|
||||
|
||||
const res = await syncWithWaniKani(mockUser);
|
||||
expect(res.count).toBe(0);
|
||||
expect(res.message).toContain('No unlocked kanji');
|
||||
});
|
||||
|
||||
it('should sync new items with full reading data', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ data: { subject_id: 101 } }],
|
||||
pages: { next_url: null }
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{
|
||||
id: 101,
|
||||
data: {
|
||||
characters: 'A',
|
||||
meanings: [{ primary: true, meaning: 'A_Meaning' }],
|
||||
level: 5,
|
||||
readings: [
|
||||
{ type: 'onyomi', reading: 'on' },
|
||||
{ type: 'kunyomi', reading: 'kun' },
|
||||
{ type: 'nanori', reading: 'nan' }
|
||||
]
|
||||
}
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.insertMany.mockResolvedValue(true);
|
||||
StudyItem.countDocuments.mockResolvedValue(1);
|
||||
|
||||
await syncWithWaniKani(mockUser);
|
||||
|
||||
expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
wkSubjectId: 101,
|
||||
onyomi: ['on'],
|
||||
kunyomi: ['kun'],
|
||||
nanori: ['nan']
|
||||
})
|
||||
]));
|
||||
});
|
||||
|
||||
it('should filter out existing items', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{ data: { subject_id: 101 } },
|
||||
{ data: { subject_id: 102 } }
|
||||
],
|
||||
pages: { next_url: null }
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.find.mockReturnValue({
|
||||
select: vi.fn().mockResolvedValue([{ wkSubjectId: 102 }])
|
||||
});
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{
|
||||
id: 101,
|
||||
data: {
|
||||
characters: 'New',
|
||||
meanings: [],
|
||||
level: 1,
|
||||
readings: []
|
||||
}
|
||||
}]
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.insertMany.mockResolvedValue(true);
|
||||
StudyItem.countDocuments.mockResolvedValue(2);
|
||||
|
||||
await syncWithWaniKani(mockUser);
|
||||
|
||||
expect(StudyItem.insertMany).toHaveBeenCalledTimes(1);
|
||||
expect(StudyItem.insertMany).toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ wkSubjectId: 101 })
|
||||
]));
|
||||
expect(StudyItem.insertMany).not.toHaveBeenCalledWith(expect.arrayContaining([
|
||||
expect.objectContaining({ wkSubjectId: 102 })
|
||||
]));
|
||||
});
|
||||
|
||||
it('should handle assignment pagination', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ data: { subject_id: 1 } }],
|
||||
pages: { next_url: 'http://next-page' }
|
||||
})
|
||||
});
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ data: { subject_id: 2 } }],
|
||||
pages: { next_url: null }
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [] })
|
||||
});
|
||||
|
||||
StudyItem.countDocuments.mockResolvedValue(2);
|
||||
|
||||
await syncWithWaniKani(mockUser);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should skip insert if operations are empty (e.g. subject data missing)', async () => {
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ data: { subject_id: 101 } }],
|
||||
pages: { next_url: null }
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [] })
|
||||
});
|
||||
|
||||
StudyItem.countDocuments.mockResolvedValue(0);
|
||||
|
||||
await syncWithWaniKani(mockUser);
|
||||
|
||||
expect(StudyItem.insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should sync items in chunks', async () => {
|
||||
const manyIds = Array.from({ length: 150 }, (_, i) => i + 1);
|
||||
const subjectData = manyIds.map(id => ({ data: { subject_id: id } }));
|
||||
|
||||
fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: subjectData,
|
||||
pages: { next_url: null }
|
||||
})
|
||||
});
|
||||
|
||||
StudyItem.find.mockReturnValue({ select: vi.fn().mockResolvedValue([]) });
|
||||
|
||||
fetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 1, data: { characters: 'C', meanings: [], level: 1 } }] })
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: 101, data: { characters: 'D', meanings: [], level: 1 } }] })
|
||||
});
|
||||
|
||||
StudyItem.insertMany.mockResolvedValue(true);
|
||||
StudyItem.countDocuments.mockResolvedValue(150);
|
||||
|
||||
await syncWithWaniKani(mockUser);
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(3);
|
||||
expect(StudyItem.insertMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
54
server/tests/utils.test.js
Normal file
54
server/tests/utils.test.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import { getDaysDiff, getSRSDate } from '../src/utils/dateUtils.js';
|
||||
|
||||
describe('Date Utils', () => {
|
||||
describe('getDaysDiff', () => {
|
||||
it('should calculate difference between two dates correctly', () => {
|
||||
const d1 = '2023-01-01';
|
||||
const d2 = '2023-01-03';
|
||||
expect(getDaysDiff(d1, d2)).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 for same day', () => {
|
||||
expect(getDaysDiff('2023-01-01', '2023-01-01')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSRSDate', () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2023-01-01T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should add correct hours for levels 1-6', () => {
|
||||
const base = new Date('2023-01-01T12:00:00Z');
|
||||
|
||||
expect(getSRSDate(1).toISOString()).toBe(new Date(base.getTime() + 4 * 3600000).toISOString());
|
||||
expect(getSRSDate(2).toISOString()).toBe(new Date(base.getTime() + 8 * 3600000).toISOString());
|
||||
expect(getSRSDate(3).toISOString()).toBe(new Date(base.getTime() + 24 * 3600000).toISOString());
|
||||
expect(getSRSDate(4).toISOString()).toBe(new Date(base.getTime() + 48 * 3600000).toISOString());
|
||||
expect(getSRSDate(5).toISOString()).toBe(new Date(base.getTime() + 7 * 24 * 3600000).toISOString());
|
||||
expect(getSRSDate(6).toISOString()).toBe(new Date(base.getTime() + 14 * 24 * 3600000).toISOString());
|
||||
});
|
||||
|
||||
it('should add correct hours for levels 7-9', () => {
|
||||
const base = new Date('2023-01-01T12:00:00Z');
|
||||
expect(getSRSDate(7).toISOString()).toBe(new Date(base.getTime() + 7 * 24 * 3600000).toISOString());
|
||||
expect(getSRSDate(8).toISOString()).toBe(new Date(base.getTime() + 30 * 24 * 3600000).toISOString());
|
||||
expect(getSRSDate(9).toISOString()).toBe(new Date(base.getTime() + 90 * 24 * 3600000).toISOString());
|
||||
});
|
||||
|
||||
it('should return null for level 10 (burned)', () => {
|
||||
expect(getSRSDate(10)).toBeNull();
|
||||
});
|
||||
|
||||
it('should default to 4 hours for unknown levels', () => {
|
||||
const base = new Date('2023-01-01T12:00:00Z');
|
||||
expect(getSRSDate(99).toISOString()).toBe(new Date(base.getTime() + 4 * 3600000).toISOString());
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user