mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Compare commits
5 Commits
12d0898585
...
dc07dc413d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc07dc413d | ||
|
|
3294bcacfc | ||
|
|
228211f925 | ||
|
|
a6a682b385 | ||
|
|
c40f12e65b |
20
.github/workflows/pipeline.yml
vendored
20
.github/workflows/pipeline.yml
vendored
@ -25,7 +25,7 @@ jobs:
|
||||
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
|
||||
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
@ -63,7 +63,7 @@ jobs:
|
||||
name: Lint Go code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
@ -71,7 +71,7 @@ jobs:
|
||||
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
@ -93,7 +93,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download TagLib
|
||||
uses: ./.github/actions/download-taglib
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
@ -145,7 +145,7 @@ jobs:
|
||||
name: Lint i18n files
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- run: |
|
||||
set -e
|
||||
for file in resources/i18n/*.json; do
|
||||
@ -191,7 +191,7 @@ jobs:
|
||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare Docker Buildx
|
||||
uses: ./.github/actions/prepare-docker
|
||||
@ -264,7 +264,7 @@ jobs:
|
||||
env:
|
||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v6
|
||||
@ -318,7 +318,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
@ -352,7 +352,7 @@ jobs:
|
||||
outputs:
|
||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'navidrome' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get updated translations
|
||||
id: poeditor
|
||||
env:
|
||||
|
||||
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
@ -19,13 +18,13 @@ import (
|
||||
var (
|
||||
fullScan bool
|
||||
subprocess bool
|
||||
targets string
|
||||
targets []string
|
||||
)
|
||||
|
||||
func init() {
|
||||
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
||||
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
|
||||
scanCmd.Flags().StringVarP(&targets, "targets", "t", "", "comma-separated list of libraryID:folderPath pairs (e.g., \"1:Music/Rock,1:Music/Jazz,2:Classical\")")
|
||||
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
|
||||
rootCmd.AddCommand(scanCmd)
|
||||
}
|
||||
|
||||
@ -74,9 +73,9 @@ func runScanner(ctx context.Context) {
|
||||
|
||||
// Parse targets if provided
|
||||
var scanTargets []model.ScanTarget
|
||||
if targets != "" {
|
||||
if len(targets) > 0 {
|
||||
var err error
|
||||
scanTargets, err = model.ParseTargets(strings.Split(targets, ","))
|
||||
scanTargets, err = model.ParseTargets(targets)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to parse targets", err)
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE annotation ADD COLUMN rated_at datetime;
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
|
||||
@ -6,6 +6,7 @@ type Annotations struct {
|
||||
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
|
||||
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
|
||||
Rating int `structs:"rating" json:"rating,omitempty" `
|
||||
RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
|
||||
Starred bool `structs:"starred" json:"starred,omitempty" `
|
||||
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
|
||||
}
|
||||
|
||||
@ -44,6 +44,7 @@ var fieldMap = map[string]*mappedField{
|
||||
"loved": {field: "COALESCE(annotation.starred, false)"},
|
||||
"dateloved": {field: "annotation.starred_at"},
|
||||
"lastplayed": {field: "annotation.play_date"},
|
||||
"daterated": {field: "annotation.rated_at"},
|
||||
"playcount": {field: "COALESCE(annotation.play_count, 0)"},
|
||||
"rating": {field: "COALESCE(annotation.rating, 0)"},
|
||||
"mbz_album_id": {field: "media_file.mbz_album_id"},
|
||||
|
||||
@ -106,6 +106,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
|
||||
"random": "random",
|
||||
"recently_added": recentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
"rated_at": "rating, rated_at",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@ -141,6 +141,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
|
||||
r.setSortMappings(map[string]string{
|
||||
"name": "order_artist_name",
|
||||
"starred_at": "starred, starred_at",
|
||||
"rated_at": "rating, rated_at",
|
||||
"song_count": "stats->>'total'->>'m'",
|
||||
"album_count": "stats->>'total'->>'a'",
|
||||
"size": "stats->>'total'->>'s'",
|
||||
|
||||
@ -84,6 +84,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
|
||||
"created_at": "media_file.created_at",
|
||||
"recently_added": mediaFileRecentlyAddedSort(),
|
||||
"starred_at": "starred, starred_at",
|
||||
"rated_at": "rating, rated_at",
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@ -388,6 +388,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
|
||||
"coalesce(play_count, 0) as play_count",
|
||||
"play_date",
|
||||
"coalesce(rating, 0) as rating",
|
||||
"rated_at",
|
||||
"f.*",
|
||||
"playlist_tracks.*",
|
||||
"library.path as library_path",
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
@ -11,13 +10,14 @@ import (
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pocketbase/dbx"
|
||||
)
|
||||
|
||||
var _ = Describe("PlaylistRepository", func() {
|
||||
var repo model.PlaylistRepository
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(context.TODO())
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
|
||||
})
|
||||
@ -252,4 +252,118 @@ var _ = Describe("PlaylistRepository", func() {
|
||||
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Smart Playlists with Tag Criteria", func() {
|
||||
var mfRepo model.MediaFileRepository
|
||||
var testPlaylistID string
|
||||
var songWithGrouping, songWithoutGrouping model.MediaFile
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx := log.NewContext(GinkgoT().Context())
|
||||
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||
mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder())
|
||||
|
||||
// Register 'grouping' as a valid tag for smart playlists
|
||||
criteria.AddTagNames([]string{"grouping"})
|
||||
|
||||
// Create a song with the grouping tag
|
||||
songWithGrouping = model.MediaFile{
|
||||
ID: "test-grouping-1",
|
||||
Title: "Song With Grouping",
|
||||
Artist: "Test Artist",
|
||||
ArtistID: "1",
|
||||
Album: "Test Album",
|
||||
AlbumID: "101",
|
||||
Path: "/test/grouping/song1.mp3",
|
||||
Tags: model.Tags{
|
||||
"grouping": []string{"My Crate"},
|
||||
},
|
||||
Participants: model.Participants{},
|
||||
LibraryID: 1,
|
||||
Lyrics: "[]",
|
||||
}
|
||||
Expect(mfRepo.Put(&songWithGrouping)).To(Succeed())
|
||||
|
||||
// Create a song without the grouping tag
|
||||
songWithoutGrouping = model.MediaFile{
|
||||
ID: "test-grouping-2",
|
||||
Title: "Song Without Grouping",
|
||||
Artist: "Test Artist",
|
||||
ArtistID: "1",
|
||||
Album: "Test Album",
|
||||
AlbumID: "101",
|
||||
Path: "/test/grouping/song2.mp3",
|
||||
Tags: model.Tags{},
|
||||
Participants: model.Participants{},
|
||||
LibraryID: 1,
|
||||
Lyrics: "[]",
|
||||
}
|
||||
Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed())
|
||||
})
|
||||
|
||||
AfterEach(func() {
|
||||
if testPlaylistID != "" {
|
||||
_ = repo.Delete(testPlaylistID)
|
||||
testPlaylistID = ""
|
||||
}
|
||||
// Clean up test media files
|
||||
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute()
|
||||
_, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute()
|
||||
})
|
||||
|
||||
It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() {
|
||||
By("creating a smart playlist that checks if grouping tag has any value")
|
||||
// This is the workaround for issue #4728: using 'contains' with empty string
|
||||
// generates SQL: value LIKE '%%' which matches any non-empty string
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.Contains{"grouping": ""},
|
||||
},
|
||||
}
|
||||
newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("refreshing the smart playlist")
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
|
||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
By("verifying only the track with grouping tag is matched")
|
||||
Expect(pls.Tracks).To(HaveLen(1))
|
||||
Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID))
|
||||
})
|
||||
|
||||
It("excludes tracks with a tag value using 'notContains' with empty string", func() {
|
||||
By("creating a smart playlist that checks if grouping tag is NOT set")
|
||||
rules := &criteria.Criteria{
|
||||
Expression: criteria.All{
|
||||
criteria.NotContains{"grouping": ""},
|
||||
},
|
||||
}
|
||||
newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules}
|
||||
Expect(repo.Put(&newPls)).To(Succeed())
|
||||
testPlaylistID = newPls.ID
|
||||
|
||||
By("refreshing the smart playlist")
|
||||
conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh
|
||||
pls, err := repo.GetWithTracks(newPls.ID, true, false)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
By("verifying the track with grouping is NOT in the playlist")
|
||||
for _, track := range pls.Tracks {
|
||||
Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID))
|
||||
}
|
||||
|
||||
By("verifying the track without grouping IS in the playlist")
|
||||
var foundWithoutGrouping bool
|
||||
for _, track := range pls.Tracks {
|
||||
if track.MediaFileID == songWithoutGrouping.ID {
|
||||
foundWithoutGrouping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
Expect(foundWithoutGrouping).To(BeTrue())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -97,6 +97,7 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
|
||||
"coalesce(rating, 0) as rating",
|
||||
"starred_at",
|
||||
"play_date",
|
||||
"rated_at",
|
||||
"f.*",
|
||||
"playlist_tracks.*",
|
||||
).
|
||||
|
||||
@ -28,6 +28,7 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
|
||||
"coalesce(rating, 0) as rating",
|
||||
"starred_at",
|
||||
"play_date",
|
||||
"rated_at",
|
||||
)
|
||||
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
|
||||
query = query.Columns(
|
||||
@ -77,7 +78,8 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
|
||||
}
|
||||
|
||||
func (r sqlRepository) SetRating(rating int, itemID string) error {
|
||||
return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
|
||||
ratedAt := time.Now()
|
||||
return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
|
||||
}
|
||||
|
||||
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
|
||||
|
||||
@ -8,12 +8,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
|
||||
@ -47,9 +45,10 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
|
||||
|
||||
// Add targets if provided
|
||||
if len(targets) > 0 {
|
||||
targetsStr := strings.Join(slice.Map(targets, func(t model.ScanTarget) string { return t.String() }), ",")
|
||||
args = append(args, "--targets", targetsStr)
|
||||
log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targetsStr)
|
||||
for _, target := range targets {
|
||||
args = append(args, "-t", target.String())
|
||||
}
|
||||
log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets)
|
||||
} else {
|
||||
log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
import { DateField as RADateField } from 'react-admin'
|
||||
|
||||
export const DateField = (props) => {
|
||||
const { record, source } = props
|
||||
const value = record?.[source]
|
||||
if (value === '0001-01-01T00:00:00Z' || value === null) return null
|
||||
if (!isDateSet(value)) return null
|
||||
return <RADateField {...props} />
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useToggleLove } from './useToggleLove'
|
||||
import { useRecordContext } from 'react-admin'
|
||||
import config from '../config'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
love: {
|
||||
@ -46,8 +47,13 @@ export const LoveButton = ({
|
||||
<Button
|
||||
onClick={handleToggleLove}
|
||||
size={'small'}
|
||||
disabled={disabled || loading || record?.missing}
|
||||
disabled={disabled || loading || record.missing}
|
||||
className={classes.love}
|
||||
title={
|
||||
isDateSet(record.starredAt)
|
||||
? new Date(record.starredAt).toLocaleString()
|
||||
: undefined
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{record.starred ? (
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import Rating from '@material-ui/lab/Rating'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { isDateSet } from '../utils/validations'
|
||||
import StarBorderIcon from '@material-ui/icons/StarBorder'
|
||||
import clsx from 'clsx'
|
||||
import { useRating } from './useRating'
|
||||
@ -45,7 +46,14 @@ export const RatingField = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<span onClick={(e) => stopPropagation(e)}>
|
||||
<span
|
||||
onClick={(e) => stopPropagation(e)}
|
||||
title={
|
||||
isDateSet(record.ratedAt)
|
||||
? new Date(record.ratedAt).toLocaleString()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Rating
|
||||
name={record.mediaFileId || record.id}
|
||||
className={clsx(
|
||||
|
||||
@ -10,3 +10,16 @@ export const urlValidate = (value) => {
|
||||
return 'ra.validation.url'
|
||||
}
|
||||
}
|
||||
|
||||
export function isDateSet(date) {
|
||||
if (!date) {
|
||||
return false
|
||||
}
|
||||
if (typeof date === 'string') {
|
||||
return date !== '0001-01-01T00:00:00Z'
|
||||
}
|
||||
if (date instanceof Date) {
|
||||
return date.toISOString() !== '0001-01-01T00:00:00Z'
|
||||
}
|
||||
return !!date
|
||||
}
|
||||
|
||||
73
ui/src/utils/validations.test.js
Normal file
73
ui/src/utils/validations.test.js
Normal file
@ -0,0 +1,73 @@
|
||||
import { isDateSet, urlValidate } from './validations'
|
||||
|
||||
describe('urlValidate', () => {
|
||||
it('returns undefined for valid URLs', () => {
|
||||
expect(urlValidate('https://example.com')).toBeUndefined()
|
||||
expect(urlValidate('http://localhost:3000')).toBeUndefined()
|
||||
expect(urlValidate('ftp://files.example.com')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty values', () => {
|
||||
expect(urlValidate('')).toBeUndefined()
|
||||
expect(urlValidate(null)).toBeUndefined()
|
||||
expect(urlValidate(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns error for invalid URLs', () => {
|
||||
expect(urlValidate('not-a-url')).toEqual('ra.validation.url')
|
||||
expect(urlValidate('example.com')).toEqual('ra.validation.url')
|
||||
expect(urlValidate('://missing-protocol')).toEqual('ra.validation.url')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isDateSet', () => {
|
||||
describe('with falsy values', () => {
|
||||
it('returns false for null', () => {
|
||||
expect(isDateSet(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isDateSet(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isDateSet('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Go zero date string', () => {
|
||||
it('returns false for Go zero date', () => {
|
||||
expect(isDateSet('0001-01-01T00:00:00Z')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with valid date strings', () => {
|
||||
it('returns true for ISO date strings', () => {
|
||||
expect(isDateSet('2024-01-15T10:30:00Z')).toBe(true)
|
||||
expect(isDateSet('2023-12-25T00:00:00Z')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for other date formats', () => {
|
||||
expect(isDateSet('2024-01-15')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with Date objects', () => {
|
||||
it('returns true for valid Date objects', () => {
|
||||
expect(isDateSet(new Date())).toBe(true)
|
||||
expect(isDateSet(new Date('2024-01-15T10:30:00Z'))).toBe(true)
|
||||
})
|
||||
|
||||
// Note: Date objects representing Go zero date would return true because
|
||||
// toISOString() adds milliseconds (0001-01-01T00:00:00.000Z).
|
||||
// In practice, dates from the API come as strings, not Date objects,
|
||||
// so this edge case doesn't occur.
|
||||
})
|
||||
|
||||
describe('with other truthy values', () => {
|
||||
it('returns true for non-date truthy values', () => {
|
||||
expect(isDateSet(123)).toBe(true)
|
||||
expect(isDateSet({})).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user