b.owner_id", "0") or 0) == 0)
add("播放位置都指向存在的书", int(scalar(database, "SELECT COUNT(*) FROM lexgo_playback_positions p LEFT JOIN lexgo_books b ON b.id=p.book_id WHERE b.id IS NULL", "0") or 0) == 0)
+ add("章节附件都指向存在的章节", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_attachments a LEFT JOIN lexgo_chapters c ON c.id=a.chapter_id WHERE c.id IS NULL", "0") or 0) == 0)
+ add("章节附件归属与章节归属一致", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_attachments a JOIN lexgo_chapters c ON c.id=a.chapter_id WHERE a.owner_id<>c.owner_id", "0") or 0) == 0)
+ add("章节播放位置都指向存在的章节", int(scalar(database, "SELECT COUNT(*) FROM lexgo_chapter_playback_positions p LEFT JOIN lexgo_chapters c ON c.id=p.chapter_id WHERE c.id IS NULL", "0") or 0) == 0)
# The audit tables must not gain a column that could hold a credential or private content.
for table in ("lexgo_login_logs", "lexgo_operation_logs"):
diff --git a/server/app/lexgo/attachment.go b/server/app/lexgo/attachment.go
index a0ff206..7c8c9aa 100644
--- a/server/app/lexgo/attachment.go
+++ b/server/app/lexgo/attachment.go
@@ -24,8 +24,10 @@ import (
// and deleting a book removes its attachments with it. Nothing is written to disk: an uploaded file
// never becomes a path, and the client file name is only used for display.
const (
- attachmentAudio = "audio"
- attachmentCover = "cover"
+ // Audio and illustration belong to a chapter; the cover belongs to the book.
+ attachmentAudio = "audio"
+ attachmentIllustration = "illustration"
+ attachmentCover = "cover"
maxAudioBytes = 20 << 20
maxCoverBytes = 2 << 20
@@ -52,16 +54,33 @@ type BookAttachment struct {
func (BookAttachment) TableName() string { return "lexgo_book_attachments" }
-// PlaybackPosition remembers where one learner stopped in one book. It is separate from the
-// attachment row: the file belongs to the book, the position belongs to the reader.
-type PlaybackPosition struct {
+// ChapterAttachment is one binary file belonging to a chapter: its audio or its illustration.
+// chapter_id plus kind are unique, so replacing a file updates one row instead of accumulating
+// versions, and deleting a chapter removes its files with it.
+type ChapterAttachment struct {
+ ChapterID int64 `gorm:"primaryKey"`
+ Kind string `gorm:"primaryKey"`
+ OwnerID int `json:"-"`
+ Mime string `json:"mime"`
+ ByteSize int `json:"byteSize"`
+ SHA256 string `json:"sha256"`
+ Bytes []byte `json:"-"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+func (ChapterAttachment) TableName() string { return "lexgo_chapter_attachments" }
+
+// ChapterPlaybackPosition remembers where one learner stopped in one chapter. It is separate from
+// the attachment row: the file belongs to the chapter, the position belongs to the reader.
+type ChapterPlaybackPosition struct {
OwnerID int `gorm:"primaryKey"`
- BookID int64 `gorm:"primaryKey"`
+ ChapterID int64 `gorm:"primaryKey"`
PositionSeconds int `json:"positionSeconds"`
UpdatedAt time.Time `json:"updatedAt"`
}
-func (PlaybackPosition) TableName() string { return "lexgo_playback_positions" }
+func (ChapterPlaybackPosition) TableName() string { return "lexgo_chapter_playback_positions" }
// BookAttachmentView is what the client sees: metadata and a version for cache invalidation, never
// the bytes.
@@ -73,10 +92,10 @@ type BookAttachmentView struct {
UpdatedAt time.Time `json:"updatedAt"`
}
+// BookAttachmentsView is what the book page shows. Audio and its position moved to the chapter,
+// so only the cover remains at book level.
type BookAttachmentsView struct {
- Cover *BookAttachmentView `json:"cover"`
- Audio *BookAttachmentView `json:"audio"`
- PlaybackSeconds int `json:"playbackSeconds"`
+ Cover *BookAttachmentView `json:"cover"`
}
// sniffAttachment checks the actual bytes instead of trusting a file name or a declared type.
@@ -87,9 +106,12 @@ func sniffAttachment(kind string, data []byte) (string, error) {
return "audio/mpeg", nil
}
return "", failure(400, "只支持 MP3 音频,请转换为 MP3 后重试")
- case attachmentCover:
+ case attachmentCover, attachmentIllustration:
mime := sniffImage(data)
if mime == "" {
+ if kind == attachmentIllustration {
+ return "", failure(400, "插图只支持 JPG、PNG 或 WebP 图片")
+ }
return "", failure(400, "封面只支持 JPG、PNG 或 WebP 图片")
}
return mime, nil
@@ -165,6 +187,10 @@ func webpDimensions(data []byte) (int, int, bool) {
// validateAttachment enforces the size, type and pixel limits before anything is stored.
func validateAttachment(kind string, data []byte) (string, error) {
+ label := "封面"
+ if kind == attachmentIllustration {
+ label = "插图"
+ }
switch kind {
case attachmentAudio:
if len(data) == 0 {
@@ -173,12 +199,12 @@ func validateAttachment(kind string, data []byte) (string, error) {
if len(data) > maxAudioBytes {
return "", failure(400, "音频文件不能超过 20 MiB")
}
- case attachmentCover:
+ case attachmentCover, attachmentIllustration:
if len(data) == 0 {
- return "", failure(400, "封面图片为空")
+ return "", failure(400, label+"图片为空")
}
if len(data) > maxCoverBytes {
- return "", failure(400, "封面图片不能超过 2 MiB")
+ return "", failure(400, label+"图片不能超过 2 MiB")
}
default:
return "", failure(400, "附件类型无效")
@@ -187,13 +213,13 @@ func validateAttachment(kind string, data []byte) (string, error) {
if err != nil {
return "", err
}
- if kind == attachmentCover {
+ if kind != attachmentAudio {
width, height, ok := coverDimensions(mime, data)
if !ok {
return "", failure(400, "无法识别该图片,请换一张 JPG、PNG 或 WebP")
}
if width > maxCoverPixels || height > maxCoverPixels {
- return "", failure(400, "封面图片不能超过 4096×4096 像素")
+ return "", failure(400, label+"图片不能超过 4096×4096 像素")
}
}
return mime, nil
@@ -266,7 +292,7 @@ func tooLarge(err error) bool {
// same row only after the new file passed every check, so a rejected upload leaves the old one in
// place.
func SaveAttachment(tx *gorm.DB, owner int, bookID int64, kind string, data []byte, now time.Time) (BookAttachmentView, error) {
- if kind != attachmentAudio && kind != attachmentCover {
+ if kind != attachmentCover {
return BookAttachmentView{}, failure(404, "附件不存在")
}
if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
@@ -286,12 +312,6 @@ func SaveAttachment(tx *gorm.DB, owner int, bookID int64, kind string, data []by
}).Create(&row).Error; err != nil {
return BookAttachmentView{}, err
}
- if kind == attachmentAudio {
- // A new file has nothing to do with the old position, so any stored position is dropped.
- if err = tx.Where("owner_id = ? AND book_id = ?", owner, bookID).Delete(&PlaybackPosition{}).Error; err != nil {
- return BookAttachmentView{}, err
- }
- }
return attachmentView(row), nil
}
@@ -300,9 +320,9 @@ func attachmentView(row BookAttachment) BookAttachmentView {
Version: row.SHA256, UpdatedAt: row.UpdatedAt}
}
-// DeleteAttachment removes one file, and an audio removal also drops the stored position.
+// DeleteAttachment removes the cover of a book.
func DeleteAttachment(tx *gorm.DB, owner int, bookID int64, kind string, now time.Time) error {
- if kind != attachmentAudio && kind != attachmentCover {
+ if kind != attachmentCover {
return failure(404, "附件不存在")
}
if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
@@ -315,9 +335,6 @@ func DeleteAttachment(tx *gorm.DB, owner int, bookID int64, kind string, now tim
if result.RowsAffected == 0 {
return failure(404, "附件不存在")
}
- if kind == attachmentAudio {
- return tx.Where("owner_id = ? AND book_id = ?", owner, bookID).Delete(&PlaybackPosition{}).Error
- }
return nil
}
@@ -352,22 +369,56 @@ func attachmentsByBook(tx *gorm.DB, owner int, bookIDs []int64) (map[int64]map[s
return found, nil
}
-func playbackSeconds(tx *gorm.DB, owner int, bookIDs []int64) (map[int64]int, error) {
+// ChapterAttachmentView is one chapter file as the client sees it: metadata and a version for
+// cache invalidation, never the bytes.
+type ChapterAttachmentView struct {
+ Kind string `json:"kind"`
+ Mime string `json:"mime"`
+ ByteSize int `json:"byteSize"`
+ Version string `json:"version"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+// chapterAttachmentViews loads the attachment metadata for a list of chapters, so a chapter list can
+// show illustrations and audio flags without a query per row.
+func chapterAttachmentViews(tx *gorm.DB, owner int, chapterIDs []int64) (map[int64]map[string]ChapterAttachmentView, error) {
+ found := map[int64]map[string]ChapterAttachmentView{}
+ if len(chapterIDs) == 0 {
+ return found, nil
+ }
+ var rows []ChapterAttachment
+ if err := tx.Select("chapter_id", "kind", "mime", "byte_size", "sha256", "updated_at").
+ Where("owner_id = ? AND chapter_id IN ?", owner, chapterIDs).Find(&rows).Error; err != nil {
+ return found, err
+ }
+ for _, row := range rows {
+ if found[row.ChapterID] == nil {
+ found[row.ChapterID] = map[string]ChapterAttachmentView{}
+ }
+ found[row.ChapterID][row.Kind] = ChapterAttachmentView{Kind: row.Kind, Mime: row.Mime,
+ ByteSize: row.ByteSize, Version: row.SHA256, UpdatedAt: row.UpdatedAt}
+ }
+ return found, nil
+}
+
+// chapterPlaybackSeconds reads this learner's position per chapter.
+func chapterPlaybackSeconds(tx *gorm.DB, owner int, chapterIDs []int64) (map[int64]int, error) {
positions := map[int64]int{}
- if len(bookIDs) == 0 {
+ if len(chapterIDs) == 0 {
return positions, nil
}
- var rows []PlaybackPosition
- if err := tx.Where("owner_id = ? AND book_id IN ?", owner, bookIDs).Find(&rows).Error; err != nil {
+ var rows []ChapterPlaybackPosition
+ if err := tx.Where("owner_id = ? AND chapter_id IN ?", owner, chapterIDs).Find(&rows).Error; err != nil {
return positions, err
}
for _, row := range rows {
- positions[row.BookID] = row.PositionSeconds
+ positions[row.ChapterID] = row.PositionSeconds
}
return positions, nil
}
-// BookAttachmentsFor is the view the book page uses.
+// BookAttachmentsFor is the view the book page uses: the cover only. Audio and its position belong
+// to a chapter now.
func BookAttachmentsFor(tx *gorm.DB, owner int, bookID int64) (BookAttachmentsView, error) {
view := BookAttachmentsView{}
found, err := attachmentsByBook(tx, owner, []int64{bookID})
@@ -377,36 +428,95 @@ func BookAttachmentsFor(tx *gorm.DB, owner int, bookID int64) (BookAttachmentsVi
if cover, ok := found[bookID][attachmentCover]; ok {
view.Cover = &cover
}
- if audio, ok := found[bookID][attachmentAudio]; ok {
- view.Audio = &audio
- }
- positions, err := playbackSeconds(tx, owner, []int64{bookID})
- if err != nil {
- return view, err
- }
- view.PlaybackSeconds = positions[bookID]
return view, nil
}
-// SavePlaybackPosition remembers where the learner stopped. Only the caller's own position moves.
-func SavePlaybackPosition(tx *gorm.DB, owner int, bookID int64, seconds int, now time.Time) (int, error) {
+// SaveChapterAttachment validates and stores one file for a chapter the caller owns. A replacement
+// updates the same row only after the new file passed every check, so a rejected upload leaves the
+// old one in place, and replacing the audio drops the position that belonged to the old file.
+func SaveChapterAttachment(tx *gorm.DB, owner int, chapterID int64, kind string, data []byte, now time.Time) (ChapterAttachmentView, error) {
+ if kind != attachmentAudio && kind != attachmentIllustration {
+ return ChapterAttachmentView{}, failure(404, "附件不存在")
+ }
+ var chapter Chapter
+ if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
+ return ChapterAttachmentView{}, err
+ }
+ mime, err := validateAttachment(kind, data)
+ if err != nil {
+ return ChapterAttachmentView{}, err
+ }
+ ts := stamp(now)
+ row := ChapterAttachment{ChapterID: chapterID, Kind: kind, OwnerID: owner, Mime: mime,
+ ByteSize: len(data), SHA256: contentSHA(string(data)), Bytes: data, CreatedAt: ts, UpdatedAt: ts}
+ if err = tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "chapter_id"}, {Name: "kind"}},
+ DoUpdates: clause.AssignmentColumns([]string{"owner_id", "mime", "byte_size", "sha256", "bytes", "updated_at"}),
+ }).Create(&row).Error; err != nil {
+ return ChapterAttachmentView{}, err
+ }
+ if kind == attachmentAudio {
+ if err = tx.Where("owner_id = ? AND chapter_id = ?", owner, chapterID).Delete(&ChapterPlaybackPosition{}).Error; err != nil {
+ return ChapterAttachmentView{}, err
+ }
+ }
+ return ChapterAttachmentView{Kind: row.Kind, Mime: row.Mime, ByteSize: row.ByteSize,
+ Version: row.SHA256, UpdatedAt: row.UpdatedAt}, nil
+}
+
+// DeleteChapterAttachment removes one chapter file, and an audio removal drops the position too.
+func DeleteChapterAttachment(tx *gorm.DB, owner int, chapterID int64, kind string, now time.Time) error {
+ if kind != attachmentAudio && kind != attachmentIllustration {
+ return failure(404, "附件不存在")
+ }
+ var chapter Chapter
+ if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
+ return err
+ }
+ result := tx.Where("chapter_id = ? AND owner_id = ? AND kind = ?", chapterID, owner, kind).Delete(&ChapterAttachment{})
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return failure(404, "附件不存在")
+ }
+ if kind == attachmentAudio {
+ return tx.Where("owner_id = ? AND chapter_id = ?", owner, chapterID).Delete(&ChapterPlaybackPosition{}).Error
+ }
+ return nil
+}
+
+// ChapterAttachmentFile returns one stored chapter file for the caller's own chapter.
+func ChapterAttachmentFile(tx *gorm.DB, owner int, chapterID int64, kind string) (ChapterAttachment, error) {
+ var row ChapterAttachment
+ err := tx.Where("chapter_id = ? AND owner_id = ? AND kind = ?", chapterID, owner, kind).First(&row).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ChapterAttachment{}, failure(404, "附件不存在")
+ }
+ return row, err
+}
+
+// SaveChapterPlaybackPosition remembers where the learner stopped in one chapter. Only the caller's
+// own position moves, and a chapter without audio is refused so a stale client cannot create rows.
+func SaveChapterPlaybackPosition(tx *gorm.DB, owner int, chapterID int64, seconds int, now time.Time) (int, error) {
if seconds < 0 || seconds > maxPlaybackSeconds {
return 0, failure(400, "播放位置无效")
}
- if err := lockOwnedBook(tx, owner, bookID, &Book{}); err != nil {
+ var chapter Chapter
+ if err := lockOwnedChapter(tx, owner, chapterID, &chapter); err != nil {
return 0, err
}
var audio int64
- if err := tx.Model(&BookAttachment{}).Where("book_id = ? AND kind = ?", bookID, attachmentAudio).Count(&audio).Error; err != nil {
+ if err := tx.Model(&ChapterAttachment{}).Where("chapter_id = ? AND kind = ?", chapterID, attachmentAudio).Count(&audio).Error; err != nil {
return 0, err
}
if audio == 0 {
- return 0, failure(404, "这本书没有音频")
+ return 0, failure(404, "这一章没有音频")
}
ts := stamp(now)
- row := PlaybackPosition{OwnerID: owner, BookID: bookID, PositionSeconds: seconds, UpdatedAt: ts}
+ row := ChapterPlaybackPosition{OwnerID: owner, ChapterID: chapterID, PositionSeconds: seconds, UpdatedAt: ts}
if err := tx.Clauses(clause.OnConflict{
- Columns: []clause.Column{{Name: "owner_id"}, {Name: "book_id"}},
+ Columns: []clause.Column{{Name: "owner_id"}, {Name: "chapter_id"}},
DoUpdates: clause.AssignmentColumns([]string{"position_seconds", "updated_at"}),
}).Create(&row).Error; err != nil {
return 0, err
@@ -415,37 +525,24 @@ func SavePlaybackPosition(tx *gorm.DB, owner int, bookID int64, seconds int, now
}
func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc, now func() time.Time) {
- upload := func(kind string, limit int64) gin.HandlerFunc {
- return protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
- if len(c.Request.URL.Query()) > 0 {
- return nil, failure(400, "附件上传不接受查询参数")
- }
- id, err := pathID(c, "书籍不存在")
- if err != nil {
- return nil, err
- }
- data, err := readAttachmentUpload(c, limit)
- if err != nil {
- return nil, err
- }
- view, err := SaveAttachment(tx, u.UserId, id, kind, data, now())
- if err != nil {
- return nil, err
- }
- return gin.H{"attachment": view}, nil
- })
- }
- v.POST("/books/:id/audio", upload(attachmentAudio, maxAudioBytes))
- v.POST("/books/:id/cover", upload(attachmentCover, maxCoverBytes))
- v.DELETE("/books/:id/audio", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ // The book keeps the cover; audio, illustration and the playback position are chapter scoped.
+ v.POST("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ if len(c.Request.URL.Query()) > 0 {
+ return nil, failure(400, "附件上传不接受查询参数")
+ }
id, err := pathID(c, "书籍不存在")
if err != nil {
return nil, err
}
- if err = DeleteAttachment(tx, u.UserId, id, attachmentAudio, now()); err != nil {
+ data, err := readAttachmentUpload(c, maxCoverBytes)
+ if err != nil {
return nil, err
}
- return gin.H{"deleted": true}, nil
+ view, err := SaveAttachment(tx, u.UserId, id, attachmentCover, data, now())
+ if err != nil {
+ return nil, err
+ }
+ return gin.H{"attachment": view}, nil
}))
v.DELETE("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := pathID(c, "书籍不存在")
@@ -457,14 +554,63 @@ func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
}
return gin.H{"deleted": true}, nil
}))
- for kind, name := range map[string]string{attachmentAudio: "audio", attachmentCover: "cover"} {
- attachmentKind, fileName := kind, name
- v.GET("/books/:id/"+fileName, protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
- id, err := pathID(c, "书籍不存在")
+ v.GET("/books/:id/cover", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ id, err := pathID(c, "书籍不存在")
+ if err != nil {
+ return nil, err
+ }
+ row, err := BookAttachmentFile(tx, u.UserId, id, attachmentCover)
+ if err != nil {
+ return nil, err
+ }
+ return binaryResponse{ContentType: row.Mime, Bytes: row.Bytes, FileName: "cover",
+ ModifiedAt: row.UpdatedAt, ETag: `"` + row.SHA256 + `"`}, nil
+ }))
+
+ upload := func(kind string) gin.HandlerFunc {
+ limit := int64(maxAudioBytes)
+ if kind == attachmentIllustration {
+ limit = maxCoverBytes
+ }
+ return protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ if len(c.Request.URL.Query()) > 0 {
+ return nil, failure(400, "附件上传不接受查询参数")
+ }
+ id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
- row, err := BookAttachmentFile(tx, u.UserId, id, attachmentKind)
+ data, err := readAttachmentUpload(c, limit)
+ if err != nil {
+ return nil, err
+ }
+ view, err := SaveChapterAttachment(tx, u.UserId, id, kind, data, now())
+ if err != nil {
+ return nil, err
+ }
+ return gin.H{"attachment": view}, nil
+ })
+ }
+ v.POST("/chapters/:id/audio", upload(attachmentAudio))
+ v.POST("/chapters/:id/illustration", upload(attachmentIllustration))
+ for kind, name := range map[string]string{attachmentAudio: "audio", attachmentIllustration: "illustration"} {
+ attachmentKind, fileName := kind, name
+ v.DELETE("/chapters/:id/"+fileName, protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ id, err := pathID(c, "章节不存在")
+ if err != nil {
+ return nil, err
+ }
+ if err = DeleteChapterAttachment(tx, u.UserId, id, attachmentKind, now()); err != nil {
+ return nil, err
+ }
+ return gin.H{"deleted": true}, nil
+ }))
+ v.GET("/chapters/:id/"+fileName, protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ id, err := pathID(c, "章节不存在")
+ if err != nil {
+ return nil, err
+ }
+ row, err := ChapterAttachmentFile(tx, u.UserId, id, attachmentKind)
if err != nil {
return nil, err
}
@@ -472,8 +618,8 @@ func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
ModifiedAt: row.UpdatedAt, ETag: `"` + row.SHA256 + `"`}, nil
}))
}
- v.PUT("/books/:id/playback", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
- id, err := pathID(c, "书籍不存在")
+ v.PUT("/chapters/:id/playback", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
+ id, err := pathID(c, "章节不存在")
if err != nil {
return nil, err
}
@@ -486,7 +632,7 @@ func registerAttachmentRoutes(v *gin.RouterGroup, protect func(bool, func(*gin.C
if input.PositionSeconds == nil {
return nil, failure(400, "请提供播放位置")
}
- seconds, err := SavePlaybackPosition(tx, u.UserId, id, *input.PositionSeconds, now())
+ seconds, err := SaveChapterPlaybackPosition(tx, u.UserId, id, *input.PositionSeconds, now())
if err != nil {
return nil, err
}
diff --git a/server/app/lexgo/attachment_test.go b/server/app/lexgo/attachment_test.go
index 12d0950..e25b9d3 100644
--- a/server/app/lexgo/attachment_test.go
+++ b/server/app/lexgo/attachment_test.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/gin-gonic/gin"
+ "gorm.io/gorm"
)
// ---------------------------------------------------------------- unit rules
@@ -76,6 +77,8 @@ func TestAttachmentTypeSniffing(t *testing.T) {
{"wav is not mp3", attachmentAudio, append([]byte("RIFF"), make([]byte, 40)...), "", false},
{"text renamed", attachmentAudio, []byte("this is not audio at all.........."), "", false},
{"png cover", attachmentCover, testPNG(t, 8, 8), "image/png", true},
+ {"png illustration", attachmentIllustration, testPNG(t, 8, 8), "image/png", true},
+ {"gif illustration", attachmentIllustration, []byte("GIF89a"), "", false},
{"jpeg cover", attachmentCover, testJPEG(t, 8, 8), "image/jpeg", true},
{"webp cover", attachmentCover, testWebP(8, 8), "image/webp", true},
{"gif is not accepted", attachmentCover, []byte("GIF89a"), "", false},
@@ -95,6 +98,9 @@ func TestAttachmentTypeSniffing(t *testing.T) {
}
func TestAttachmentLimits(t *testing.T) {
+ if _, err := validateAttachment(attachmentIllustration, make([]byte, maxCoverBytes+1)); err == nil {
+ t.Fatal("an oversized illustration must be refused")
+ }
if _, err := validateAttachment(attachmentAudio, make([]byte, maxAudioBytes+1)); err == nil {
t.Fatal("an oversized audio file must be refused")
}
@@ -146,18 +152,17 @@ func TestWebPDimensions(t *testing.T) {
func TestPlaybackPositionBounds(t *testing.T) {
// The bound is checked before any database work, so a nil transaction is fine here.
- if _, err := SavePlaybackPosition(nil, 1, 1, -1, time.Now()); err == nil {
+ if _, err := SaveChapterPlaybackPosition(nil, 1, 1, -1, time.Now()); err == nil {
t.Fatal("a negative position must be refused")
}
- if _, err := SavePlaybackPosition(nil, 1, 1, maxPlaybackSeconds+1, time.Now()); err == nil {
+ if _, err := SaveChapterPlaybackPosition(nil, 1, 1, maxPlaybackSeconds+1, time.Now()); err == nil {
t.Fatal("an absurd position must be refused")
}
}
// ---------------------------------------------------------------- integration
-// uploadAttachment posts one multipart file to an attachment endpoint.
-func uploadAttachment(t *testing.T, r *gin.Engine, token string, path string, data []byte) (int, string, BookAttachmentView) {
+func uploadAttachment(t *testing.T, r *gin.Engine, token string, path string, data []byte) (int, string, ChapterAttachmentView) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -182,7 +187,7 @@ func uploadAttachment(t *testing.T, r *gin.Engine, token string, path string, da
Code int `json:"code"`
Msg string
Data struct {
- Attachment BookAttachmentView
+ Attachment ChapterAttachmentView
}
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
@@ -229,37 +234,264 @@ func bookDetailOf(t *testing.T, r *gin.Engine, token string, bookID int64) (Book
return payload.Book, payload.Chapters
}
-// TestMySQLBookAttachmentsAndPlayback covers upload, authenticated Range reads, replacement,
-// position handling, ownership and cascade deletion.
-func TestMySQLBookAttachmentsAndPlayback(t *testing.T) {
+func chapterInList(t *testing.T, r *gin.Engine, token string, bookID, chapterID int64) ChapterSummary {
+ t.Helper()
+ _, chapters := bookDetailOf(t, r, token, bookID)
+ for _, chapter := range chapters {
+ if chapter.ID == chapterID {
+ return chapter
+ }
+ }
+ t.Fatalf("chapter %d is missing from the book list", chapterID)
+ return ChapterSummary{}
+}
+
+func readerChapter(t *testing.T, r *gin.Engine, token string, chapterID int64) ChapterView {
+ t.Helper()
+ code, _, data := callRaw(t, r, "GET", fmt.Sprintf("/api/v1/chapters/%d", chapterID), token, nil)
+ if code != 200 {
+ t.Fatalf("reader status %d", code)
+ }
+ var payload struct {
+ Chapter ChapterView
+ }
+ if err := json.Unmarshal(data, &payload); err != nil {
+ t.Fatal(err)
+ }
+ return payload.Chapter
+}
+
+func attachmentRows(t *testing.T, db *gorm.DB, chapterID int64, kind string) int64 {
+ t.Helper()
+ var rows int64
+ if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ? AND kind = ?", chapterID, kind).Count(&rows).Error; err != nil {
+ t.Fatal(err)
+ }
+ return rows
+}
+
+// TestMySQLChapterAttachmentsAndPlayback covers the chapter attachments: illustration and audio per
+// chapter, authenticated Range reads, replacement, the per-chapter position, ownership and cascade.
+func TestMySQLChapterAttachmentsAndPlayback(t *testing.T) {
db, r, owner := libraryFixture(t)
learner := newLearner(t, r, owner.Token)
other := newLearner(t, r, owner.Token)
code, pasted := pasteBook(t, r, learner.Token, map[string]string{
- "requestId": "attach-0001", "title": "Fictional attachments", "text": "Curiosity opens the first door.\n", "language": "en"})
+ "requestId": "chapter-attach-0001", "title": "Fictional chapter attachments",
+ "text": "Curiosity opens the first door.\n", "language": "en"})
if code != 201 {
t.Fatalf("paste %d", code)
}
drainIngest(t, db)
- bookID := pasted.Chapter.BookID
+ bookID, chapterID := pasted.Chapter.BookID, pasted.Chapter.ID
- // A book without attachments reports neither a cover nor audio.
- book, _ := bookDetailOf(t, r, learner.Token, bookID)
- if book.Attachments == nil || book.Attachments.Cover != nil || book.Attachments.Audio != nil || book.Attachments.PlaybackSeconds != 0 {
- t.Fatalf("a new book must have no attachments: %+v", book.Attachments)
+ // A chapter without attachments reports nothing and serves 404.
+ if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.IllustrationVersion != "" || listed.AudioVersion != "" || listed.PlaybackSeconds != 0 {
+ t.Fatalf("a new chapter must have no attachments: %+v", listed)
}
- if recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil); recorder.Code != 404 {
- t.Fatalf("reading a missing audio must be 404, got %d", recorder.Code)
+ if reader := readerChapter(t, r, learner.Token, chapterID); reader.IllustrationVersion != "" || reader.AudioVersion != "" {
+ t.Fatalf("the reader must report no attachments: %+v", reader)
+ }
+ for _, path := range []string{"audio", "illustration"} {
+ if recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/%s", chapterID, path), nil); recorder.Code != 404 {
+ t.Fatalf("reading a missing %s must be 404, got %d", path, recorder.Code)
+ }
}
- // A cover upload stores the bytes and is visible in the list as a version.
+ // The illustration is stored, listed with a version and served byte for byte.
+ illustration := testPNG(t, 60, 34)
+ code, msg, view := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), illustration)
+ if code != 200 || view.Kind != attachmentIllustration || view.Mime != "image/png" || view.Version != contentSHA(string(illustration)) {
+ t.Fatalf("illustration upload: %d %s %+v", code, msg, view)
+ }
+ if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.IllustrationVersion != view.Version || listed.AudioVersion != "" {
+ t.Fatalf("the chapter list must carry the illustration version: %+v", listed)
+ }
+ recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil)
+ if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), illustration) || recorder.Header().Get("Content-Type") != "image/png" {
+ t.Fatalf("illustration read: %d %s", recorder.Code, recorder.Header().Get("Content-Type"))
+ }
+ etag := recorder.Header().Get("ETag")
+ if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), map[string]string{"If-None-Match": etag}); recorder.Code != 304 {
+ t.Fatalf("a matching ETag must answer 304, got %d", recorder.Code)
+ }
+
+ // The audio is stored and served in full and by range.
+ audio := testMP3(4096)
+ code, msg, audioView := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), audio)
+ if code != 200 || audioView.Kind != attachmentAudio || audioView.Mime != "audio/mpeg" {
+ t.Fatalf("audio upload: %d %s %+v", code, msg, audioView)
+ }
+ if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.AudioVersion != audioView.Version {
+ t.Fatalf("the chapter list must carry the audio version: %+v", listed)
+ }
+ if reader := readerChapter(t, r, learner.Token, chapterID); reader.AudioVersion != audioView.Version || reader.IllustrationVersion != view.Version {
+ t.Fatalf("the reader must carry both versions: %+v", reader)
+ }
+ recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil)
+ if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), audio) || recorder.Header().Get("Accept-Ranges") == "" {
+ t.Fatalf("audio read: %d ranges=%q", recorder.Code, recorder.Header().Get("Accept-Ranges"))
+ }
+ recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), map[string]string{"Range": "bytes=100-199"})
+ if recorder.Code != 206 || recorder.Body.Len() != 100 || !bytes.Equal(recorder.Body.Bytes(), audio[100:200]) {
+ t.Fatalf("a range request must answer 206 with the slice, got %d with %d bytes", recorder.Code, recorder.Body.Len())
+ }
+ if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), map[string]string{"Range": "bytes=999999-"}); recorder.Code != 416 {
+ t.Fatalf("an unsatisfiable range must answer 416, got %d", recorder.Code)
+ }
+
+ // The book-level audio endpoints are retired: the route no longer exists.
+ if code, _, _ := callRaw(t, r, "POST", fmt.Sprintf("/api/v1/books/%d/audio", bookID), learner.Token, nil); code != 404 {
+ t.Fatalf("the book-level audio route must be gone, got %d", code)
+ }
+ if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 5}); code != 404 {
+ t.Fatalf("the book-level playback route must be gone, got %d", code)
+ }
+ if book, _ := bookDetailOf(t, r, learner.Token, bookID); book.Attachments != nil && book.Attachments.Cover != nil {
+ t.Fatal("the book view must only carry the cover")
+ }
+
+ // The position is per chapter and only moves for the caller.
+ code, msg, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": 125})
+ if code != 200 {
+ t.Fatalf("position update: %d %s", code, msg)
+ }
+ if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.PlaybackSeconds != 125 {
+ t.Fatalf("the chapter list must report the position, got %d", listed.PlaybackSeconds)
+ }
+ if reader := readerChapter(t, r, learner.Token, chapterID); reader.PlaybackSeconds != 125 {
+ t.Fatalf("the reader must report the position, got %d", reader.PlaybackSeconds)
+ }
+ if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": -5}); code != 400 {
+ t.Fatal("a negative position must be refused")
+ }
+ if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{}); code != 400 {
+ t.Fatal("a position request without a value must be refused")
+ }
+
+ // Replacing the audio drops the old position and keeps one row.
+ smaller := testMP3(2048)
+ code, msg, replaced := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), smaller)
+ if code != 200 || replaced.ByteSize != len(smaller) || replaced.Version == audioView.Version {
+ t.Fatalf("audio replacement: %d %s %+v", code, msg, replaced)
+ }
+ if listed := chapterInList(t, r, learner.Token, bookID, chapterID); listed.PlaybackSeconds != 0 {
+ t.Fatalf("a replacement must reset the position, got %d", listed.PlaybackSeconds)
+ }
+ if rows := attachmentRows(t, db, chapterID, attachmentAudio); rows != 1 {
+ t.Fatalf("a replacement must keep one row, got %d", rows)
+ }
+
+ // A rejected upload leaves the stored files untouched.
+ if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), []byte("not audio at all")); code != 400 {
+ t.Fatalf("text as audio must be refused, got %d", code)
+ }
+ if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), []byte("")); code != 400 {
+ t.Fatalf("svg as illustration must be refused, got %d", code)
+ }
+ big := make([]byte, maxAudioBytes+1024)
+ copy(big, "ID3")
+ if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), big); code != 413 {
+ t.Fatalf("an oversized audio must be refused with 413, got %d", code)
+ }
+ if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), testPNG(t, maxCoverPixels+1, 4)); code != 400 {
+ t.Fatalf("an oversized illustration must be refused, got %d", code)
+ }
+ recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil)
+ if !bytes.Equal(recorder.Body.Bytes(), smaller) {
+ t.Fatal("a refused upload must keep the previous audio")
+ }
+ recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil)
+ if !bytes.Equal(recorder.Body.Bytes(), illustration) {
+ t.Fatal("a refused upload must keep the previous illustration")
+ }
+
+ // Ownership: another account cannot read, replace, remove or report on this chapter.
+ if recorder = fetchRaw(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil); recorder.Code != 404 {
+ t.Fatalf("another account reading the audio must be 404, got %d", recorder.Code)
+ }
+ if recorder = fetchRaw(t, r, "", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), nil); recorder.Code != 401 {
+ t.Fatalf("an anonymous read must be 401, got %d", recorder.Code)
+ }
+ if code, _, _ = uploadAttachment(t, r, other.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), testPNG(t, 8, 8)); code != 404 {
+ t.Fatalf("another account uploading an illustration must be 404, got %d", code)
+ }
+ if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), other.Token, nil); code != 404 {
+ t.Fatalf("another account deleting the audio must be 404, got %d", code)
+ }
+ if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), other.Token, map[string]any{"positionSeconds": 10}); code != 404 {
+ t.Fatalf("another account reporting a position must be 404, got %d", code)
+ }
+ if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), "", map[string]any{"positionSeconds": 10}); code != 401 {
+ t.Fatal("reporting a position needs a session")
+ }
+
+ // Removing the audio removes the position with it, and the illustration is separate.
+ code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/chapters/%d/playback", chapterID), learner.Token, map[string]any{"positionSeconds": 42})
+ if code != 200 {
+ t.Fatalf("position before removal %d", code)
+ }
+ if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), learner.Token, nil); code != 200 {
+ t.Fatalf("audio removal %d", code)
+ }
+ var positions int64
+ if err := db.Model(&ChapterPlaybackPosition{}).Where("chapter_id = ?", chapterID).Count(&positions).Error; err != nil || positions != 0 {
+ t.Fatalf("removing the audio must remove the position: %d %v", positions, err)
+ }
+ if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), learner.Token, nil); code != 404 {
+ t.Fatal("removing a missing attachment must be 404")
+ }
+ if rows := attachmentRows(t, db, chapterID, attachmentIllustration); rows != 1 {
+ t.Fatalf("removing the audio must not touch the illustration: %d", rows)
+ }
+ if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil); recorder.Code != 200 {
+ t.Fatal("the illustration must still be readable")
+ }
+
+ // Deleting the chapter takes its remaining attachment with it.
+ code, msg, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/chapters/%d", chapterID), learner.Token, nil)
+ if code != 200 {
+ t.Fatalf("chapter delete: %d %s", code, msg)
+ }
+ var attachments int64
+ if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ?", chapterID).Count(&attachments).Error; err != nil || attachments != 0 {
+ t.Fatalf("deleting a chapter must remove its attachments: %d %v", attachments, err)
+ }
+}
+
+// TestMySQLBookCoverAndChapterUploadRules covers the cover behaviour that stayed on the book plus the
+// request shapes that must not be stored for a chapter.
+func TestMySQLBookCoverAndChapterUploadRules(t *testing.T) {
+ db, r, owner := libraryFixture(t)
+ learner := newLearner(t, r, owner.Token)
+ other := newLearner(t, r, owner.Token)
+ code, pasted := pasteBook(t, r, learner.Token, map[string]string{
+ "requestId": "chapter-attach-0002", "title": "Fictional attachment rules",
+ "text": "Curiosity opens the first door.\n", "language": "en"})
+ if code != 201 {
+ t.Fatalf("paste %d", code)
+ }
+ drainIngest(t, db)
+ bookID, chapterID := pasted.Chapter.BookID, pasted.Chapter.ID
+
+ // The cover stays a book-level attachment with the same caching behaviour.
cover := testPNG(t, 40, 25)
- code, msg, coverView := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), cover)
- if code != 200 || coverView.Kind != attachmentCover || coverView.Mime != "image/png" || coverView.ByteSize != len(cover) {
- t.Fatalf("cover upload: %d %s %+v", code, msg, coverView)
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, _ := writer.CreateFormFile("file", "cover.png")
+ part.Write(cover)
+ writer.Close()
+ request := httptest.NewRequest("POST", fmt.Sprintf("/api/v1/books/%d/cover", bookID), bytes.NewReader(body.Bytes()))
+ request.Header.Set("Content-Type", writer.FormDataContentType())
+ request.Header.Set("Authorization", "Bearer "+learner.Token)
+ recorder := httptest.NewRecorder()
+ r.ServeHTTP(recorder, request)
+ if recorder.Code != 200 {
+ t.Fatalf("cover upload: %d", recorder.Code)
}
- if coverView.Version != contentSHA(string(cover)) {
- t.Fatal("the cover version must be the content digest")
+ book, _ := bookDetailOf(t, r, learner.Token, bookID)
+ if book.Attachments == nil || book.Attachments.Cover == nil || book.Attachments.Cover.Version != contentSHA(string(cover)) {
+ t.Fatalf("the book must report its cover: %+v", book.Attachments)
}
code, _, listed := callRaw(t, r, "GET", "/api/v1/books", learner.Token, nil)
if code != 200 {
@@ -271,189 +503,41 @@ func TestMySQLBookAttachmentsAndPlayback(t *testing.T) {
if err := json.Unmarshal(listed, &page); err != nil {
t.Fatal(err)
}
- if len(page.Items) != 1 || page.Items[0].CoverVersion != coverView.Version || page.Items[0].HasAudio {
- t.Fatalf("book list attachments: %+v", page.Items)
+ if len(page.Items) != 1 || page.Items[0].CoverVersion != contentSHA(string(cover)) {
+ t.Fatalf("the book list must carry the cover version: %+v", page.Items)
+ }
+ if fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil).Code != 200 {
+ t.Fatal("the cover must be readable")
+ }
+ if fetchRaw(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil).Code != 404 {
+ t.Fatal("another account must not read the cover")
+ }
+ if code, _, _ := callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, nil); code != 200 {
+ t.Fatal("the book must be deletable")
+ }
+ var covers int64
+ if err := db.Model(&BookAttachment{}).Where("book_id = ?", bookID).Count(&covers).Error; err != nil || covers != 0 {
+ t.Fatalf("deleting the book must remove its cover: %d %v", covers, err)
}
- // The cover is served with its own content type and can be cached by version.
- recorder := fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil)
- if recorder.Code != 200 || recorder.Header().Get("Content-Type") != "image/png" || !bytes.Equal(recorder.Body.Bytes(), cover) {
- t.Fatalf("cover read: %d %s", recorder.Code, recorder.Header().Get("Content-Type"))
- }
- etag := recorder.Header().Get("ETag")
- if etag == "" {
- t.Fatal("the cover must carry an ETag")
- }
- if recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), map[string]string{"If-None-Match": etag}); recorder.Code != 304 {
- t.Fatalf("a matching ETag must answer 304, got %d", recorder.Code)
- }
-
- // An audio upload is readable in full and by Range, which is what the player needs.
- audio := testMP3(4096)
- code, msg, audioView := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), audio)
- if code != 200 || audioView.Kind != attachmentAudio || audioView.Mime != "audio/mpeg" {
- t.Fatalf("audio upload: %d %s %+v", code, msg, audioView)
- }
- book, _ = bookDetailOf(t, r, learner.Token, bookID)
- if book.Attachments.Audio == nil || book.Attachments.Audio.ByteSize != len(audio) {
- t.Fatalf("book detail audio: %+v", book.Attachments)
- }
- recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil)
- if recorder.Code != 200 || !bytes.Equal(recorder.Body.Bytes(), audio) || recorder.Header().Get("Accept-Ranges") == "" {
- t.Fatalf("audio read: %d ranges=%q bytes=%d", recorder.Code, recorder.Header().Get("Accept-Ranges"), recorder.Body.Len())
- }
- recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), map[string]string{"Range": "bytes=100-199"})
- if recorder.Code != 206 || recorder.Body.Len() != 100 {
- t.Fatalf("a range request must answer 206 with 100 bytes, got %d with %d", recorder.Code, recorder.Body.Len())
- }
- if contentRange := recorder.Header().Get("Content-Range"); contentRange == "" {
- t.Fatal("a range answer must carry Content-Range")
- }
- if !bytes.Equal(recorder.Body.Bytes(), audio[100:200]) {
- t.Fatal("the range must return the requested bytes")
- }
- recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), map[string]string{"Range": "bytes=999999-"})
- if recorder.Code != 416 {
- t.Fatalf("an unsatisfiable range must answer 416, got %d", recorder.Code)
- }
-
- // Playback position: only the caller's own position moves, and it is reported with the book.
- code, msg, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 125})
- if code != 200 {
- t.Fatalf("position update: %d %s", code, msg)
- }
- book, _ = bookDetailOf(t, r, learner.Token, bookID)
- if book.Attachments.PlaybackSeconds != 125 {
- t.Fatalf("playback position %d", book.Attachments.PlaybackSeconds)
- }
- // Another account cannot even see the book, so its position is unreachable.
- if code, _, _ = callRaw(t, r, "GET", fmt.Sprintf("/api/v1/books/%d", bookID), other.Token, nil); code != 404 {
- t.Fatalf("another account reading the book must be 404, got %d", code)
- }
- code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 300})
- if code != 200 {
- t.Fatalf("second position update %d", code)
- }
- book, _ = bookDetailOf(t, r, learner.Token, bookID)
- if book.Attachments.PlaybackSeconds != 300 {
- t.Fatalf("the position must be replaced, got %d", book.Attachments.PlaybackSeconds)
- }
- if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": -5}); code != 400 {
- t.Fatal("a negative position must be refused")
- }
- if code, _, _ := callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{}); code != 400 {
- t.Fatal("a position request without a value must be refused")
- }
-
- // Replacing the audio drops the old position: the new file has nothing to do with it.
- smaller := testMP3(2048)
- code, msg, replaced := uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), smaller)
- if code != 200 || replaced.ByteSize != len(smaller) || replaced.Version == audioView.Version {
- t.Fatalf("audio replacement: %d %s %+v", code, msg, replaced)
- }
- book, _ = bookDetailOf(t, r, learner.Token, bookID)
- if book.Attachments.PlaybackSeconds != 0 {
- t.Fatalf("a replacement must reset the position, got %d", book.Attachments.PlaybackSeconds)
- }
- var rows int64
- if err := db.Model(&BookAttachment{}).Where("book_id = ? AND kind = ?", bookID, attachmentAudio).Count(&rows).Error; err != nil || rows != 1 {
- t.Fatalf("a replacement must keep one row: %d %v", rows, err)
- }
-
- // A rejected upload leaves the stored file untouched.
- code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), []byte("not audio at all, just text"))
- if code != 400 {
- t.Fatalf("a text file must be refused, got %d", code)
- }
- recorder = fetchRaw(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil)
- if !bytes.Equal(recorder.Body.Bytes(), smaller) {
- t.Fatal("a refused upload must keep the previous file")
- }
- big := make([]byte, maxAudioBytes+1024)
- copy(big, "ID3")
- if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), big); code != 413 {
- t.Fatalf("an oversized audio must be refused with 413, got %d", code)
- }
- if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), testPNG(t, maxCoverPixels+1, 4)); code != 400 {
- t.Fatalf("an oversized image must be refused, got %d", code)
- }
-
- // Ownership: another account cannot read, replace, remove or report on this book.
- if recorder = fetchRaw(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil); recorder.Code != 404 {
- t.Fatalf("another account reading audio must be 404, got %d", recorder.Code)
- }
- if recorder = fetchRaw(t, r, "", fmt.Sprintf("/api/v1/books/%d/audio", bookID), nil); recorder.Code != 401 {
- t.Fatalf("an anonymous read must be 401, got %d", recorder.Code)
- }
- if code, _, _ = uploadAttachment(t, r, other.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), testPNG(t, 8, 8)); code != 404 {
- t.Fatalf("another account uploading a cover must be 404, got %d", code)
- }
- if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d/audio", bookID), other.Token, nil); code != 404 {
- t.Fatalf("another account deleting audio must be 404, got %d", code)
- }
- if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), other.Token, map[string]any{"positionSeconds": 10}); code != 404 {
- t.Fatalf("another account reporting a position must be 404, got %d", code)
- }
- if code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), "", map[string]any{"positionSeconds": 10}); code != 401 {
- t.Fatal("reporting a position needs a session")
- }
-
- // Removing the audio removes the position with it.
- code, _, _ = callRaw(t, r, "PUT", fmt.Sprintf("/api/v1/books/%d/playback", bookID), learner.Token, map[string]any{"positionSeconds": 42})
- if code != 200 {
- t.Fatalf("position before removal %d", code)
- }
- if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d/audio", bookID), learner.Token, nil); code != 200 {
- t.Fatalf("audio removal %d", code)
- }
- if err := db.Model(&PlaybackPosition{}).Where("book_id = ?", bookID).Count(&rows).Error; err != nil || rows != 0 {
- t.Fatalf("removing the audio must remove the position: %d %v", rows, err)
- }
- if code, _, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d/audio", bookID), learner.Token, nil); code != 404 {
- t.Fatal("removing a missing attachment must be 404")
- }
-
- // Deleting the book takes its remaining attachments with it.
- code, msg, _ = callRaw(t, r, "DELETE", fmt.Sprintf("/api/v1/books/%d", bookID), learner.Token, nil)
- if code != 200 {
- t.Fatalf("book delete: %d %s", code, msg)
- }
- var attachments int64
- if err := db.Model(&BookAttachment{}).Where("book_id = ?", bookID).Count(&attachments).Error; err != nil || attachments != 0 {
- t.Fatalf("deleting a book must remove its attachments: %d %v", attachments, err)
- }
-}
-
-// TestMySQLAttachmentUploadRejectsBadRequests covers the request shapes that must not be stored.
-func TestMySQLAttachmentUploadRejectsBadRequests(t *testing.T) {
- db, r, owner := libraryFixture(t)
- learner := newLearner(t, r, owner.Token)
- code, pasted := pasteBook(t, r, learner.Token, map[string]string{
- "requestId": "attach-0002", "title": "Fictional attachment rules", "text": "Curiosity opens the first door.\n", "language": "en"})
- if code != 201 {
- t.Fatalf("paste %d", code)
- }
- drainIngest(t, db)
- bookID := pasted.Chapter.BookID
-
- if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover", bookID), nil); code != 400 {
+ // Request shapes that must not be stored.
+ if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/illustration", chapterID), nil); code != 400 {
t.Fatalf("an empty upload must be refused, got %d", code)
}
- if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/audio", bookID), []byte{}); code != 400 {
+ if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), []byte{}); code != 400 {
t.Fatalf("an empty audio must be refused, got %d", code)
}
- if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/books/%d/cover?extra=1", bookID), testPNG(t, 8, 8)); code != 400 {
+ if code, _, _ = uploadAttachment(t, r, learner.Token, fmt.Sprintf("/api/v1/chapters/%d/audio?extra=1", chapterID), testMP3(16)); code != 400 {
t.Fatal("an upload with query parameters must be refused")
}
- if code, _, _ = uploadAttachment(t, r, learner.Token, "/api/v1/books/999999/cover", testPNG(t, 8, 8)); code != 404 {
- t.Fatal("an unknown book must be 404")
+ if code, _, _ = uploadAttachment(t, r, learner.Token, "/api/v1/chapters/999999/audio", testMP3(16)); code != 404 {
+ t.Fatal("an unknown chapter must be 404")
}
- if code, _, _ = uploadAttachment(t, r, "", fmt.Sprintf("/api/v1/books/%d/cover", bookID), testPNG(t, 8, 8)); code != 401 {
+ if code, _, _ = uploadAttachment(t, r, "", fmt.Sprintf("/api/v1/chapters/%d/audio", chapterID), testMP3(16)); code != 401 {
t.Fatal("uploading needs a session")
}
- // The rejections must not have stored anything for this book.
- var attachments int64
- if err := db.Model(&BookAttachment{}).Where("book_id = ?", bookID).Count(&attachments).Error; err != nil || attachments != 0 {
- t.Fatalf("nothing may be stored for this book: %d %v", attachments, err)
+ var stored int64
+ if err := db.Model(&ChapterAttachment{}).Where("chapter_id = ?", chapterID).Count(&stored).Error; err != nil || stored != 0 {
+ t.Fatalf("nothing may be stored for this chapter: %d %v", stored, err)
}
}
diff --git a/server/app/lexgo/database.go b/server/app/lexgo/database.go
index c0aeb40..2716175 100644
--- a/server/app/lexgo/database.go
+++ b/server/app/lexgo/database.go
@@ -87,6 +87,9 @@ func Migrate(db *gorm.DB) error {
if current < 8 {
statements = append(statements, schemaV8Statements...)
}
+ if current < 9 {
+ statements = append(statements, schemaV9Statements...)
+ }
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
@@ -102,7 +105,39 @@ func Migrate(db *gorm.DB) error {
// SchemaVersion is the version an explicit migration leaves behind, and the
// version the server requires before it starts.
-const SchemaVersion = 8
+const SchemaVersion = 9
+
+// v9 moves audio and adds illustrations to the chapter, where a multi-article book needs them, and
+// keeps the cover on the book. Both new tables are additive, so the version marker is the only
+// thing a binary rollback has to change. The last statement removes the retired book-level audio
+// rows; it is idempotent and part of the same version, so a retry after a partial run leaves the
+// same result.
+var schemaV9Statements = []string{
+ `CREATE TABLE IF NOT EXISTS lexgo_chapter_attachments (
+ chapter_id BIGINT UNSIGNED NOT NULL,
+ kind VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ owner_id BIGINT NOT NULL,
+ mime VARCHAR(64) NOT NULL, byte_size INT NOT NULL,
+ sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
+ bytes MEDIUMBLOB NOT NULL,
+ created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL,
+ PRIMARY KEY (chapter_id, kind),
+ CHECK (kind IN ('audio','illustration')),
+ FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE,
+ FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
+ `CREATE TABLE IF NOT EXISTS lexgo_chapter_playback_positions (
+ owner_id BIGINT NOT NULL, chapter_id BIGINT UNSIGNED NOT NULL,
+ position_seconds INT NOT NULL DEFAULT 0, updated_at DATETIME(3) NOT NULL,
+ PRIMARY KEY (owner_id, chapter_id),
+ CHECK (position_seconds >= 0),
+ FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE,
+ FOREIGN KEY (chapter_id) REFERENCES lexgo_chapters(id) ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
+ // Book-level audio is retired: the file belongs to a chapter now. Only the test-era rows can
+ // exist here, and the ticket records that they are not migrated.
+ `DELETE FROM lexgo_book_attachments WHERE kind = 'audio'`,
+}
// v8 adds the optional book attachments and the per-learner playback position. Both are additive
// tables, so an older binary can still be rolled back to by writing the version marker back.
diff --git a/server/app/lexgo/library.go b/server/app/lexgo/library.go
index 00e070e..cae3ae8 100644
--- a/server/app/lexgo/library.go
+++ b/server/app/lexgo/library.go
@@ -153,9 +153,14 @@ type ChapterSummary struct {
ErrorMessage string `json:"errorMessage"`
// ReadAt is set only where the completion was resolved and still matches the chapter's
// current content; a chapter edited into a newer version reads as unread again.
- ReadAt *time.Time `json:"readAt"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
+ ReadAt *time.Time `json:"readAt"`
+ // IllustrationVersion and AudioVersion are content digests; they are always present and empty
+ // when that file is absent, so a client never has to tell "missing" from "empty".
+ IllustrationVersion string `json:"illustrationVersion"`
+ AudioVersion string `json:"audioVersion"`
+ PlaybackSeconds int `json:"playbackSeconds"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
}
type ChapterView struct {
@@ -582,6 +587,14 @@ func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary
if err != nil {
return BookRef{}, nil, err
}
+ attachments, err := chapterAttachmentViews(db, owner, ids)
+ if err != nil {
+ return BookRef{}, nil, err
+ }
+ positions, err := chapterPlaybackSeconds(db, owner, ids)
+ if err != nil {
+ return BookRef{}, nil, err
+ }
for _, c := range chapters {
var jobID *int64
if id, ok := jobs[c.ID]; ok {
@@ -591,6 +604,9 @@ func BookDetail(db *gorm.DB, owner int, bookID int64) (BookRef, []ChapterSummary
if at, ok := marks[c.ID]; ok {
summary.ReadAt = &at
}
+ summary.IllustrationVersion = attachments[c.ID][attachmentIllustration].Version
+ summary.AudioVersion = attachments[c.ID][attachmentAudio].Version
+ summary.PlaybackSeconds = positions[c.ID]
items = append(items, summary)
}
ref := bookRef(book)
@@ -663,12 +679,23 @@ func ChapterDetail(db *gorm.DB, owner int, chapterID int64) (ReaderResponse, err
if at, ok := marks[chapter.ID]; ok {
view.ReadAt = &at
}
- // The reader shows the book's audio player and the stored position, so the response carries the
- // attachment metadata for the book, not only for the book page.
+ // The reader shows the chapter's illustration and audio, so this response carries the chapter's
+ // attachment metadata; the book keeps its cover.
ref := bookRef(book)
if err = attachToBookRef(db, owner, &ref); err != nil {
return ReaderResponse{}, err
}
+ attachments, err := chapterAttachmentViews(db, owner, []int64{chapter.ID})
+ if err != nil {
+ return ReaderResponse{}, err
+ }
+ positions, err := chapterPlaybackSeconds(db, owner, []int64{chapter.ID})
+ if err != nil {
+ return ReaderResponse{}, err
+ }
+ view.IllustrationVersion = attachments[chapter.ID][attachmentIllustration].Version
+ view.AudioVersion = attachments[chapter.ID][attachmentAudio].Version
+ view.PlaybackSeconds = positions[chapter.ID]
return ReaderResponse{Book: ref, Chapter: view, Navigation: navigation}, nil
}
diff --git a/server/app/lexgo/migration_test.go b/server/app/lexgo/migration_test.go
index 12728c1..b7b4e06 100644
--- a/server/app/lexgo/migration_test.go
+++ b/server/app/lexgo/migration_test.go
@@ -501,8 +501,15 @@ func TestMigrationFromV7AddsBookAttachments(t *testing.T) {
if err := db.Create(&unknown).Error; err == nil {
t.Fatal("an unknown attachment kind must be rejected")
}
- position := PlaybackPosition{OwnerID: 31, BookID: book.ID, PositionSeconds: 12, UpdatedAt: saved}
- if err := db.Create(&position).Error; err != nil {
+ // A cover row, so a later check can show that retiring book-level audio leaves covers alone.
+ if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
+ book.ID, attachmentCover, 31, "image/png", 4, contentSHA("png"), []byte("png!"), saved, saved).Error; err != nil {
+ t.Fatal(err)
+ }
+ // The v8 table is written with raw SQL: the Go type for it was retired in v9, but the migration
+ // this test covers is the one that created it.
+ if err := db.Exec("INSERT INTO lexgo_playback_positions (owner_id, book_id, position_seconds, updated_at) VALUES (?,?,?,?)",
+ 31, book.ID, 12, saved).Error; err != nil {
t.Fatal(err)
}
// Rolling the marker back for a binary rollback and upgrading again keeps the rows.
@@ -512,12 +519,19 @@ func TestMigrationFromV7AddsBookAttachments(t *testing.T) {
if err := Migrate(db); err != nil {
t.Fatal(err)
}
- if err := db.Model(&BookAttachment{}).Count(&attachments).Error; err != nil || attachments != 1 {
- t.Fatalf("re-upgrade must keep the attachment: %d %v", attachments, err)
+ // Re-upgrading now also applies v9, which retires book-level audio: the audio row this test
+ // created is removed by that step while the cover row stays.
+ var audioLeft int64
+ if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentAudio).Count(&audioLeft).Error; err != nil || audioLeft != 0 {
+ t.Fatalf("re-upgrading into v9 must clear book-level audio: %d %v", audioLeft, err)
}
+ if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentCover).Count(&attachments).Error; err != nil || attachments != 1 {
+ t.Fatalf("re-upgrade must keep the cover: %d %v", attachments, err)
+ }
+ // The retired position table keeps its row: it is no longer written, but nothing deletes it.
var positions int64
- if err := db.Model(&PlaybackPosition{}).Count(&positions).Error; err != nil || positions != 1 {
- t.Fatalf("re-upgrade must keep the position: %d %v", positions, err)
+ if err := db.Table("lexgo_playback_positions").Where("book_id = ?", book.ID).Count(&positions).Error; err != nil || positions != 1 {
+ t.Fatalf("re-upgrade must keep the retired position row: %d %v", positions, err)
}
// Deleting the book removes both, so no attachment or position can outlive its book.
if err := db.Where("id = ?", book.ID).Delete(&Book{}).Error; err != nil {
@@ -526,7 +540,115 @@ func TestMigrationFromV7AddsBookAttachments(t *testing.T) {
if err := db.Model(&BookAttachment{}).Count(&attachments).Error; err != nil || attachments != 0 {
t.Fatalf("deleting a book must remove its attachments: %d %v", attachments, err)
}
- if err := db.Model(&PlaybackPosition{}).Count(&positions).Error; err != nil || positions != 0 {
+ if err := db.Table("lexgo_playback_positions").Where("book_id = ?", book.ID).Count(&positions).Error; err != nil || positions != 0 {
t.Fatalf("deleting a book must remove its positions: %d %v", positions, err)
}
}
+
+func TestMigrationFromV8AddsChapterAttachments(t *testing.T) {
+ db := emptyMigrationDB(t)
+ statements := []string{"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))", "INSERT INTO lexgo_schema VALUES (1,8,'lexgo')"}
+ statements = append(statements, schemaV2Statements...)
+ statements = append(statements, schemaV3Statements...)
+ statements = append(statements, schemaV4Statements...)
+ statements = append(statements, schemaV5Statements...)
+ statements = append(statements, schemaV6Statements...)
+ statements = append(statements, schemaV7Statements...)
+ statements = append(statements, schemaV8Statements...)
+ for _, statement := range statements {
+ if err := db.Exec(statement).Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := db.Exec("INSERT INTO sys_user (user_id,username,password,role_id) VALUES (41,'fixture_v8','fictional-not-a-real-hash',2)").Error; err != nil {
+ t.Fatal(err)
+ }
+ saved := stamp(time.Now())
+ book := Book{OwnerID: 41, Title: "Fictional v8 book", Language: "en", CreatedAt: saved, UpdatedAt: saved}
+ if err := db.Create(&book).Error; err != nil {
+ t.Fatal(err)
+ }
+ text := "Curiosity opens the first door.\n"
+ chapter := Chapter{BookID: book.ID, OwnerID: 41, Ordinal: 1, Title: "Fictional v8",
+ OriginalText: text, ContentSHA256: contentSHA(text), CharCount: len([]rune(text)), Status: statusReady,
+ CreatedAt: saved, UpdatedAt: saved}
+ if err := db.Create(&chapter).Error; err != nil {
+ t.Fatal(err)
+ }
+ // A book-level audio row and a cover row, as the retired version would have left them.
+ if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
+ book.ID, attachmentAudio, 41, "audio/mpeg", 4, contentSHA("ID3x"), []byte("ID3x"), saved, saved).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Exec("INSERT INTO lexgo_book_attachments (book_id, kind, owner_id, mime, byte_size, sha256, bytes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)",
+ book.ID, attachmentCover, 41, "image/png", 4, contentSHA("png"), []byte("png!"), saved, saved).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ if err := CheckSchema(db); err == nil {
+ t.Fatal("old schema accepted before explicit migration")
+ }
+ if err := Migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ if err := CheckSchema(db); err != nil {
+ t.Fatal(err)
+ }
+
+ // The upgrade removes the retired book-level audio rows and keeps the cover.
+ var audioRows int64
+ if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentAudio).Count(&audioRows).Error; err != nil || audioRows != 0 {
+ t.Fatalf("the migration must clear book-level audio: %d %v", audioRows, err)
+ }
+ var coverRows int64
+ if err := db.Model(&BookAttachment{}).Where("kind = ?", attachmentCover).Count(&coverRows).Error; err != nil || coverRows != 1 {
+ t.Fatalf("the migration must keep the cover: %d %v", coverRows, err)
+ }
+ // The new tables start empty and hold one row per chapter and kind.
+ var chapterRows int64
+ if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 0 {
+ t.Fatalf("an upgrade must not invent chapter attachments: %d %v", chapterRows, err)
+ }
+ entry := ChapterAttachment{ChapterID: chapter.ID, Kind: attachmentAudio, OwnerID: 41, Mime: "audio/mpeg",
+ ByteSize: 4, SHA256: contentSHA("ID3x"), Bytes: []byte("ID3x"), CreatedAt: saved, UpdatedAt: saved}
+ if err := db.Create(&entry).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Create(&entry).Error; err == nil {
+ t.Fatal("a chapter must hold only one attachment per kind")
+ }
+ unknown := entry
+ unknown.Kind = "video"
+ if err := db.Create(&unknown).Error; err == nil {
+ t.Fatal("an unknown attachment kind must be rejected")
+ }
+ position := ChapterPlaybackPosition{OwnerID: 41, ChapterID: chapter.ID, PositionSeconds: 7, UpdatedAt: saved}
+ if err := db.Create(&position).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ // Rolling the marker back for a binary rollback and upgrading again keeps the rows.
+ if err := db.Exec("UPDATE lexgo_schema SET version=8 WHERE id=1").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := Migrate(db); err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 1 {
+ t.Fatalf("re-upgrade must keep the chapter attachment: %d %v", chapterRows, err)
+ }
+ var positions int64
+ if err := db.Model(&ChapterPlaybackPosition{}).Count(&positions).Error; err != nil || positions != 1 {
+ t.Fatalf("re-upgrade must keep the chapter position: %d %v", positions, err)
+ }
+ // Deleting the chapter removes both, so nothing can outlive its chapter.
+ if err := db.Where("id = ?", chapter.ID).Delete(&Chapter{}).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := db.Model(&ChapterAttachment{}).Count(&chapterRows).Error; err != nil || chapterRows != 0 {
+ t.Fatalf("deleting a chapter must remove its attachments: %d %v", chapterRows, err)
+ }
+ if err := db.Model(&ChapterPlaybackPosition{}).Count(&positions).Error; err != nil || positions != 0 {
+ t.Fatalf("deleting a chapter must remove its positions: %d %v", positions, err)
+ }
+}
diff --git a/server/app/lexgo/ops.go b/server/app/lexgo/ops.go
index 6f56017..f100a69 100644
--- a/server/app/lexgo/ops.go
+++ b/server/app/lexgo/ops.go
@@ -35,6 +35,7 @@ var OpsTables = []string{
"lexgo_books", "lexgo_chapters", "lexgo_ingest_jobs", "lexgo_dictionaries",
"lexgo_terms", "lexgo_term_reviews", "lexgo_review_answers", "lexgo_chapter_progress",
"lexgo_book_attachments", "lexgo_playback_positions",
+ "lexgo_chapter_attachments", "lexgo_chapter_playback_positions",
}
var opsSystemSchemas = map[string]bool{
@@ -556,6 +557,17 @@ func OpsVerify(client *gorm.DB, database string, manifestPath string) ([]OpsChec
if err := linked("SELECT COUNT(*) FROM "+positions+" p LEFT JOIN "+book+" b ON b.id = p.book_id WHERE b.id IS NULL", "播放位置都指向存在的书"); err != nil {
return checks, err
}
+ // Chapter attachments and their positions must belong to a chapter that still exists.
+ chapter_attachments, chapter_positions := table("lexgo_chapter_attachments"), table("lexgo_chapter_playback_positions")
+ if err := linked("SELECT COUNT(*) FROM "+chapter_attachments+" a LEFT JOIN "+chapter+" c ON c.id = a.chapter_id WHERE c.id IS NULL", "章节附件都指向存在的章节"); err != nil {
+ return checks, err
+ }
+ if err := linked("SELECT COUNT(*) FROM "+chapter_attachments+" a JOIN "+chapter+" c ON c.id = a.chapter_id WHERE a.owner_id <> c.owner_id", "章节附件归属与章节归属一致"); err != nil {
+ return checks, err
+ }
+ if err := linked("SELECT COUNT(*) FROM "+chapter_positions+" p LEFT JOIN "+chapter+" c ON c.id = p.chapter_id WHERE c.id IS NULL", "章节播放位置都指向存在的章节"); err != nil {
+ return checks, err
+ }
// The audit tables must not gain a column that could hold a credential or private content.
for _, name := range []string{"lexgo_login_logs", "lexgo_operation_logs"} {